From fbf8b4440636fdafeeed6f17222abd7b9d59e1e9 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Wed, 21 Jan 2026 15:46:16 +0000 Subject: [PATCH 01/12] incusd/device/nic: Add `attached` configuration key Signed-off-by: Benjamin Somers --- .../server/device/device_utils_network.go | 8 +++---- internal/server/device/nic.go | 1 + internal/server/device/nic_bridged.go | 14 +++++++++++++ internal/server/device/nic_ipvlan.go | 14 +++++++++++++ internal/server/device/nic_macvlan.go | 21 +++++++++++++++++-- internal/server/device/nic_ovn.go | 14 +++++++++++++ internal/server/device/nic_p2p.go | 14 +++++++++++++ internal/server/device/nic_physical.go | 16 +++++++++++++- internal/server/device/nic_routed.go | 14 +++++++++++++ internal/server/device/nic_sriov.go | 21 +++++++++++++++++-- 10 files changed, 128 insertions(+), 9 deletions(-) diff --git a/internal/server/device/device_utils_network.go b/internal/server/device/device_utils_network.go index 452cad6158c..38f2b0e4664 100644 --- a/internal/server/device/device_utils_network.go +++ b/internal/server/device/device_utils_network.go @@ -611,12 +611,12 @@ func networkSetupHostVethLimits(d *deviceCommon, oldConfig deviceConfig.Device, // networkClearHostVethLimits clears any network rate limits to the veth device specified in the config. func networkClearHostVethLimits(d *deviceCommon) error { - err := d.state.Firewall.InstanceClearNetPrio(d.inst.Project().Name, d.inst.Name(), d.config["host_name"]) - if err != nil { - return err + // Detached NICs cannot be cleaned up this way. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil } - return nil + return d.state.Firewall.InstanceClearNetPrio(d.inst.Project().Name, d.inst.Name(), d.config["host_name"]) } // networkValidGateway validates the gateway value. diff --git a/internal/server/device/nic.go b/internal/server/device/nic.go index 8f620f19f9d..fdd9010980e 100644 --- a/internal/server/device/nic.go +++ b/internal/server/device/nic.go @@ -60,6 +60,7 @@ func nicValidationRules(requiredFields []string, optionalFields []string, instCo "vendorid": validate.Optional(validate.IsDeviceID), "productid": validate.Optional(validate.IsDeviceID), "pci": validate.IsPCIAddress, + "attached": validate.Optional(validate.IsBool), } validators := map[string]func(value string) error{} diff --git a/internal/server/device/nic_bridged.go b/internal/server/device/nic_bridged.go index de1d4be07c5..3be92f8a9f4 100644 --- a/internal/server/device/nic_bridged.go +++ b/internal/server/device/nic_bridged.go @@ -316,6 +316,15 @@ func (d *nicBridged) validateConfig(instConf instance.ConfigReader) error { // managed: no // shortdesc: Override the bus for the device (can be `virtio` or `usb`) (VM only) "io.bus", + + // gendoc:generate(entity=devices, group=nic_bridged, key=attached) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is plugged in or not + "attached", } // checkWithManagedNetwork validates the device's settings against the managed network. @@ -726,6 +735,11 @@ func (d *nicBridged) PreStartCheck() error { // Start is run when the device is added to a running instance or instance is starting up. func (d *nicBridged) Start() (*deviceConfig.RunConfig, error) { + // Ignore detached NICs. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil, nil + } + err := d.validateEnvironment() if err != nil { return nil, err diff --git a/internal/server/device/nic_ipvlan.go b/internal/server/device/nic_ipvlan.go index b51580e580d..7335186e583 100644 --- a/internal/server/device/nic_ipvlan.go +++ b/internal/server/device/nic_ipvlan.go @@ -108,6 +108,15 @@ func (d *nicIPVLAN) validateConfig(instConf instance.ConfigReader) error { // default: false // shortdesc: Register VLAN using GARP VLAN Registration Protocol "gvrp", + + // gendoc:generate(entity=devices, group=nic_ipvlan, key=attached) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is plugged in or not + "attached", } rules := nicValidationRules(requiredFields, optionalFields, instConf) @@ -301,6 +310,11 @@ func (d *nicIPVLAN) validateEnvironment() error { // Start is run when the instance is starting up (IPVLAN doesn't support hot plugging). func (d *nicIPVLAN) Start() (*deviceConfig.RunConfig, error) { + // Ignore detached NICs. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil, nil + } + err := d.validateEnvironment() if err != nil { return nil, err diff --git a/internal/server/device/nic_macvlan.go b/internal/server/device/nic_macvlan.go index 51ca5e73cbb..0ebe79dc147 100644 --- a/internal/server/device/nic_macvlan.go +++ b/internal/server/device/nic_macvlan.go @@ -128,6 +128,15 @@ func (d *nicMACVLAN) validateConfig(instConf instance.ConfigReader) error { // managed: no // shortdesc: Override the bus for the device (can be `virtio` or `usb`) (VM only) "io.bus", + + // gendoc:generate(entity=devices, group=nic_macvlan, key=attached) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is plugged in or not + "attached", } // Check that if network proeperty is set that conflicting keys are not present. @@ -213,6 +222,11 @@ func (d *nicMACVLAN) validateEnvironment() error { // Start is run when the device is added to a running instance or instance is starting up. func (d *nicMACVLAN) Start() (*deviceConfig.RunConfig, error) { + // Ignore detached NICs. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil, nil + } + err := d.validateEnvironment() if err != nil { return nil, err @@ -363,9 +377,12 @@ func (d *nicMACVLAN) Stop() (*deviceConfig.RunConfig, error) { v := d.volatileGet() runConf := deviceConfig.RunConfig{ PostHooks: []func() error{d.postStop}, - NetworkInterface: []deviceConfig.RunConfigItem{ + } + + if util.IsTrueOrEmpty(d.config["attached"]) { + runConf.NetworkInterface = []deviceConfig.RunConfigItem{ {Key: "link", Value: v["host_name"]}, - }, + } } return &runConf, nil diff --git a/internal/server/device/nic_ovn.go b/internal/server/device/nic_ovn.go index 711ca91e9d7..4ecd1082006 100644 --- a/internal/server/device/nic_ovn.go +++ b/internal/server/device/nic_ovn.go @@ -317,6 +317,15 @@ func (d *nicOVN) validateConfig(instConf instance.ConfigReader) error { // managed: no // shortdesc: The priority for outgoing traffic, to be used by the kernel queuing discipline to prioritize network packets "limits.priority", + + // gendoc:generate(entity=devices, group=nic_ovn, key=attached) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is plugged in or not + "attached", } // The NIC's network may be a non-default project, so lookup project and get network's project name. @@ -693,6 +702,11 @@ func (d *nicOVN) init(inst instance.Instance, s *state.State, name string, conf // Start is run when the device is added to a running instance or instance is starting up. func (d *nicOVN) Start() (*deviceConfig.RunConfig, error) { + // Ignore detached NICs. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil, nil + } + err := d.validateEnvironment() if err != nil { return nil, err diff --git a/internal/server/device/nic_p2p.go b/internal/server/device/nic_p2p.go index cf2753ffac8..92cb20b5519 100644 --- a/internal/server/device/nic_p2p.go +++ b/internal/server/device/nic_p2p.go @@ -125,6 +125,15 @@ func (d *nicP2P) validateConfig(instConf instance.ConfigReader) error { // default: `virtio` // shortdesc: Override the bus for the device (can be `virtio` or `usb`) (VM only) "io.bus", + + // gendoc:generate(entity=devices, group=nic_p2p, key=attached) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is plugged in or not + "attached", } err := d.config.Validate(nicValidationRules([]string{}, optionalFields, instConf)) @@ -157,6 +166,11 @@ func (d *nicP2P) UpdatableFields(oldDevice Type) []string { // Start is run when the device is added to a running instance or instance is starting up. func (d *nicP2P) Start() (*deviceConfig.RunConfig, error) { + // Ignore detached NICs. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil, nil + } + err := d.validateEnvironment() if err != nil { return nil, err diff --git a/internal/server/device/nic_physical.go b/internal/server/device/nic_physical.go index cdab00a6752..735a1da5d3b 100644 --- a/internal/server/device/nic_physical.go +++ b/internal/server/device/nic_physical.go @@ -85,6 +85,15 @@ func (d *nicPhysical) validateConfig(instConf instance.ConfigReader) error { // managed: no // shortdesc: The Maximum Transmit Unit (MTU) of the new interface "mtu", + + // gendoc:generate(entity=devices, group=nic_physical, key=attached) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is plugged in or not + "attached", } if instConf.Type() == instancetype.Container || instConf.Type() == instancetype.Any { @@ -196,6 +205,11 @@ func (d *nicPhysical) validateEnvironment() error { // Start is run when the device is added to a running instance or instance is starting up. func (d *nicPhysical) Start() (*deviceConfig.RunConfig, error) { + // Ignore detached NICs. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil, nil + } + err := d.validateEnvironment() if err != nil { return nil, err @@ -443,7 +457,7 @@ func (d *nicPhysical) Stop() (*deviceConfig.RunConfig, error) { DeviceName: fmt.Sprintf("%s-%s-%s", d.name, v["last_state.usb.bus"], v["last_state.usb.device"]), HostDevicePath: fmt.Sprintf("/dev/bus/usb/%s/%s", v["last_state.usb.bus"], v["last_state.usb.device"]), }) - } else { + } else if util.IsTrueOrEmpty(d.config["attached"]) { // Handle all other NICs. runConf.NetworkInterface = []deviceConfig.RunConfigItem{ {Key: "link", Value: v["host_name"]}, diff --git a/internal/server/device/nic_routed.go b/internal/server/device/nic_routed.go index a92fb9e592c..fd4fdbafb0d 100644 --- a/internal/server/device/nic_routed.go +++ b/internal/server/device/nic_routed.go @@ -243,6 +243,15 @@ func (d *nicRouted) validateConfig(instConf instance.ConfigReader) error { // default: `virtio` // shortdesc: Override the bus for the device (can be `virtio` or `usb`) (VM only) "io.bus", + + // gendoc:generate(entity=devices, group=nic_routed, key=attached) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is plugged in or not + "attached", } rules := nicValidationRules(requiredFields, optionalFields, instConf) @@ -467,6 +476,11 @@ func (d *nicRouted) checkIPAvailability(parent string) error { // Start is run when the instance is starting up (Routed mode doesn't support hot plugging). func (d *nicRouted) Start() (*deviceConfig.RunConfig, error) { + // Ignore detached NICs. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil, nil + } + err := d.validateEnvironment() if err != nil { return nil, err diff --git a/internal/server/device/nic_sriov.go b/internal/server/device/nic_sriov.go index c1454748212..0c939c1ac77 100644 --- a/internal/server/device/nic_sriov.go +++ b/internal/server/device/nic_sriov.go @@ -133,6 +133,15 @@ func (d *nicSRIOV) validateConfig(instConf instance.ConfigReader) error { // required: no // shortdesc: The PCI address of the parent host device "pci", + + // gendoc:generate(entity=devices, group=nic_sriov, key=attached) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is plugged in or not + "attached", } // Check that if network property is set that conflicting keys are not present. @@ -240,6 +249,11 @@ func (d *nicSRIOV) validateEnvironment() error { // Start is run when the device is added to a running instance or instance is starting up. func (d *nicSRIOV) Start() (*deviceConfig.RunConfig, error) { + // Ignore detached NICs. + if !util.IsTrueOrEmpty(d.config["attached"]) { + return nil, nil + } + err := d.validateEnvironment() if err != nil { return nil, err @@ -329,9 +343,12 @@ func (d *nicSRIOV) Stop() (*deviceConfig.RunConfig, error) { v := d.volatileGet() runConf := deviceConfig.RunConfig{ PostHooks: []func() error{d.postStop}, - NetworkInterface: []deviceConfig.RunConfigItem{ + } + + if util.IsTrueOrEmpty(d.config["attached"]) { + runConf.NetworkInterface = []deviceConfig.RunConfigItem{ {Key: "link", Value: v["host_name"]}, - }, + } } return &runConf, nil From 8c27e80a67ea01feea2ef65ad5d1f07e5cf3ee18 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Wed, 21 Jan 2026 16:16:11 +0000 Subject: [PATCH 02/12] incusd/device/nic: Add `connected` configuration key Signed-off-by: Benjamin Somers --- internal/server/device/device_common.go | 11 ++++++++ internal/server/device/nic.go | 1 + internal/server/device/nic_bridged.go | 19 ++++++++++++- internal/server/device/nic_macvlan.go | 34 +++++++++++++++++++++++ internal/server/device/nic_ovn.go | 36 ++++++++++++++++++++++++- internal/server/device/nic_p2p.go | 14 ++++++++-- internal/server/device/nic_physical.go | 34 +++++++++++++++++++++++ internal/server/device/nic_routed.go | 14 +++++++++- internal/server/device/nic_sriov.go | 34 +++++++++++++++++++++++ 9 files changed, 192 insertions(+), 5 deletions(-) diff --git a/internal/server/device/device_common.go b/internal/server/device/device_common.go index 7d8877d67bb..22cf938e3be 100644 --- a/internal/server/device/device_common.go +++ b/internal/server/device/device_common.go @@ -118,3 +118,14 @@ func (d *deviceCommon) generateHostName(prefix string, hwaddr string) (string, e // Handle instances.nic.host_name random mode or where no MAC address supplied. return network.RandomDevName(prefix), nil } + +// setNICLink sets the link status (connected/disconnected) for the given NIC. +func (d *deviceCommon) setNICLink() error { + runConf := deviceConfig.RunConfig{} + runConf.NetworkInterface = []deviceConfig.RunConfigItem{ + {Key: "devName", Value: d.name}, + {Key: "connected", Value: d.config["connected"]}, + } + + return d.inst.DeviceEventHandler(&runConf) +} diff --git a/internal/server/device/nic.go b/internal/server/device/nic.go index fdd9010980e..4512e0c2737 100644 --- a/internal/server/device/nic.go +++ b/internal/server/device/nic.go @@ -61,6 +61,7 @@ func nicValidationRules(requiredFields []string, optionalFields []string, instCo "productid": validate.Optional(validate.IsDeviceID), "pci": validate.IsPCIAddress, "attached": validate.Optional(validate.IsBool), + "connected": validate.Optional(validate.IsBool), } validators := map[string]func(value string) error{} diff --git a/internal/server/device/nic_bridged.go b/internal/server/device/nic_bridged.go index 3be92f8a9f4..1922f9ce44e 100644 --- a/internal/server/device/nic_bridged.go +++ b/internal/server/device/nic_bridged.go @@ -325,6 +325,15 @@ func (d *nicBridged) validateConfig(instConf instance.ConfigReader) error { // required: no // shortdesc: Whether the NIC is plugged in or not "attached", + + // gendoc:generate(entity=devices, group=nic_bridged, key=connected) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is connected to the host network + "connected", } // checkWithManagedNetwork validates the device's settings against the managed network. @@ -702,7 +711,7 @@ func (d *nicBridged) UpdatableFields(oldDevice Type) []string { return []string{} } - return []string{"limits.ingress", "limits.egress", "limits.max", "limits.priority", "ipv4.routes", "ipv6.routes", "ipv4.routes.external", "ipv6.routes.external", "ipv4.address", "ipv6.address", "security.mac_filtering", "security.ipv4_filtering", "security.ipv6_filtering", "security.acls", "security.acls.default.egress.action", "security.acls.default.egress.logged", "security.acls.default.ingress.action", "security.acls.default.ingress.logged"} + return []string{"limits.ingress", "limits.egress", "limits.max", "limits.priority", "ipv4.routes", "ipv6.routes", "ipv4.routes.external", "ipv6.routes.external", "ipv4.address", "ipv6.address", "security.mac_filtering", "security.ipv4_filtering", "security.ipv6_filtering", "security.acls", "security.acls.default.egress.action", "security.acls.default.egress.logged", "security.acls.default.ingress.action", "security.acls.default.ingress.logged", "connected"} } // Add is run when a device is added to a non-snapshot instance whether or not the instance is running. @@ -930,6 +939,7 @@ func (d *nicBridged) Start() (*deviceConfig.RunConfig, error) { {Key: "flags", Value: "up"}, {Key: "link", Value: peerName}, {Key: "hwaddr", Value: d.config["hwaddr"]}, + {Key: "connected", Value: d.config["connected"]}, } if d.config["io.bus"] == "usb" { @@ -1063,6 +1073,13 @@ func (d *nicBridged) Update(oldDevices deviceConfig.Devices, isRunning bool) err return err } + if isRunning { + err = d.setNICLink() + if err != nil { + return err + } + } + reverter.Success() return nil diff --git a/internal/server/device/nic_macvlan.go b/internal/server/device/nic_macvlan.go index 0ebe79dc147..00fe7c9c445 100644 --- a/internal/server/device/nic_macvlan.go +++ b/internal/server/device/nic_macvlan.go @@ -137,6 +137,15 @@ func (d *nicMACVLAN) validateConfig(instConf instance.ConfigReader) error { // required: no // shortdesc: Whether the NIC is plugged in or not "attached", + + // gendoc:generate(entity=devices, group=nic_macvlan, key=connected) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is connected to the host network (VM only) + "connected", } // Check that if network proeperty is set that conflicting keys are not present. @@ -184,6 +193,10 @@ func (d *nicMACVLAN) validateConfig(instConf instance.ConfigReader) error { requiredFields = append(requiredFields, "parent") } + if instConf.Type() != instancetype.VM && d.config["connected"] != "" { + return errors.New("The \"connected\" option is only supported on virtual machines for macvlan NICs") + } + err := d.config.Validate(nicValidationRules(requiredFields, optionalFields, instConf)) if err != nil { return err @@ -353,6 +366,7 @@ func (d *nicMACVLAN) Start() (*deviceConfig.RunConfig, error) { {Key: "flags", Value: "up"}, {Key: "link", Value: saveData["host_name"]}, {Key: "hwaddr", Value: d.config["hwaddr"]}, + {Key: "connected", Value: d.config["connected"]}, } if d.config["io.bus"] == "usb" { @@ -425,3 +439,23 @@ func (d *nicMACVLAN) postStop() error { return nil } + +// UpdatableFields returns a list of fields that can be updated without triggering a device remove & add. +func (d *nicMACVLAN) UpdatableFields(oldDevice Type) []string { + // Check old and new device types match. + _, match := oldDevice.(*nicMACVLAN) + if !match { + return []string{} + } + + return []string{"connected"} +} + +// Update applies configuration changes to a started device. +func (d *nicMACVLAN) Update(oldDevices deviceConfig.Devices, isRunning bool) error { + if isRunning { + return d.setNICLink() + } + + return nil +} diff --git a/internal/server/device/nic_ovn.go b/internal/server/device/nic_ovn.go index 4ecd1082006..1b34c56b16e 100644 --- a/internal/server/device/nic_ovn.go +++ b/internal/server/device/nic_ovn.go @@ -79,7 +79,7 @@ func (d *nicOVN) UpdatableFields(oldDevice Type) []string { return []string{} } - return []string{"security.acls", "limits.ingress", "limits.egress", "limits.max", "limits.priority"} + return []string{"security.acls", "limits.ingress", "limits.egress", "limits.max", "limits.priority", "connected"} } // validateConfig checks the supplied config for correctness. @@ -326,6 +326,15 @@ func (d *nicOVN) validateConfig(instConf instance.ConfigReader) error { // required: no // shortdesc: Whether the NIC is plugged in or not "attached", + + // gendoc:generate(entity=devices, group=nic_ovn, key=connected) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is connected to the host network (container support requires setting `acceleration` to `none`) + "connected", } // The NIC's network may be a non-default project, so lookup project and get network's project name. @@ -584,6 +593,11 @@ func (d *nicOVN) validateConfig(instConf instance.ConfigReader) error { } } + // The connected option can only be handled properly on containers if acceleration is set to none. + if d.config["connected"] != "" && !d.canSetLink(instConf) { + return errors.New("The \"connected\" option requires setting acceleration=none on containers for OVN NICs") + } + return nil } @@ -1001,6 +1015,10 @@ func (d *nicOVN) Start() (*deviceConfig.RunConfig, error) { {Key: "link", Value: peerName}, } + if d.canSetLink(nil) { + runConf.NetworkInterface = append(runConf.NetworkInterface, deviceConfig.RunConfigItem{Key: "connected", Value: d.config["connected"]}) + } + instType := d.inst.Type() if instType == instancetype.VM { if d.config["acceleration"] == "sriov" { @@ -1156,6 +1174,10 @@ func (d *nicOVN) Update(oldDevices deviceConfig.Devices, isRunning bool) error { return err } + if isRunning && d.canSetLink(nil) { + return d.setNICLink() + } + return nil } @@ -1548,3 +1570,15 @@ func (d *nicOVN) setupHostNIC(hostName string, ovnPortName ovn.OVNSwitchPort) (r return cleanup, err } + +// canSetLink determines whether the device supports setting a link state. +func (d *nicOVN) canSetLink(instConf instance.ConfigReader) bool { + var instType instancetype.Type + if instConf != nil { + instType = instConf.Type() + } else { + instType = d.inst.Type() + } + + return instType == instancetype.VM || slices.Contains([]string{"", "none"}, d.config["acceleration"]) +} diff --git a/internal/server/device/nic_p2p.go b/internal/server/device/nic_p2p.go index 92cb20b5519..72359849375 100644 --- a/internal/server/device/nic_p2p.go +++ b/internal/server/device/nic_p2p.go @@ -134,6 +134,15 @@ func (d *nicP2P) validateConfig(instConf instance.ConfigReader) error { // required: no // shortdesc: Whether the NIC is plugged in or not "attached", + + // gendoc:generate(entity=devices, group=nic_p2p, key=connected) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is connected to the host network + "connected", } err := d.config.Validate(nicValidationRules([]string{}, optionalFields, instConf)) @@ -161,7 +170,7 @@ func (d *nicP2P) UpdatableFields(oldDevice Type) []string { return []string{} } - return []string{"limits.ingress", "limits.egress", "limits.max", "limits.priority", "ipv4.routes", "ipv6.routes"} + return []string{"limits.ingress", "limits.egress", "limits.max", "limits.priority", "ipv4.routes", "ipv6.routes", "connected"} } // Start is run when the device is added to a running instance or instance is starting up. @@ -246,6 +255,7 @@ func (d *nicP2P) Start() (*deviceConfig.RunConfig, error) { {Key: "flags", Value: "up"}, {Key: "link", Value: peerName}, {Key: "hwaddr", Value: d.config["hwaddr"]}, + {Key: "connected", Value: d.config["connected"]}, } if d.config["io.bus"] == "usb" { @@ -298,7 +308,7 @@ func (d *nicP2P) Update(oldDevices deviceConfig.Devices, isRunning bool) error { return err } - return nil + return d.setNICLink() } // Stop is run when the device is removed from the instance. diff --git a/internal/server/device/nic_physical.go b/internal/server/device/nic_physical.go index 735a1da5d3b..978ed6550fb 100644 --- a/internal/server/device/nic_physical.go +++ b/internal/server/device/nic_physical.go @@ -94,6 +94,15 @@ func (d *nicPhysical) validateConfig(instConf instance.ConfigReader) error { // required: no // shortdesc: Whether the NIC is plugged in or not "attached", + + // gendoc:generate(entity=devices, group=nic_physical, key=connected) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is connected to the host network (VM only) + "connected", } if instConf.Type() == instancetype.Container || instConf.Type() == instancetype.Any { @@ -177,6 +186,10 @@ func (d *nicPhysical) validateConfig(instConf instance.ConfigReader) error { requiredFields = append(requiredFields, "parent") } + if instConf.Type() != instancetype.VM && d.config["connected"] != "" { + return errors.New("The \"connected\" option is only supported on virtual machines for physical NICs") + } + err := d.config.Validate(nicValidationRules(requiredFields, optionalFields, instConf)) if err != nil { return err @@ -346,6 +359,7 @@ func (d *nicPhysical) Start() (*deviceConfig.RunConfig, error) { {Key: "name", Value: d.config["name"]}, {Key: "flags", Value: "up"}, {Key: "link", Value: saveData["host_name"]}, + {Key: "connected", Value: d.config["connected"]}, } if d.inst.Type() == instancetype.VM { @@ -550,3 +564,23 @@ func IsPhysicalNICWithBridge(s *state.State, deviceProjectName string, d deviceC return false } + +// UpdatableFields returns a list of fields that can be updated without triggering a device remove & add. +func (d *nicPhysical) UpdatableFields(oldDevice Type) []string { + // Check old and new device types match. + _, match := oldDevice.(*nicPhysical) + if !match { + return []string{} + } + + return []string{"connected"} +} + +// Update applies configuration changes to a started device. +func (d *nicPhysical) Update(oldDevices deviceConfig.Devices, isRunning bool) error { + if isRunning { + return d.setNICLink() + } + + return nil +} diff --git a/internal/server/device/nic_routed.go b/internal/server/device/nic_routed.go index fd4fdbafb0d..cf66fd215ff 100644 --- a/internal/server/device/nic_routed.go +++ b/internal/server/device/nic_routed.go @@ -44,7 +44,7 @@ func (d *nicRouted) UpdatableFields(oldDevice Type) []string { return []string{} } - return []string{"limits.ingress", "limits.egress", "limits.max", "limits.priority"} + return []string{"limits.ingress", "limits.egress", "limits.max", "limits.priority", "connected"} } // validateConfig checks the supplied config for correctness. @@ -252,6 +252,15 @@ func (d *nicRouted) validateConfig(instConf instance.ConfigReader) error { // required: no // shortdesc: Whether the NIC is plugged in or not "attached", + + // gendoc:generate(entity=devices, group=nic_routed, key=connected) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is connected to the host network + "connected", } rules := nicValidationRules(requiredFields, optionalFields, instConf) @@ -774,6 +783,7 @@ func (d *nicRouted) Start() (*deviceConfig.RunConfig, error) { {Key: "flags", Value: "up"}, {Key: "link", Value: peerName}, {Key: "hwaddr", Value: d.config["hwaddr"]}, + {Key: "connected", Value: d.config["connected"]}, } if d.config["io.bus"] == "usb" { @@ -870,6 +880,8 @@ func (d *nicRouted) Update(oldDevices deviceConfig.Devices, isRunning bool) erro if err != nil { return err } + + return d.setNICLink() } return nil diff --git a/internal/server/device/nic_sriov.go b/internal/server/device/nic_sriov.go index 0c939c1ac77..d3a4c363d6f 100644 --- a/internal/server/device/nic_sriov.go +++ b/internal/server/device/nic_sriov.go @@ -142,6 +142,15 @@ func (d *nicSRIOV) validateConfig(instConf instance.ConfigReader) error { // required: no // shortdesc: Whether the NIC is plugged in or not "attached", + + // gendoc:generate(entity=devices, group=nic_sriov, key=connected) + // + // --- + // type: bool + // default: `true` + // required: no + // shortdesc: Whether the NIC is connected to the host network (VM only) + "connected", } // Check that if network property is set that conflicting keys are not present. @@ -189,6 +198,10 @@ func (d *nicSRIOV) validateConfig(instConf instance.ConfigReader) error { requiredFields = append(requiredFields, "parent") } + if instConf.Type() != instancetype.VM && d.config["connected"] != "" { + return errors.New("The \"connected\" option is only supported on virtual machines for SR-IOV NICs") + } + err := d.config.Validate(nicValidationRules(requiredFields, optionalFields, instConf)) if err != nil { return err @@ -324,6 +337,7 @@ func (d *nicSRIOV) Start() (*deviceConfig.RunConfig, error) { {Key: "flags", Value: "up"}, {Key: "link", Value: saveData["host_name"]}, {Key: "hwaddr", Value: d.config["hwaddr"]}, + {Key: "connected", Value: d.config["connected"]}, } if d.inst.Type() == instancetype.VM { @@ -521,3 +535,23 @@ func nicSelected(device deviceConfig.Device, nic api.ResourcesNetworkCard) bool return false } + +// UpdatableFields returns a list of fields that can be updated without triggering a device remove & add. +func (d *nicSRIOV) UpdatableFields(oldDevice Type) []string { + // Check old and new device types match. + _, match := oldDevice.(*nicSRIOV) + if !match { + return []string{} + } + + return []string{"connected"} +} + +// Update applies configuration changes to a started device. +func (d *nicSRIOV) Update(oldDevices deviceConfig.Devices, isRunning bool) error { + if isRunning { + return d.setNICLink() + } + + return nil +} From 299db59b4bb72d64a07999a7520e4831308e3688 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 01:41:20 +0000 Subject: [PATCH 03/12] incusd/instance/qemu: Properly update detached devices This fixes a bug where modifying an updatable configuration key (for example, `limits.read`) while the device (for example a disk) is detached could lead the update function to ask QEMU to perform operations on a device it doesn't know. Signed-off-by: Benjamin Somers --- internal/server/instance/drivers/driver_qemu.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/server/instance/drivers/driver_qemu.go b/internal/server/instance/drivers/driver_qemu.go index 5632f23e82f..6a10e0d5242 100644 --- a/internal/server/instance/drivers/driver_qemu.go +++ b/internal/server/instance/drivers/driver_qemu.go @@ -6327,6 +6327,12 @@ func (d *qemu) Update(args db.InstanceArgs, userRequested bool) error { return []string{} // Couldn't create Device, so this cannot be an update. } + // Detached devices need to be fully recreated on update so that the update logic doesn't + // try to access non-existing QEMU devices. + if !util.IsTrueOrEmpty(oldDevice["attached"]) { + return []string{} + } + return newDevType.UpdatableFields(oldDevType) }) From 65b0ba52cb43375617b9ec4eecd71d050bb14f68 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 10:22:57 +0000 Subject: [PATCH 04/12] incusd/instance/lxc: Properly update detached devices Signed-off-by: Benjamin Somers --- internal/server/instance/drivers/driver_lxc.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/server/instance/drivers/driver_lxc.go b/internal/server/instance/drivers/driver_lxc.go index 8acabfd2542..6564de67a51 100644 --- a/internal/server/instance/drivers/driver_lxc.go +++ b/internal/server/instance/drivers/driver_lxc.go @@ -4813,6 +4813,12 @@ func (d *lxc) Update(args db.InstanceArgs, userRequested bool) error { return []string{} // Couldn't create Device, so this cannot be an update. } + // Detached devices need to be fully recreated on update so that the update logic doesn't + // try to access non-existing LXC devices. + if !util.IsTrueOrEmpty(oldDevice["attached"]) { + return []string{} + } + return newDevType.UpdatableFields(oldDevType) }) From 0d6bc20fb0597befa28d14d613db662d42ba0b59 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 03:13:49 +0000 Subject: [PATCH 05/12] incusd/device/nic_ovn: Factor common options Signed-off-by: Benjamin Somers --- internal/server/device/nic_ovn.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/server/device/nic_ovn.go b/internal/server/device/nic_ovn.go index 1b34c56b16e..06d9d2e31bd 100644 --- a/internal/server/device/nic_ovn.go +++ b/internal/server/device/nic_ovn.go @@ -1021,13 +1021,16 @@ func (d *nicOVN) Start() (*deviceConfig.RunConfig, error) { instType := d.inst.Type() if instType == instancetype.VM { + runConf.NetworkInterface = append(runConf.NetworkInterface, + []deviceConfig.RunConfigItem{ + {Key: "devName", Value: d.name}, + {Key: "mtu", Value: fmt.Sprintf("%d", mtu)}, + }...) if d.config["acceleration"] == "sriov" { runConf.NetworkInterface = append(runConf.NetworkInterface, []deviceConfig.RunConfigItem{ - {Key: "devName", Value: d.name}, {Key: "pciSlotName", Value: vfPCIDev.SlotName}, {Key: "pciIOMMUGroup", Value: fmt.Sprintf("%d", pciIOMMUGroup)}, - {Key: "mtu", Value: fmt.Sprintf("%d", mtu)}, }...) } else if d.config["acceleration"] == "vdpa" { if vDPADevice == nil { @@ -1036,20 +1039,16 @@ func (d *nicOVN) Start() (*deviceConfig.RunConfig, error) { runConf.NetworkInterface = append(runConf.NetworkInterface, []deviceConfig.RunConfigItem{ - {Key: "devName", Value: d.name}, {Key: "pciSlotName", Value: vfPCIDev.SlotName}, {Key: "pciIOMMUGroup", Value: fmt.Sprintf("%d", pciIOMMUGroup)}, {Key: "maxVQP", Value: fmt.Sprintf("%d", vDPADevice.MaxVQs/2)}, {Key: "vDPADevName", Value: vDPADevice.Name}, {Key: "vhostVDPAPath", Value: vDPADevice.VhostVDPA.Path}, - {Key: "mtu", Value: fmt.Sprintf("%d", mtu)}, }...) } else { runConf.NetworkInterface = append(runConf.NetworkInterface, []deviceConfig.RunConfigItem{ - {Key: "devName", Value: d.name}, {Key: "hwaddr", Value: d.config["hwaddr"]}, - {Key: "mtu", Value: fmt.Sprintf("%d", mtu)}, }...) } } else if instType == instancetype.Container { From f52525b349d87398918e11369b2ee1121dad9256 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 09:53:47 +0000 Subject: [PATCH 06/12] incusd/device/nic_p2p: Fix boot.priority spelling in gendoc Signed-off-by: Benjamin Somers --- internal/server/device/nic_p2p.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/device/nic_p2p.go b/internal/server/device/nic_p2p.go index 72359849375..15482b278c7 100644 --- a/internal/server/device/nic_p2p.go +++ b/internal/server/device/nic_p2p.go @@ -111,7 +111,7 @@ func (d *nicP2P) validateConfig(instConf instance.ConfigReader) error { // shortdesc: Comma-delimited list of IPv6 static routes to add on host to NIC "ipv6.routes", - // gendoc:generate(entity=devices, group=nic_p2p, key=boot.priotiry) + // gendoc:generate(entity=devices, group=nic_p2p, key=boot.priority) // // --- // type: integer From 189171c269c6237ef926f0e767ec51d9dbdaccab Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 01:48:43 +0000 Subject: [PATCH 07/12] incusd/instance/qemu: Implement NIC connected config key Signed-off-by: Benjamin Somers --- .../server/instance/drivers/driver_qemu.go | 37 +++++++++++++++++-- .../server/instance/drivers/qmp/commands.go | 24 +++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/internal/server/instance/drivers/driver_qemu.go b/internal/server/instance/drivers/driver_qemu.go index 6a10e0d5242..5cc43a18136 100644 --- a/internal/server/instance/drivers/driver_qemu.go +++ b/internal/server/instance/drivers/driver_qemu.go @@ -4929,6 +4929,7 @@ func (d *qemu) addNetDevConfig(busName string, qemuDev map[string]any, bootIndex defer reverter.Fail() var devName, nicName, devHwaddr, pciSlotName, pciIOMMUGroup, vDPADevName, vhostVDPAPath, maxVQP string + var connected bool for _, nicItem := range nicConfig { if nicItem.Key == "devName" { devName = nicItem.Value @@ -4946,6 +4947,8 @@ func (d *qemu) addNetDevConfig(busName string, qemuDev map[string]any, bootIndex vhostVDPAPath = nicItem.Value } else if nicItem.Key == "maxVQP" { maxVQP = nicItem.Value + } else if nicItem.Key == "connected" { + connected = util.IsTrueOrEmpty(nicItem.Value) } } @@ -5062,7 +5065,7 @@ func (d *qemu) addNetDevConfig(busName string, qemuDev map[string]any, bootIndex qemuDev["netdev"] = qemuNetDev["id"].(string) qemuDev["mac"] = devHwaddr - err = m.AddNIC(qemuNetDev, qemuDev) + err = m.AddNIC(qemuNetDev, qemuDev, connected) if err != nil { return fmt.Errorf("Failed setting up device %q: %w", devName, err) } @@ -5171,7 +5174,7 @@ func (d *qemu) addNetDevConfig(busName string, qemuDev map[string]any, bootIndex qemuDev["iommu_platform"] = true qemuDev["disable-legacy"] = true - err = m.AddNIC(qemuNetDev, qemuDev) + err = m.AddNIC(qemuNetDev, qemuDev, connected) if err != nil { return fmt.Errorf("Failed setting up device %q: %w", devName, err) } @@ -5204,7 +5207,7 @@ func (d *qemu) addNetDevConfig(busName string, qemuDev map[string]any, bootIndex } monHook = func(m *qmp.Monitor) error { - err := m.AddNIC(nil, qemuDev) + err := m.AddNIC(nil, qemuDev, connected) if err != nil { return fmt.Errorf("Failed setting up device %q: %w", devName, err) } @@ -9165,6 +9168,34 @@ func (d *qemu) DeviceEventHandler(runConf *deviceConfig.RunConfig) error { } } + // Handle NIC reconfiguration. + var devName string + var connected bool + for _, dev := range runConf.NetworkInterface { + switch dev.Key { + case "devName": + devName = dev.Value + case "connected": + connected = util.IsTrueOrEmpty(dev.Value) + } + } + + if devName != "" { + // Get the QMP monitor. + m, err := d.qmpConnect() + if err != nil { + return err + } + + // Figure out the QEMU device ID. + devID := fmt.Sprintf("%s%s", qemuDeviceIDPrefix, linux.PathNameEncode(devName)) + + err = m.SetNICLink(devID, connected) + if err != nil { + return fmt.Errorf("Failed setting NIC device link status: %w", err) + } + } + return nil } diff --git a/internal/server/instance/drivers/qmp/commands.go b/internal/server/instance/drivers/qmp/commands.go index 4980eb38c34..381213173b9 100644 --- a/internal/server/instance/drivers/qmp/commands.go +++ b/internal/server/instance/drivers/qmp/commands.go @@ -867,7 +867,7 @@ func (m *Monitor) RemoveDevice(deviceID string) error { } // AddNIC adds a NIC device. -func (m *Monitor) AddNIC(netDev map[string]any, device map[string]any) error { +func (m *Monitor) AddNIC(netDev map[string]any, device map[string]any, connected bool) error { reverter := revert.New() defer reverter.Fail() @@ -894,6 +894,16 @@ func (m *Monitor) AddNIC(netDev map[string]any, device map[string]any) error { return fmt.Errorf("Failed adding NIC device: %w", err) } + id, ok := device["id"].(string) + if !ok { + return errors.New("NIC device must have an id") + } + + err = m.SetNICLink(id, connected) + if err != nil { + return fmt.Errorf("Failed setting NIC device link status: %w", err) + } + reverter.Success() return nil @@ -1562,3 +1572,15 @@ func (m *Monitor) DumpGuestMemory(path string, format string) error { return m.Run("dump-guest-memory", args, &queryResp) } + +// SetNICLink sets the link status of the given device. +func (m *Monitor) SetNICLink(id string, connected bool) error { + var args struct { + Name string `json:"name"` + Up bool `json:"up"` + } + + args.Name = id + args.Up = connected + return m.Run("set_link", args, nil) +} From 74b7cd5d14c5b2c00e44cd2c49222b0fb3be49b1 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 16:18:15 +0000 Subject: [PATCH 08/12] incusd/ip/link: Relax parent detection logic This commit fixes the fact that `LinkByName` reported a link not found error when run on a veth pair whose other end is in a container. Signed-off-by: Benjamin Somers --- internal/server/ip/link.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/server/ip/link.go b/internal/server/ip/link.go index 4c50acfa360..ef671c913a3 100644 --- a/internal/server/ip/link.go +++ b/internal/server/ip/link.go @@ -97,11 +97,9 @@ func LinkByName(name string) (LinkInfo, error) { if link.Attrs().ParentIndex != 0 { parentLink, err := netlink.LinkByIndex(link.Attrs().ParentIndex) - if err != nil { - return LinkInfo{}, err + if err == nil { + parent = parentLink.Attrs().Name } - - parent = parentLink.Attrs().Name } if link.Attrs().MasterIndex != 0 { From 7ff5d06a97bfcb55c3666583832b5bf1e5a8fed5 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 17:19:10 +0000 Subject: [PATCH 09/12] incusd/instance/lxc: Implement NIC connected config key Signed-off-by: Benjamin Somers --- .../server/instance/drivers/driver_lxc.go | 76 ++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/internal/server/instance/drivers/driver_lxc.go b/internal/server/instance/drivers/driver_lxc.go index 6564de67a51..d3f75a2a293 100644 --- a/internal/server/instance/drivers/driver_lxc.go +++ b/internal/server/instance/drivers/driver_lxc.go @@ -60,6 +60,7 @@ import ( "github.com/lxc/incus/v6/internal/server/instance" "github.com/lxc/incus/v6/internal/server/instance/instancetype" "github.com/lxc/incus/v6/internal/server/instance/operationlock" + "github.com/lxc/incus/v6/internal/server/ip" "github.com/lxc/incus/v6/internal/server/lifecycle" "github.com/lxc/incus/v6/internal/server/locking" "github.com/lxc/incus/v6/internal/server/metrics" @@ -1493,7 +1494,7 @@ func (d *lxc) deviceStart(dev device.Device, instanceRunning bool) (*deviceConfi // Attach network interface if requested. if len(runConf.NetworkInterface) > 0 { - err = d.deviceAttachNIC(configCopy, runConf.NetworkInterface) + err = d.deviceAttachNIC(dev.Name(), configCopy, runConf.NetworkInterface) if err != nil { return nil, err } @@ -1570,16 +1571,18 @@ func (d *lxc) deviceAddCgroupRules(cgroups []deviceConfig.RunConfigItem) error { } // deviceAttachNIC live attaches a NIC device to a container. -func (d *lxc) deviceAttachNIC(configCopy map[string]string, netIF []deviceConfig.RunConfigItem) error { - devName := "" +func (d *lxc) deviceAttachNIC(devName string, configCopy map[string]string, netIF []deviceConfig.RunConfigItem) error { + ctDevName := "" + connected := true for _, dev := range netIF { if dev.Key == "link" { - devName = dev.Value - break + ctDevName = dev.Value + } else if dev.Key == "connected" { + connected = util.IsTrueOrEmpty(dev.Value) } } - if devName == "" { + if ctDevName == "" { return errors.New("Device didn't provide a link property to use") } @@ -1590,12 +1593,12 @@ func (d *lxc) deviceAttachNIC(configCopy map[string]string, netIF []deviceConfig } // Add the interface to the container. - err = cc.AttachInterface(devName, configCopy["name"]) + err = cc.AttachInterface(ctDevName, configCopy["name"]) if err != nil { - return fmt.Errorf("Failed to attach interface: %s to %s: %w", devName, configCopy["name"], err) + return fmt.Errorf("Failed to attach interface: %s to %s: %w", ctDevName, configCopy["name"], err) } - return nil + return d.setNICLink(devName, connected, true) } // deviceStop loads a new device and calls its Stop() function. @@ -1817,6 +1820,25 @@ func (d *lxc) DeviceEventHandler(runConf *deviceConfig.RunConfig) error { } } + // Handle NIC reconfiguration. + var devName string + var connected bool + for _, dev := range runConf.NetworkInterface { + switch dev.Key { + case "devName": + devName = dev.Value + case "connected": + connected = util.IsTrueOrEmpty(dev.Value) + } + } + + if devName != "" { + err := d.setNICLink(devName, connected, false) + if err != nil { + return err + } + } + // Run any post hooks requested. err := d.runHooks(runConf.PostHooks) if err != nil { @@ -2302,6 +2324,15 @@ func (d *lxc) startCommon() (string, []func() error, error) { } for _, nicItem := range runConf.NetworkInterface { + // The connected state is not a LXC configuration key; we defer its handling to a post hook. + if nicItem.Key == "connected" { + runConf.PostHooks = append(runConf.PostHooks, func() error { + return d.setNICLink(dev.Name(), util.IsTrueOrEmpty(nicItem.Value), true) + }) + + continue + } + err = lxcSetConfigItem(cc, fmt.Sprintf("%s.%d.%s", networkKeyPrefix, nicID, nicItem.Key), nicItem.Value) if err != nil { return "", nil, fmt.Errorf("Failed to setup device network interface %q: %w", dev.Name(), err) @@ -9402,3 +9433,30 @@ func (d *lxc) CreateQcow2Snapshot(snapName string, backingFilename string) error func (d *lxc) DeleteQcow2Snapshot(snapshotIndex int, backingFilename string) error { return nil } + +// setNICLink sets the link status of the given device. +func (d *lxc) setNICLink(devName string, connected bool, assumeUp bool) error { + // This check is added so that devices that cannot handle link states do not fail to initialize. + if connected && assumeUp { + return nil + } + + link, err := ip.LinkByName(d.localConfig["volatile."+devName+".host_name"]) + if err != nil { + return fmt.Errorf("Failed to find interface %s: %w", devName, err) + } + + if connected { + err = link.SetUp() + if err != nil { + return fmt.Errorf("Failed to bring %s up: %w", devName, err) + } + } else { + err = link.SetDown() + if err != nil { + return fmt.Errorf("Failed to bring %s down: %w", devName, err) + } + } + + return nil +} From 79cd4ad52b13080bdbbff2dd098d3e4d8e48d413 Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 18:27:48 +0000 Subject: [PATCH 10/12] api: nic_attached_connected Signed-off-by: Benjamin Somers --- doc/api-extensions.md | 7 +++++++ internal/version/api.go | 1 + 2 files changed, 8 insertions(+) diff --git a/doc/api-extensions.md b/doc/api-extensions.md index e8d32e10841..eca83253d77 100644 --- a/doc/api-extensions.md +++ b/doc/api-extensions.md @@ -2974,3 +2974,10 @@ Adds support for selecting an SR-IOV network interface by vendor ID, product ID, ## `network_zones_dns_contact` Adds a `dns.contact` configuration key to network zones. + +## `nic_attached_connected` + +This introduces two new properties for NICs: + +* `attached`, behaving like the `attached` key for disk and USB devices; +* `connected`, setting the up/down link state for the NIC (when supported). diff --git a/internal/version/api.go b/internal/version/api.go index 8e4d5201565..f667fe4d75a 100644 --- a/internal/version/api.go +++ b/internal/version/api.go @@ -516,6 +516,7 @@ var APIExtensions = []string{ "file_delete_force", "nic_sriov_select_ext", "network_zones_dns_contact", + "nic_attached_connected", } // APIExtensionsCount returns the number of available API extensions. From 24ce2b63c469b5664e6ba9cdffaddbe6e23b522e Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 18:29:40 +0000 Subject: [PATCH 11/12] doc: Update config Signed-off-by: Benjamin Somers --- doc/config_options.txt | 122 ++++++++++++++++- internal/server/metadata/configuration.json | 137 +++++++++++++++++++- 2 files changed, 257 insertions(+), 2 deletions(-) diff --git a/doc/config_options.txt b/doc/config_options.txt index 506f71109ad..8afdeddad83 100644 --- a/doc/config_options.txt +++ b/doc/config_options.txt @@ -443,6 +443,14 @@ With VMs, this option supports mounting file system disk devices and paths withi +```{config:option} attached devices-nic_bridged +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is plugged in or not" +:type: "bool" + +``` + ```{config:option} boot.priority devices-nic_bridged :managed: "no" :shortdesc: "Boot priority for VMs (higher value boots first)" @@ -450,6 +458,14 @@ With VMs, this option supports mounting file system disk devices and paths withi ``` +```{config:option} connected devices-nic_bridged +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is connected to the host network" +:type: "bool" + +``` + ```{config:option} host_name devices-nic_bridged :default: "randomly assigned" :managed: "no" @@ -668,6 +684,14 @@ With VMs, this option supports mounting file system disk devices and paths withi +```{config:option} attached devices-nic_ipvlan +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is plugged in or not" +:type: "bool" + +``` + ```{config:option} gvrp devices-nic_ipvlan :default: "false" :shortdesc: "Register VLAN using GARP VLAN Registration Protocol" @@ -755,6 +779,14 @@ With VMs, this option supports mounting file system disk devices and paths withi +```{config:option} attached devices-nic_macvlan +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is plugged in or not" +:type: "bool" + +``` + ```{config:option} boot.priority devices-nic_macvlan :managed: "no" :shortdesc: "Boot priority for VMs (higher value boots first)" @@ -762,6 +794,14 @@ With VMs, this option supports mounting file system disk devices and paths withi ``` +```{config:option} connected devices-nic_macvlan +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is connected to the host network (VM only)" +:type: "bool" + +``` + ```{config:option} gvrp devices-nic_macvlan :default: "false" :managed: "no" @@ -841,6 +881,14 @@ With VMs, this option supports mounting file system disk devices and paths withi ``` +```{config:option} attached devices-nic_ovn +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is plugged in or not" +:type: "bool" + +``` + ```{config:option} boot.priority devices-nic_ovn :managed: "no" :shortdesc: "Boot priority for VMs (higher value boots first)" @@ -848,6 +896,14 @@ With VMs, this option supports mounting file system disk devices and paths withi ``` +```{config:option} connected devices-nic_ovn +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is connected to the host network (container support requires setting `acceleration` to `none`)" +:type: "bool" + +``` + ```{config:option} host_name devices-nic_ovn :default: "randomly assigned" :managed: "no" @@ -1035,12 +1091,28 @@ With VMs, this option supports mounting file system disk devices and paths withi -```{config:option} boot.priotiry devices-nic_p2p +```{config:option} attached devices-nic_p2p +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is plugged in or not" +:type: "bool" + +``` + +```{config:option} boot.priority devices-nic_p2p :shortdesc: "Boot priority for VMs (higher value boots first)" :type: "integer" ``` +```{config:option} connected devices-nic_p2p +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is connected to the host network" +:type: "bool" + +``` + ```{config:option} host_name devices-nic_p2p :default: "randomly assigned" :shortdesc: "The name of the interface on the host" @@ -1120,6 +1192,14 @@ With VMs, this option supports mounting file system disk devices and paths withi +```{config:option} attached devices-nic_physical +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is plugged in or not" +:type: "bool" + +``` + ```{config:option} boot.priority devices-nic_physical :managed: "no" :shortdesc: "Boot priority for VMs (higher value boots first)" @@ -1127,6 +1207,14 @@ With VMs, this option supports mounting file system disk devices and paths withi ``` +```{config:option} connected devices-nic_physical +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is connected to the host network (VM only)" +:type: "bool" + +``` + ```{config:option} gvrp devices-nic_physical :default: "false" :managed: "no" @@ -1189,6 +1277,22 @@ With VMs, this option supports mounting file system disk devices and paths withi +```{config:option} attached devices-nic_routed +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is plugged in or not" +:type: "bool" + +``` + +```{config:option} connected devices-nic_routed +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is connected to the host network" +:type: "bool" + +``` + ```{config:option} gvrp devices-nic_routed :default: "false" :shortdesc: "Register VLAN using GARP VLAN Registration Protocol" @@ -1375,6 +1479,14 @@ The custom policy routing table ID to add IPv6 static routes to (in addition to +```{config:option} attached devices-nic_sriov +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is plugged in or not" +:type: "bool" + +``` + ```{config:option} boot.priority devices-nic_sriov :managed: "no" :shortdesc: "Boot priority for VMs (higher value boots first)" @@ -1382,6 +1494,14 @@ The custom policy routing table ID to add IPv6 static routes to (in addition to ``` +```{config:option} connected devices-nic_sriov +:default: "`true`" +:required: "no" +:shortdesc: "Whether the NIC is connected to the host network (VM only)" +:type: "bool" + +``` + ```{config:option} hwaddr devices-nic_sriov :default: "randomly assigned" :managed: "no" diff --git a/internal/server/metadata/configuration.json b/internal/server/metadata/configuration.json index 73e9044bc11..1d16b5c9d8f 100644 --- a/internal/server/metadata/configuration.json +++ b/internal/server/metadata/configuration.json @@ -484,6 +484,15 @@ }, "nic_bridged": { "keys": [ + { + "attached": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is plugged in or not", + "type": "bool" + } + }, { "boot.priority": { "longdesc": "", @@ -492,6 +501,15 @@ "type": "integer" } }, + { + "connected": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is connected to the host network", + "type": "bool" + } + }, { "host_name": { "default": "randomly assigned", @@ -741,6 +759,15 @@ }, "nic_ipvlan": { "keys": [ + { + "attached": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is plugged in or not", + "type": "bool" + } + }, { "gvrp": { "default": "false", @@ -843,6 +870,15 @@ }, "nic_macvlan": { "keys": [ + { + "attached": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is plugged in or not", + "type": "bool" + } + }, { "boot.priority": { "longdesc": "", @@ -851,6 +887,15 @@ "type": "integer" } }, + { + "connected": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is connected to the host network (VM only)", + "type": "bool" + } + }, { "gvrp": { "default": "false", @@ -942,6 +987,15 @@ "type": "string" } }, + { + "attached": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is plugged in or not", + "type": "bool" + } + }, { "boot.priority": { "longdesc": "", @@ -950,6 +1004,15 @@ "type": "integer" } }, + { + "connected": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is connected to the host network (container support requires setting `acceleration` to `none`)", + "type": "bool" + } + }, { "host_name": { "default": "randomly assigned", @@ -1165,12 +1228,30 @@ "nic_p2p": { "keys": [ { - "boot.priotiry": { + "attached": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is plugged in or not", + "type": "bool" + } + }, + { + "boot.priority": { "longdesc": "", "shortdesc": "Boot priority for VMs (higher value boots first)", "type": "integer" } }, + { + "connected": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is connected to the host network", + "type": "bool" + } + }, { "host_name": { "default": "randomly assigned", @@ -1264,6 +1345,15 @@ }, "nic_physical": { "keys": [ + { + "attached": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is plugged in or not", + "type": "bool" + } + }, { "boot.priority": { "longdesc": "", @@ -1272,6 +1362,15 @@ "type": "integer" } }, + { + "connected": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is connected to the host network (VM only)", + "type": "bool" + } + }, { "gvrp": { "default": "false", @@ -1344,6 +1443,24 @@ }, "nic_routed": { "keys": [ + { + "attached": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is plugged in or not", + "type": "bool" + } + }, + { + "connected": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is connected to the host network", + "type": "bool" + } + }, { "gvrp": { "default": "false", @@ -1558,6 +1675,15 @@ }, "nic_sriov": { "keys": [ + { + "attached": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is plugged in or not", + "type": "bool" + } + }, { "boot.priority": { "longdesc": "", @@ -1566,6 +1692,15 @@ "type": "integer" } }, + { + "connected": { + "default": "`true`", + "longdesc": "", + "required": "no", + "shortdesc": "Whether the NIC is connected to the host network (VM only)", + "type": "bool" + } + }, { "hwaddr": { "default": "randomly assigned", From 97b2bac903f7793deb07bd7b949ad95dcd2a957e Mon Sep 17 00:00:00 2001 From: Benjamin Somers Date: Thu, 22 Jan 2026 22:10:17 +0000 Subject: [PATCH 12/12] test Signed-off-by: Benjamin Somers --- .github/workflows/tests.yml | 4 -- test/main.sh | 40 +++++++++---------- test/suites/container_devices_nic_bridged.sh | 24 +++++++++++ test/suites/container_devices_nic_macvlan.sh | 12 ++++++ test/suites/container_devices_nic_p2p.sh | 24 +++++++++++ test/suites/container_devices_nic_physical.sh | 24 +++++++++++ test/suites/container_devices_nic_routed.sh | 25 ++++++++++++ test/suites/container_devices_nic_sriov.sh | 22 ++++++++++ 8 files changed, 151 insertions(+), 24 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 25e62e10337..f4061d17e43 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -134,11 +134,7 @@ jobs: - stable - tip suite: - - cluster - - standalone_core - - standalone_container - standalone_network - - standalone_storage backend: - dir os: diff --git a/test/main.sh b/test/main.sh index afa783dec30..2c361b751c5 100755 --- a/test/main.sh +++ b/test/main.sh @@ -304,11 +304,11 @@ run_standalone_storage() { # Network and networking related tests run_standalone_network() { - run_test test_address_set "network address set" - run_test test_container_devices_nic_bridged_acl "container devices - nic - bridged - acl" + ##run_test test_address_set "network address set" + ##run_test test_container_devices_nic_bridged_acl "container devices - nic - bridged - acl" run_test test_container_devices_nic_bridged "container devices - nic - bridged" - run_test test_container_devices_nic_bridged_filtering "container devices - nic - bridged - filtering" - run_test test_container_devices_nic_bridged_vlan "container devices - nic - bridged - vlan" + ##run_test test_container_devices_nic_bridged_filtering "container devices - nic - bridged - filtering" + ##run_test test_container_devices_nic_bridged_vlan "container devices - nic - bridged - vlan" run_test test_container_devices_nic_ipvlan "container devices - nic - ipvlan" run_test test_container_devices_nic_macvlan "container devices - nic - macvlan" run_test test_container_devices_nic_p2p "container devices - nic - p2p" @@ -316,19 +316,19 @@ run_standalone_network() { run_test test_container_devices_nic_routed "container devices - nic - routed" run_test test_container_devices_nic_sriov "container devices - nic - sriov" run_test test_container_devices_proxy "container devices - proxy" - run_test test_network_acl "network ACL management" - run_test test_network_dhcp_routes "network dhcp routes" - run_test test_network_forward "network address forwards" - run_test test_network_hwaddr_pattern "network MAC address pattern" - run_test test_network "network management" - run_test test_network_peers "network peers" - run_test test_network_zone "network DNS zones" - run_test test_oidc "OpenID Connect" - run_test test_openfga "OpenFGA" - run_test test_remote_admin "remote administration" - run_test test_remote_url "remote url handling" - run_test test_remote_usage "remote usage" - run_test test_syslog_socket "Syslog socket" + ##run_test test_network_acl "network ACL management" + ##run_test test_network_dhcp_routes "network dhcp routes" + ##run_test test_network_forward "network address forwards" + ##run_test test_network_hwaddr_pattern "network MAC address pattern" + ##run_test test_network "network management" + ##run_test test_network_peers "network peers" + ##run_test test_network_zone "network DNS zones" + ##run_test test_oidc "OpenID Connect" + ##run_test test_openfga "OpenFGA" + ##run_test test_remote_admin "remote administration" + ##run_test test_remote_url "remote url handling" + ##run_test test_remote_usage "remote usage" + ##run_test test_syslog_socket "Syslog socket" } # Any other container test @@ -407,10 +407,10 @@ case "${1:-""}" in run_cluster ;; standalone) - run_standalone_core - run_standalone_container + ##run_standalone_core + ##run_standalone_container run_standalone_network - run_standalone_storage + ##run_standalone_storage ;; standalone_*) # shellcheck disable=SC2086 diff --git a/test/suites/container_devices_nic_bridged.sh b/test/suites/container_devices_nic_bridged.sh index ea600f5997a..9443c6713d8 100644 --- a/test/suites/container_devices_nic_bridged.sh +++ b/test/suites/container_devices_nic_bridged.sh @@ -709,6 +709,30 @@ test_container_devices_nic_bridged() { ! incus start foo2 || false incus delete -f foo foo2 + # Test attached and connected keys. + incus launch testimage foo + incus config device add foo eth0 nic nictype=bridged name=eth0 parent=${brName} + incus exec foo ip link set eth0 up + [ "$(incus file pull foo/sys/class/net/eth0/operstate -)" = "up" ] + incus config device set foo eth0 connected=false + incus exec foo ip link set eth0 up + [ "$(incus file pull foo/sys/class/net/eth0/operstate -)" = "down" ] + incus config device set foo eth0 attached=false + ! incus file pull foo/sys/class/net/eth0/operstate - || false + incus config device set foo eth0 connected=true + ! incus file pull foo/sys/class/net/eth0/operstate - || false + incus config device set foo eth0 attached=true + incus exec foo ip link set eth0 up + [ "$(incus file pull foo/sys/class/net/eth0/operstate -)" = "up" ] + incus config device set foo eth0 connected=false + incus exec foo ip link set eth0 up + [ "$(incus file pull foo/sys/class/net/eth0/operstate -)" = "down" ] + # Check that it survives a reboot. + incus restart foo + incus exec foo ip link set eth0 up + [ "$(incus file pull foo/sys/class/net/eth0/operstate -)" = "down" ] + incus delete -f foo + # Check we haven't left any NICS lying around. endNicCount=$(find /sys/class/net | wc -l) if [ "$startNicCount" != "$endNicCount" ]; then diff --git a/test/suites/container_devices_nic_macvlan.sh b/test/suites/container_devices_nic_macvlan.sh index 6b0a1a69bdb..3e7a10803b9 100644 --- a/test/suites/container_devices_nic_macvlan.sh +++ b/test/suites/container_devices_nic_macvlan.sh @@ -115,6 +115,18 @@ test_container_devices_nic_macvlan() { incus exec "${ctName}" -- ping -c2 -W5 "192.0.2.2${ipRand}" incus exec "${ctName}" -- ping6 -c2 -W5 "2001:db8::2${ipRand}" incus config device remove "${ctName}" eth0 + + # Test attached key. + incus config device add "${ctName}" eth0 nic network="${ctName}net" name=eth0 + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 attached=false + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 attached=true + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device remove "${ctName}" eth0 + incus network delete "${ctName}net" # Check we haven't left any NICS lying around. diff --git a/test/suites/container_devices_nic_p2p.sh b/test/suites/container_devices_nic_p2p.sh index 9dd5288bc7a..7dafb40bb4f 100644 --- a/test/suites/container_devices_nic_p2p.sh +++ b/test/suites/container_devices_nic_p2p.sh @@ -358,6 +358,30 @@ test_container_devices_nic_p2p() { # Test hotplugging nic with new name (rather than updating existing nic). incus config device add "${ctName}" eth1 nic nictype=p2p + incus config device remove "${ctName}" eth1 + + # Test attached and connected keys. + incus config device add "${ctName}" eth0 nic nictype=p2p name=eth0 + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 connected=false + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + incus config device set "${ctName}" eth0 attached=false + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 connected=true + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 attached=true + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 connected=false + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + # Check that it survives a reboot. + incus restart "${ctName}" + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + incus stop -f "${ctName}" # Check we haven't left any NICS lying around. diff --git a/test/suites/container_devices_nic_physical.sh b/test/suites/container_devices_nic_physical.sh index 6f6236c0af1..a07cb04ca18 100644 --- a/test/suites/container_devices_nic_physical.sh +++ b/test/suites/container_devices_nic_physical.sh @@ -216,6 +216,30 @@ test_container_devices_nic_physical() { fi fi + incus config device remove "${ctName}" eth1 + + # Test attached and connected keys. + incus config device add "${ctName}" eth0 nic nictype=p2p name=eth0 + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 connected=false + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + incus config device set "${ctName}" eth0 attached=false + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 connected=true + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 attached=true + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 connected=false + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + # Check that it survives a reboot. + incus restart "${ctName}" + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + incus delete "${ctName}" -f incus network delete "${networkName}" diff --git a/test/suites/container_devices_nic_routed.sh b/test/suites/container_devices_nic_routed.sh index 7b40bed7f9e..97dcf0da5e6 100644 --- a/test/suites/container_devices_nic_routed.sh +++ b/test/suites/container_devices_nic_routed.sh @@ -267,6 +267,31 @@ test_container_devices_nic_routed() { false fi + incus config device remove "${ctName}" eth0 + incus start "${ctName}" + + # Test attached and connected keys. + incus config device add "${ctName}" eth0 nic network="${ctName}" name=eth0 + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 connected=false + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + incus config device set "${ctName}" eth0 attached=false + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 connected=true + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 attached=true + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 connected=false + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + # Check that it survives a reboot. + incus restart "${ctName}" + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + # Cleanup routed checks incus delete "${ctName}" -f incus delete "${ctName}2" -f diff --git a/test/suites/container_devices_nic_sriov.sh b/test/suites/container_devices_nic_sriov.sh index 9a0ac9b550b..b71b2820a8f 100644 --- a/test/suites/container_devices_nic_sriov.sh +++ b/test/suites/container_devices_nic_sriov.sh @@ -154,6 +154,28 @@ test_container_devices_nic_sriov() { false fi + # Test attached and connected keys. + incus config device add "${ctName}" eth0 nic network="${ctName}" name=eth0 + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 connected=false + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + incus config device set "${ctName}" eth0 attached=false + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 connected=true + ! incus file pull "${ctName}/sys/class/net/eth0/operstate" - || false + incus config device set "${ctName}" eth0 attached=true + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "up" ] + incus config device set "${ctName}" eth0 connected=false + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + # Check that it survives a reboot. + incus restart "${ctName}" + incus exec "${ctName}" ip link set eth0 up + [ "$(incus file pull "${ctName}/sys/class/net/eth0/operstate" -)" = "down" ] + incus config device remove "${ctName}" eth0 incus network delete "${ctName}net"