From fc9285ace7df06d732537ed537b8fffd5ae1b28d Mon Sep 17 00:00:00 2001 From: Amir Hatamzad Date: Fri, 31 Jul 2026 15:09:25 -0700 Subject: [PATCH] fix(admin-cli): delete machine interfaces by MAC address (#3046) Extend DeleteInterface to accept a MAC address, so an operator can clear a leftover interface record when they have the BMC MAC but not the interface id -- the case that blocks re-ingestion of a replacement host. A MAC is unique only per network segment, so it can match several interfaces. All matches are validated before any is deleted, and the request is refused if any of them still belongs to a live machine, DPU, switch or power shelf. Also clear machine_boot_override in the shared machine_interface::delete. That foreign key has no ON DELETE CASCADE, so a leftover override previously failed the delete with a foreign-key violation; fixing it in the shared path also fixes the same latent bug for machine, switch and power-shelf force-delete. Exploration reports are intentionally left to site explorer, which prunes them on its next run. Part of #3046 Signed-off-by: Amir Hatamzad --- .../src/machine_interfaces/delete/args.rs | 20 +- .../src/machine_interfaces/delete/cmd.rs | 11 +- .../admin-cli/src/machine_interfaces/tests.rs | 22 +- .../src/handlers/machine_interface.rs | 123 +++++-- .../api-core/src/tests/machine_interfaces.rs | 338 ++++++++++++++++++ crates/api-db/src/machine_interface.rs | 5 + crates/rpc/proto/forge.proto | 11 + rest-api/proto/core/gen/v1/nico_nico.pb.go | 29 +- rest-api/proto/core/src/v1/nico_nico.proto | 11 + 9 files changed, 538 insertions(+), 32 deletions(-) diff --git a/crates/admin-cli/src/machine_interfaces/delete/args.rs b/crates/admin-cli/src/machine_interfaces/delete/args.rs index fcd0754c7e..5455184eff 100644 --- a/crates/admin-cli/src/machine_interfaces/delete/args.rs +++ b/crates/admin-cli/src/machine_interfaces/delete/args.rs @@ -16,7 +16,8 @@ */ use carbide_uuid::machine::MachineInterfaceId; -use clap::Parser; +use clap::{ArgGroup, Parser}; +use mac_address::MacAddress; #[derive(Parser, Debug)] #[command(after_long_help = "\ @@ -25,8 +26,23 @@ EXAMPLES: Delete a machine interface by ID (redeploy kea afterward): $ nico-admin-cli machine-interfaces delete 12345678-1234-5678-90ab-cdef01234567 +Delete a leftover interface when you only have the BMC MAC (e.g. a replacement host +whose ingestion is blocked by a stale interface record): + $ nico-admin-cli machine-interfaces delete --mac-address 00:11:22:33:44:55 + ")] +#[clap(group( + ArgGroup::new("interface_selector") + .required(true) + .args(["interface_id", "mac_address"]), +))] pub struct Args { #[clap(help = "The interface ID to delete.")] - pub interface_id: MachineInterfaceId, + pub interface_id: Option, + + #[clap( + long, + help = "Delete every interface carrying this MAC address instead of selecting by ID." + )] + pub mac_address: Option, } diff --git a/crates/admin-cli/src/machine_interfaces/delete/cmd.rs b/crates/admin-cli/src/machine_interfaces/delete/cmd.rs index 572d843ae1..b1b5777da2 100644 --- a/crates/admin-cli/src/machine_interfaces/delete/cmd.rs +++ b/crates/admin-cli/src/machine_interfaces/delete/cmd.rs @@ -15,11 +15,20 @@ * limitations under the License. */ +use ::rpc::forge::InterfaceDeleteQuery; + use super::args::Args; use crate::errors::CarbideCliResult; use crate::rpc::ApiClient; pub async fn handle_delete(args: Args, api_client: &ApiClient) -> CarbideCliResult<()> { - api_client.0.delete_interface(args.interface_id).await?; + // Clap's ArgGroup guarantees exactly one of these is set. + api_client + .0 + .delete_interface(InterfaceDeleteQuery { + id: args.interface_id, + mac_address: args.mac_address.map(|mac| mac.to_string()), + }) + .await?; Ok(()) } diff --git a/crates/admin-cli/src/machine_interfaces/tests.rs b/crates/admin-cli/src/machine_interfaces/tests.rs index 9d9a4ee46f..01af76a091 100644 --- a/crates/admin-cli/src/machine_interfaces/tests.rs +++ b/crates/admin-cli/src/machine_interfaces/tests.rs @@ -85,13 +85,31 @@ fn parse_delete_variants() { run = |argv| { Cmd::try_parse_from(argv.iter().copied()) .map(|cmd| match cmd { - Cmd::Delete(args) => args.interface_id.to_string(), + Cmd::Delete(args) => ( + args.interface_id.map(|id| id.to_string()), + args.mac_address.map(|mac| mac.to_string()), + ), _ => panic!("expected Delete variant"), }) .map_err(drop) }; "with an interface ID" { - &["machine-interface", "delete", TEST_INTERFACE_ID][..] => Yields(TEST_INTERFACE_ID.to_string()), + &["machine-interface", "delete", TEST_INTERFACE_ID][..] + => Yields((Some(TEST_INTERFACE_ID.to_string()), None)), + } + + "with a MAC address" { + &["machine-interface", "delete", "--mac-address", "00:11:22:33:44:55"][..] + => Yields((None, Some("00:11:22:33:44:55".to_string()))), + } + + "ID and MAC together are rejected" { + &["machine-interface", "delete", TEST_INTERFACE_ID, "--mac-address", "00:11:22:33:44:55"][..] + => Fails, + } + + "a malformed MAC is rejected at parse time" { + &["machine-interface", "delete", "--mac-address", "not-a-mac"][..] => Fails, } ); } diff --git a/crates/api-core/src/handlers/machine_interface.rs b/crates/api-core/src/handlers/machine_interface.rs index 571f2de2c0..f937c09470 100644 --- a/crates/api-core/src/handlers/machine_interface.rs +++ b/crates/api-core/src/handlers/machine_interface.rs @@ -22,6 +22,7 @@ use ::rpc::forge as rpc; use db::WithTransaction; use futures_util::FutureExt; use itertools::Itertools; +use mac_address::MacAddress; use model::machine_interface::InterfaceType; use tonic::{Request, Response, Status}; @@ -79,42 +80,118 @@ pub(crate) async fn delete_interface( let mut txn = api.txn_begin().await?; - let rpc::InterfaceDeleteQuery { id } = request.into_inner(); - let Some(id) = id else { - return Err(CarbideError::MissingArgument("delete interface.interface_id").into()); - }; + let rpc::InterfaceDeleteQuery { id, mac_address } = request.into_inner(); - let interface = db::machine_interface::find_one(&mut txn, id).await?; + // Resolve the interfaces to delete. Deleting by MAC exists for the case where the + // operator only has the BMC MAC and not the interface id; a MAC is unique per + // network segment, so it can match more than one interface. + let interfaces = match (id, mac_address) { + (Some(id), None) => match db::machine_interface::find_one(&mut txn, id).await { + Ok(interface) => vec![interface], + // Report an unknown id as not-found rather than letting it fall through as an + // internal error, matching the unknown-MAC arm below. + Err(db::DatabaseError::FindOneReturnedNoResultsError(_)) => { + return Err(CarbideError::NotFoundError { + kind: "Machine Interface", + id: id.to_string(), + } + .into()); + } + Err(e) => return Err(e.into()), + }, + (None, Some(mac_address)) => { + let mac = MacAddress::from_str(&mac_address).map_err(|e| { + CarbideError::InvalidArgument(format!("invalid MAC address {mac_address:?}: {e}")) + })?; + let mut interfaces = + db::machine_interface::find_by_mac_address(txn.as_pgconn(), mac).await?; + if interfaces.is_empty() { + return Err(CarbideError::NotFoundError { + kind: "Machine Interface", + id: mac.to_string(), + } + .into()); + } + // `machine_interface::delete` retains each row's boot pair keyed by MAC, so + // when several rows share this MAC the last one deleted decides the retained + // `boot_interface_id`. The lookup has no inherent order, so sort to make that + // outcome deterministic rather than dependent on the query plan. + interfaces.sort_by_key(|interface| interface.id); + interfaces + } + (Some(_), Some(_)) => { + return Err(CarbideError::InvalidArgument( + "specify either id or mac_address, not both".to_string(), + ) + .into()); + } + (None, None) => { + return Err( + CarbideError::MissingArgument("delete interface.id or .mac_address").into(), + ); + } + }; - // There should not be any machine associated with this interface. - if let Some(machine_id) = interface.machine_id { - if interface.interface_type == InterfaceType::Bmc { + // Check every interface before deleting any of them, so a MAC that matches one + // deletable and one attached interface refuses as a whole instead of half-deleting. + for interface in &interfaces { + // There should not be any machine associated with this interface. + if let Some(machine_id) = interface.machine_id { + if interface.interface_type == InterfaceType::Bmc { + return Err(CarbideError::InvalidArgument(format!( + "this looks like a BMC interface and attached with machine: {machine_id}. delete that first" + )) + .into()); + } return Err(CarbideError::InvalidArgument(format!( - "this looks like a BMC interface and attached with machine: {machine_id}. delete that first" + "already a machine {machine_id} is attached to this interface. delete that first" )) .into()); } - return Err(CarbideError::InvalidArgument(format!( - "already a machine {machine_id} is attached to this interface. delete that first" - )) - .into()); - } - // There should not be any BMC information associated with any machine. - for address in interface.addresses.iter() { - let machine_id = - db::machine_topology::find_machine_id_by_bmc_ip(txn.as_pgconn(), &address.to_string()) - .await?; - - if let Some(machine_id) = machine_id { + // Nor any other live owner. Unlike `machine_id`, these associations do not stop + // the row being deleted at the database level, so they have to be refused here -- + // selecting by MAC can match rows the caller never saw, so a switch or power + // shelf must not be taken out as collateral. + if let Some(dpu_machine_id) = interface.attached_dpu_machine_id { + return Err(CarbideError::InvalidArgument(format!( + "this interface is attached to DPU machine {dpu_machine_id}. delete that first" + )) + .into()); + } + if let Some(switch_id) = interface.switch_id { + return Err(CarbideError::InvalidArgument(format!( + "this interface belongs to switch {switch_id}. delete that first" + )) + .into()); + } + if let Some(power_shelf_id) = interface.power_shelf_id { return Err(CarbideError::InvalidArgument(format!( - "this looks like a BMC interface and attached with machine: {machine_id}. delete that first" + "this interface belongs to power shelf {power_shelf_id}. delete that first" )) .into()); } + + // There should not be any BMC information associated with any machine. + for address in interface.addresses.iter() { + let machine_id = db::machine_topology::find_machine_id_by_bmc_ip( + txn.as_pgconn(), + &address.to_string(), + ) + .await?; + + if let Some(machine_id) = machine_id { + return Err(CarbideError::InvalidArgument(format!( + "this looks like a BMC interface and attached with machine: {machine_id}. delete that first" + )) + .into()); + } + } } - db::machine_interface::delete(&interface.id, &mut txn).await?; + for interface in &interfaces { + db::machine_interface::delete(&interface.id, &mut txn).await?; + } txn.commit().await?; diff --git a/crates/api-core/src/tests/machine_interfaces.rs b/crates/api-core/src/tests/machine_interfaces.rs index a1eb0b3aed..74f2cca1f9 100644 --- a/crates/api-core/src/tests/machine_interfaces.rs +++ b/crates/api-core/src/tests/machine_interfaces.rs @@ -735,6 +735,7 @@ async fn test_delete_interface(pool: sqlx::PgPool) -> Result<(), Box Result<(), Box> { + let env = create_test_env(pool).await; + let mac = "FF:FF:FF:FF:FF:BB"; + + let dhcp_response = env + .api + .discover_dhcp(tonic::Request::new(rpc::forge::DhcpDiscovery { + mac_address: mac.to_string(), + relay_address: FIXTURE_DHCP_RELAY_ADDRESS.to_string(), + link_address: None, + vendor_string: None, + circuit_id: None, + remote_id: None, + desired_address: None, + address_family: None, + message_kind: None, + duid: None, + })) + .await + .unwrap() + .into_inner(); + let interface_id = dhcp_response + .machine_interface_id + .expect("discover_dhcp must return an interface id"); + + env.api + .delete_interface(tonic::Request::new(rpc::forge::InterfaceDeleteQuery { + id: None, + mac_address: Some(mac.to_string()), + })) + .await + .unwrap(); + + let mut txn = env.pool.begin().await?; + assert!( + db::machine_interface::find_by_mac_address(txn.as_mut(), MacAddress::from_str(mac)?) + .await? + .is_empty(), + "the interface should be gone once delete by MAC succeeds" + ); + let found = db::machine_interface::find_one(txn.as_mut(), interface_id).await; + assert!(matches!( + found, + Err(DatabaseError::FindOneReturnedNoResultsError(_)) + )); + txn.commit().await?; + + Ok(()) +} + +// An unknown MAC is reported as not found rather than silently succeeding. +#[crate::sqlx_test] +async fn test_delete_interface_by_unknown_mac_is_not_found( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool).await; + + let status = env + .api + .delete_interface(tonic::Request::new(rpc::forge::InterfaceDeleteQuery { + id: None, + mac_address: Some("FF:FF:FF:FF:FF:CC".to_string()), + })) + .await + .expect_err("an unknown MAC should not succeed"); + assert_eq!(status.code(), Code::NotFound); + + Ok(()) +} + +// A leftover boot override must not block interface deletion: `machine_boot_override` +// has a foreign key to `machine_interfaces` with no ON DELETE CASCADE, so the delete +// fails with a FK violation unless the override is cleared first. +#[crate::sqlx_test] +async fn test_delete_interface_clears_boot_override( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool).await; + let mac = "FF:FF:FF:FF:FF:DD"; + + let dhcp_response = env + .api + .discover_dhcp(tonic::Request::new(rpc::forge::DhcpDiscovery { + mac_address: mac.to_string(), + relay_address: FIXTURE_DHCP_RELAY_ADDRESS.to_string(), + link_address: None, + vendor_string: None, + circuit_id: None, + remote_id: None, + desired_address: None, + address_family: None, + message_kind: None, + duid: None, + })) + .await + .unwrap() + .into_inner(); + let interface_id = dhcp_response + .machine_interface_id + .expect("discover_dhcp must return an interface id"); + + let mut txn = env.pool.begin().await?; + db::machine_boot_override::create( + txn.as_mut(), + interface_id, + Some("custom-pxe-script".to_string()), + None, + ) + .await?; + txn.commit().await?; + + env.api + .delete_interface(tonic::Request::new(rpc::forge::InterfaceDeleteQuery { + id: Some(interface_id), + mac_address: None, + })) + .await + .expect("a leftover boot override must not block deletion"); + + let mut txn = env.pool.begin().await?; + assert!( + db::machine_boot_override::find_optional(txn.as_mut(), interface_id) + .await? + .is_none(), + "the boot override should be gone with its interface" + ); + txn.commit().await?; + + Ok(()) +} + +// A MAC is unique only per network segment, so the same MAC can legitimately exist on +// several segments -- the exact shape of #3046, where a host returns to the site on a +// different network prefix. Deleting by MAC must remove all of them. +#[crate::sqlx_test] +async fn test_delete_interface_by_mac_removes_every_segment( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool).await; + let mac = MacAddress::from_str("FF:FF:FF:FF:FE:01")?; + + create_host_inband_network_segment(&env.api, None).await; + + let mut txn = env.pool.begin().await?; + let admin_segment = db::network_segment::admin(&mut txn) + .await? + .into_iter() + .next() + .unwrap(); + let host_inband_gateway = FIXTURE_HOST_INBAND_NETWORK_SEGMENT_GATEWAY.ip(); + let inband_segment = db::network_segment::for_segment_type_all( + &mut txn, + std::slice::from_ref(&host_inband_gateway), + NetworkSegmentType::HostInband, + ) + .await? + .into_iter() + .next() + .expect("the host-inband fixture segment should exist"); + for segment in [&admin_segment, &inband_segment] { + db::machine_interface::create( + &mut txn, + std::slice::from_ref(segment), + &mac, + false, + AddressSelectionStrategy::NextAvailableIp, + None, + ) + .await?; + } + txn.commit().await?; + + let mut txn = env.pool.begin().await?; + assert_eq!( + db::machine_interface::find_by_mac_address(txn.as_mut(), mac) + .await? + .len(), + 2, + "the same MAC should exist on both segments" + ); + txn.commit().await?; + + env.api + .delete_interface(tonic::Request::new(rpc::forge::InterfaceDeleteQuery { + id: None, + mac_address: Some(mac.to_string()), + })) + .await + .unwrap(); + + let mut txn = env.pool.begin().await?; + assert!( + db::machine_interface::find_by_mac_address(txn.as_mut(), mac) + .await? + .is_empty(), + "every interface carrying the MAC should be deleted" + ); + txn.commit().await?; + + Ok(()) +} + +// When a MAC matches one free interface and one still owned by a switch, the whole +// request is refused and the free interface survives -- deletion is all-or-nothing. +#[crate::sqlx_test] +async fn test_delete_interface_by_mac_refuses_when_any_match_is_owned( + pool: sqlx::PgPool, +) -> Result<(), Box> { + use carbide_uuid::switch::SwitchId; + use model::switch::{NewSwitch, SwitchConfig}; + + let env = create_test_env(pool).await; + let mac = MacAddress::from_str("FF:FF:FF:FF:FE:02")?; + + create_host_inband_network_segment(&env.api, None).await; + + let mut txn = env.pool.begin().await?; + let admin_segment = db::network_segment::admin(&mut txn) + .await? + .into_iter() + .next() + .unwrap(); + let host_inband_gateway = FIXTURE_HOST_INBAND_NETWORK_SEGMENT_GATEWAY.ip(); + let inband_segment = db::network_segment::for_segment_type_all( + &mut txn, + std::slice::from_ref(&host_inband_gateway), + NetworkSegmentType::HostInband, + ) + .await? + .into_iter() + .next() + .expect("the host-inband fixture segment should exist"); + + let free_interface = db::machine_interface::create( + &mut txn, + std::slice::from_ref(&admin_segment), + &mac, + false, + AddressSelectionStrategy::NextAvailableIp, + None, + ) + .await?; + let owned_interface = db::machine_interface::create( + &mut txn, + std::slice::from_ref(&inband_segment), + &mac, + false, + AddressSelectionStrategy::NextAvailableIp, + None, + ) + .await?; + + let switch_id = SwitchId::from(uuid::Uuid::new_v4()); + db::switch::create( + &mut txn, + &NewSwitch { + id: switch_id, + config: SwitchConfig { + name: "Test Switch".to_string(), + enable_nmxc: false, + fabric_manager_config: None, + }, + bmc_mac_address: None, + metadata: None, + rack_id: None, + slot_number: Some(2), + tray_index: Some(1), + }, + ) + .await?; + db::machine_interface::associate_interface_with_machine( + &owned_interface.id, + MachineInterfaceAssociation::Switch(switch_id), + &mut txn, + ) + .await?; + txn.commit().await?; + + let status = env + .api + .delete_interface(tonic::Request::new(rpc::forge::InterfaceDeleteQuery { + id: None, + mac_address: Some(mac.to_string()), + })) + .await + .expect_err("a switch-owned match must refuse the whole request"); + assert_eq!(status.code(), Code::InvalidArgument); + + let mut txn = env.pool.begin().await?; + assert_eq!( + db::machine_interface::find_by_mac_address(txn.as_mut(), mac) + .await? + .len(), + 2, + "nothing should be deleted when any match is refused" + ); + assert!( + db::machine_interface::find_one(txn.as_mut(), free_interface.id) + .await + .is_ok(), + "the eligible interface must survive an all-or-nothing refusal" + ); + txn.commit().await?; + + Ok(()) +} + +// An unknown interface id reports not-found, matching the unknown-MAC arm. Previously +// this fell through as an internal error. +#[crate::sqlx_test] +async fn test_delete_interface_by_unknown_id_is_not_found( + pool: sqlx::PgPool, +) -> Result<(), Box> { + let env = create_test_env(pool).await; + + let status = env + .api + .delete_interface(tonic::Request::new(rpc::forge::InterfaceDeleteQuery { + id: Some(carbide_uuid::machine::MachineInterfaceId::from( + uuid::Uuid::new_v4(), + )), + mac_address: None, + })) + .await + .expect_err("an unknown interface id should not succeed"); + assert_eq!(status.code(), Code::NotFound); + + Ok(()) +} diff --git a/crates/api-db/src/machine_interface.rs b/crates/api-db/src/machine_interface.rs index 6cdc7e5114..afb783c505 100644 --- a/crates/api-db/src/machine_interface.rs +++ b/crates/api-db/src/machine_interface.rs @@ -3541,6 +3541,11 @@ pub async fn delete( "DELETE FROM machine_interfaces WHERE id=$1 RETURNING mac_address, boot_interface_id"; crate::machine_interface_address::delete(txn, interface_id).await?; crate::dhcp_entry::delete(txn, interface_id).await?; + // `machine_boot_override` references this row with no ON DELETE CASCADE, so a + // leftover override otherwise fails the delete below with a foreign-key violation. + // The override is meaningless once its interface is gone, so drop it here for every + // caller rather than making each one remember. + crate::machine_boot_override::clear(txn, *interface_id).await?; let deleted: Option<(MacAddress, Option)> = sqlx::query_as(query) .bind(*interface_id) .fetch_optional(&mut *txn) diff --git a/crates/rpc/proto/forge.proto b/crates/rpc/proto/forge.proto index 87267a2547..a7133e1515 100644 --- a/crates/rpc/proto/forge.proto +++ b/crates/rpc/proto/forge.proto @@ -3668,8 +3668,19 @@ message MachineList { repeated Machine machines = 1; } +// Selects the interface(s) to delete. Exactly one of `id` or `mac_address` must be set. +// Deleting by MAC exists for cleaning up a leftover interface when the operator has the +// MAC but not the interface id -- e.g. a replacement host whose ingestion is blocked by +// a stale interface record, where the MAC is read off the chassis or expected machines. message InterfaceDeleteQuery { common.MachineInterfaceId id = 1; + + // Delete every interface whose own MAC address matches this value; it is not filtered + // by interface type (for a BMC interface this is the BMC MAC). A MAC is unique only + // per network segment, so this may match more than one interface -- they are all + // validated before any is deleted, so a match that still belongs to a live machine, + // DPU, switch or power shelf refuses the whole request. + optional string mac_address = 2; } message InterfaceSearchQuery { diff --git a/rest-api/proto/core/gen/v1/nico_nico.pb.go b/rest-api/proto/core/gen/v1/nico_nico.pb.go index 631f662669..dc2ff2f478 100644 --- a/rest-api/proto/core/gen/v1/nico_nico.pb.go +++ b/rest-api/proto/core/gen/v1/nico_nico.pb.go @@ -21232,9 +21232,19 @@ func (x *MachineList) GetMachines() []*Machine { return nil } +// Selects the interface(s) to delete. Exactly one of `id` or `mac_address` must be set. +// Deleting by MAC exists for cleaning up a leftover interface when the operator has the +// MAC but not the interface id -- e.g. a replacement host whose ingestion is blocked by +// a stale interface record, where the MAC is read off the chassis or expected machines. type InterfaceDeleteQuery struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id *MachineInterfaceId `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Id *MachineInterfaceId `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Delete every interface whose own MAC address matches this value; it is not filtered + // by interface type (for a BMC interface this is the BMC MAC). A MAC is unique only + // per network segment, so this may match more than one interface -- they are all + // validated before any is deleted, so a match that still belongs to a live machine, + // DPU, switch or power shelf refuses the whole request. + MacAddress *string `protobuf:"bytes,2,opt,name=mac_address,json=macAddress,proto3,oneof" json:"mac_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -21276,6 +21286,13 @@ func (x *InterfaceDeleteQuery) GetId() *MachineInterfaceId { return nil } +func (x *InterfaceDeleteQuery) GetMacAddress() string { + if x != nil && x.MacAddress != nil { + return *x.MacAddress + } + return "" +} + type InterfaceSearchQuery struct { state protoimpl.MessageState `protogen:"open.v1"` Id *MachineInterfaceId `protobuf:"bytes,1,opt,name=id,proto3,oneof" json:"id,omitempty"` @@ -64159,9 +64176,12 @@ const file_nico_nico_proto_rawDesc = "" + "interfaces\x18\x01 \x03(\v2\x17.forge.MachineInterfaceR\n" + "interfaces\"9\n" + "\vMachineList\x12*\n" + - "\bmachines\x18\x01 \x03(\v2\x0e.forge.MachineR\bmachines\"B\n" + + "\bmachines\x18\x01 \x03(\v2\x0e.forge.MachineR\bmachines\"x\n" + "\x14InterfaceDeleteQuery\x12*\n" + - "\x02id\x18\x01 \x01(\v2\x1a.common.MachineInterfaceIdR\x02id\"j\n" + + "\x02id\x18\x01 \x01(\v2\x1a.common.MachineInterfaceIdR\x02id\x12$\n" + + "\vmac_address\x18\x02 \x01(\tH\x00R\n" + + "macAddress\x88\x01\x01B\x0e\n" + + "\f_mac_address\"j\n" + "\x14InterfaceSearchQuery\x12/\n" + "\x02id\x18\x01 \x01(\v2\x1a.common.MachineInterfaceIdH\x00R\x02id\x88\x01\x01\x12\x13\n" + "\x02ip\x18\x02 \x01(\tH\x01R\x02ip\x88\x01\x01B\x05\n" + @@ -72350,6 +72370,7 @@ func file_nico_nico_proto_init() { file_nico_nico_proto_msgTypes[222].OneofWrappers = []any{} file_nico_nico_proto_msgTypes[225].OneofWrappers = []any{} file_nico_nico_proto_msgTypes[234].OneofWrappers = []any{} + file_nico_nico_proto_msgTypes[239].OneofWrappers = []any{} file_nico_nico_proto_msgTypes[240].OneofWrappers = []any{} file_nico_nico_proto_msgTypes[248].OneofWrappers = []any{} file_nico_nico_proto_msgTypes[249].OneofWrappers = []any{} diff --git a/rest-api/proto/core/src/v1/nico_nico.proto b/rest-api/proto/core/src/v1/nico_nico.proto index 0ef124fa94..0665ed6b2e 100644 --- a/rest-api/proto/core/src/v1/nico_nico.proto +++ b/rest-api/proto/core/src/v1/nico_nico.proto @@ -3667,8 +3667,19 @@ message MachineList { repeated Machine machines = 1; } +// Selects the interface(s) to delete. Exactly one of `id` or `mac_address` must be set. +// Deleting by MAC exists for cleaning up a leftover interface when the operator has the +// MAC but not the interface id -- e.g. a replacement host whose ingestion is blocked by +// a stale interface record, where the MAC is read off the chassis or expected machines. message InterfaceDeleteQuery { common.MachineInterfaceId id = 1; + + // Delete every interface whose own MAC address matches this value; it is not filtered + // by interface type (for a BMC interface this is the BMC MAC). A MAC is unique only + // per network segment, so this may match more than one interface -- they are all + // validated before any is deleted, so a match that still belongs to a live machine, + // DPU, switch or power shelf refuses the whole request. + optional string mac_address = 2; } message InterfaceSearchQuery {