@@ -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+
262432pub ( 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