Skip to content

Commit 518d56d

Browse files
committed
feat(vpc): release inactive VNI allocations safely
Support for changing an existing VPC's routing profile will allocate a VNI from the destination profile before switching the VPC to it. Core will keep the previous profile's VNI allocated while agents converge so an operator can roll back. The VPC always has an active VNI and temporarily has a second, inactive allocation. Add an explicit `release_inactive_vni` action that requires the current VPC version and verifies that the VPC owns one allocation matching its active VNI and one different allocation in the other routing profile pool. Core advances the VPC version and releases only the inactive allocation in the same database transaction. The response identifies the released VNI so clients can distinguish cleanup from an older Core that ignored the new request field. VPC deletion now releases every VNI allocated to the exact VPC owner in both pools instead of deriving one pool from the current profile. This prevents retained allocations from leaking while preserving allocations owned by other VPCs. This supports #5814 Signed-off-by: Chet Nichols III <chetn@nvidia.com>
1 parent f40026a commit 518d56d

8 files changed

Lines changed: 765 additions & 57 deletions

File tree

crates/admin-cli/src/rpc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2387,6 +2387,7 @@ impl ApiClient {
23872387
default_nvlink_logical_partition_id: None,
23882388
routing_profile_overrides: None,
23892389
power_resource_group: None,
2390+
release_inactive_vni: None,
23902391
};
23912392
self.0
23922393
.update_vpc(request)

crates/api-core/src/handlers/vpc.rs

Lines changed: 191 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,24 @@ pub(crate) async fn update(
164164
request: Request<rpc::VpcUpdateRequest>,
165165
) -> Result<Response<rpc::VpcUpdateResult>, Status> {
166166
log_request_data(&request);
167+
let request = request.into_inner();
168+
let cleanup_metadata_supplied = request.metadata.is_some();
169+
let cleanup_nsg_supplied = request.network_security_group_id.is_some();
170+
let cleanup_requested = request.release_inactive_vni.unwrap_or(false);
171+
if cleanup_requested
172+
&& (request.default_nvlink_logical_partition_id.is_some()
173+
|| request.routing_profile_overrides.is_some()
174+
|| request.power_resource_group.is_some())
175+
{
176+
return Err(CarbideError::InvalidArgument(
177+
"release_inactive_vni cannot be combined with other VPC updates".to_string(),
178+
)
179+
.into());
180+
}
181+
167182
// Preserve operator errors previously returned by handler-level validation
168183
// without changing how unrelated request-conversion errors are represented.
169-
let vpc_update = UpdateVpc::try_from(request.into_inner()).map_err(|error| match error {
184+
let vpc_update = UpdateVpc::try_from(request).map_err(|error| match error {
170185
RpcDataConversionError::MissingArgument("id") => {
171186
CarbideError::InvalidArgument("VPC ID is required".to_string()).into()
172187
}
@@ -176,6 +191,16 @@ pub(crate) async fn update(
176191
error => Status::from(error),
177192
})?;
178193

194+
if cleanup_requested {
195+
return release_inactive_vni(
196+
api,
197+
vpc_update,
198+
cleanup_metadata_supplied,
199+
cleanup_nsg_supplied,
200+
)
201+
.await;
202+
}
203+
179204
let mut txn = api.txn_begin().await?;
180205

181206
// Security-group and routing-profile changes both require validation
@@ -256,9 +281,154 @@ pub(crate) async fn update(
256281

257282
Ok(Response::new(rpc::VpcUpdateResult {
258283
vpc: Some(vpc_to_rpc(vpc, api.runtime_config.fnn.as_ref())),
284+
released_inactive_vni: None,
259285
}))
260286
}
261287

288+
async fn release_inactive_vni(
289+
api: &Api,
290+
mut vpc_update: UpdateVpc,
291+
metadata_supplied: bool,
292+
nsg_supplied: bool,
293+
) -> Result<Response<rpc::VpcUpdateResult>, Status> {
294+
let expected_version = vpc_update.if_version_match.ok_or_else(|| {
295+
CarbideError::InvalidArgument(
296+
"if_version_match is required to release an inactive VNI".to_string(),
297+
)
298+
})?;
299+
300+
let mut txn = api.txn_begin().await?;
301+
let vpc = db::vpc::find_by_with_lock(
302+
txn.as_mut(),
303+
ObjectColumnFilter::One(vpc::IdColumn, &vpc_update.id),
304+
db::vpc::VpcRowLock::Mutation,
305+
)
306+
.await?
307+
.pop()
308+
.ok_or_else(|| CarbideError::NotFoundError {
309+
kind: "Vpc",
310+
id: vpc_update.id.to_string(),
311+
})?;
312+
313+
// Preserve the update contract: a stale snapshot is a concurrency failure,
314+
// even when one of its echoed replacement-style fields is also stale.
315+
if vpc.version != expected_version {
316+
return Err(
317+
CarbideError::ConcurrentModificationError("vpc", expected_version.to_string()).into(),
318+
);
319+
}
320+
321+
if (metadata_supplied && vpc_update.metadata != vpc.metadata)
322+
|| (nsg_supplied
323+
&& vpc_update.network_security_group_id != vpc.config.network_security_group_id)
324+
{
325+
return Err(CarbideError::InvalidArgument(
326+
"release_inactive_vni cannot change VPC metadata or network security group".to_string(),
327+
)
328+
.into());
329+
}
330+
331+
// Cleanup is a standalone action. Preserve replacement-style fields while
332+
// the normal update path checks the version and advances it. Callers may
333+
// echo their current values for older-server safety.
334+
vpc_update.metadata = vpc.metadata.clone();
335+
vpc_update.network_security_group_id = vpc.config.network_security_group_id.clone();
336+
let updated_vpc = db::vpc::update(&vpc_update, &mut txn).await?;
337+
338+
let released_inactive_vni = release_inactive_vpc_vni(api, &mut txn, &vpc)
339+
.await?
340+
.try_into()
341+
.map_err(|_| {
342+
CarbideError::internal(
343+
"released VPC VNI cannot be represented by the RPC API".to_string(),
344+
)
345+
})?;
346+
347+
txn.commit().await?;
348+
349+
Ok(Response::new(rpc::VpcUpdateResult {
350+
vpc: Some(vpc_to_rpc(updated_vpc, api.runtime_config.fnn.as_ref())),
351+
released_inactive_vni: Some(released_inactive_vni),
352+
}))
353+
}
354+
355+
async fn release_inactive_vpc_vni(
356+
api: &Api,
357+
txn: &mut PgConnection,
358+
vpc: &model::vpc::Vpc,
359+
) -> Result<i32, CarbideError> {
360+
let active_vni = vpc.status.vni.ok_or_else(|| {
361+
CarbideError::FailedPrecondition(format!("VPC `{}` does not have an active VNI", vpc.id))
362+
})?;
363+
364+
let internal_pool = &api.common_pools.ethernet.pool_vpc_vni;
365+
let external_pool = &api.common_pools.ethernet.pool_external_vpc_vni;
366+
let owner_id = vpc.id.to_string();
367+
let internal_vni = db::resource_pool::find_owned_allocation(
368+
internal_pool,
369+
txn,
370+
resource_pool::OwnerType::Vpc,
371+
&owner_id,
372+
)
373+
.await
374+
.map_err(db::DatabaseError::from)?;
375+
let external_vni = db::resource_pool::find_owned_allocation(
376+
external_pool,
377+
txn,
378+
resource_pool::OwnerType::Vpc,
379+
&owner_id,
380+
)
381+
.await
382+
.map_err(db::DatabaseError::from)?;
383+
384+
let (inactive_pool, inactive_vni) = match (internal_vni, external_vni) {
385+
(Some(internal_vni), Some(external_vni))
386+
if internal_vni == active_vni && external_vni != active_vni =>
387+
{
388+
(external_pool, external_vni)
389+
}
390+
(Some(internal_vni), Some(external_vni))
391+
if external_vni == active_vni && internal_vni != active_vni =>
392+
{
393+
(internal_pool, internal_vni)
394+
}
395+
(Some(internal_vni), None) if internal_vni == active_vni => {
396+
return Err(CarbideError::FailedPrecondition(format!(
397+
"VPC `{}` does not have an inactive VNI allocation (active VNI `{active_vni}`)",
398+
vpc.id,
399+
)));
400+
}
401+
(None, Some(external_vni)) if external_vni == active_vni => {
402+
return Err(CarbideError::FailedPrecondition(format!(
403+
"VPC `{}` does not have an inactive VNI allocation (active VNI `{active_vni}`)",
404+
vpc.id,
405+
)));
406+
}
407+
_ => {
408+
return Err(CarbideError::FailedPrecondition(format!(
409+
"VPC `{}` has inconsistent VNI allocations: active VNI `{active_vni}`, internal allocation {internal_vni:?}, external allocation {external_vni:?}",
410+
vpc.id,
411+
)));
412+
}
413+
};
414+
415+
let released = db::resource_pool::release_all_owned(
416+
inactive_pool,
417+
txn,
418+
resource_pool::OwnerType::Vpc,
419+
&owner_id,
420+
)
421+
.await?;
422+
if released != 1 {
423+
return Err(CarbideError::FailedPrecondition(format!(
424+
"VPC `{}` has inconsistent inactive VNI allocation state",
425+
vpc.id
426+
)));
427+
}
428+
429+
Ok(inactive_vni)
430+
}
431+
262432
pub(crate) async fn update_virtualization(
263433
api: &Api,
264434
request: Request<rpc::VpcUpdateVirtualizationRequest>,
@@ -352,55 +522,28 @@ pub(crate) async fn delete(
352522
.into());
353523
}
354524

355-
let vpc = match db::vpc::try_delete(&mut txn, vpc_id).await? {
356-
Some(vpc) => vpc,
357-
None => {
358-
// VPC didn't exist or was deleted in the past. We are not allowed
359-
// to free the VNI again
360-
return Err(CarbideError::NotFoundError {
361-
kind: "vpc",
362-
id: vpc_id.to_string(),
363-
}
364-
.into());
525+
if db::vpc::try_delete(&mut txn, vpc_id).await?.is_none() {
526+
// VPC didn't exist or was deleted in the past. We are not allowed to
527+
// free any VNI allocation again.
528+
return Err(CarbideError::NotFoundError {
529+
kind: "vpc",
530+
id: vpc_id.to_string(),
365531
}
366-
};
367-
368-
if let Some(vni) = vpc.status.vni {
369-
// We can just keep deriving int/ext from the routing profile
370-
// because a VPC is not allowed to change its profile after
371-
// creation. VPC types that don't carry a routing profile
372-
// (ETV, Flat) land in the internal pool on create -- mirror
373-
// that here so the VNI is released back to the same pool.
374-
let internal = match (
375-
api.runtime_config.fnn.as_ref(),
376-
vpc.config.routing_profile_type,
377-
) {
378-
(None, _) | (Some(_), None) => true,
379-
(Some(f), Some(profile_type)) => {
380-
let Some(profile) = f.routing_profiles.get(&profile_type) else {
381-
return Err(CarbideError::NotFoundError {
382-
kind: "routing_profile_type",
383-
id: profile_type,
384-
}
385-
.into());
386-
};
387-
profile.internal.unwrap_or_default()
388-
}
389-
};
532+
.into());
533+
}
390534

391-
if internal {
392-
db::resource_pool::release(&api.common_pools.ethernet.pool_vpc_vni, &mut txn, vni)
393-
.await
394-
.map_err(CarbideError::from)?;
395-
} else {
396-
db::resource_pool::release(
397-
&api.common_pools.ethernet.pool_external_vpc_vni,
398-
&mut txn,
399-
vni,
400-
)
401-
.await
402-
.map_err(CarbideError::from)?;
403-
}
535+
let owner_id = vpc_id.to_string();
536+
for pool in [
537+
&api.common_pools.ethernet.pool_vpc_vni,
538+
&api.common_pools.ethernet.pool_external_vpc_vni,
539+
] {
540+
db::resource_pool::release_all_owned(
541+
pool,
542+
&mut txn,
543+
resource_pool::OwnerType::Vpc,
544+
&owner_id,
545+
)
546+
.await?;
404547
}
405548

406549
// Delete associated VPC peerings

0 commit comments

Comments
 (0)