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 {