From aa8c92944ca562f397e69231ae1ce37095ddeead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:03:35 -0400 Subject: [PATCH 1/9] incusd/network/ovn: Add Enabled option to OVNSwitchPortOpts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/ovn/ovn_nb_actions.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/server/network/ovn/ovn_nb_actions.go b/internal/server/network/ovn/ovn_nb_actions.go index 926e7d1f85..df7257dba7 100644 --- a/internal/server/network/ovn/ovn_nb_actions.go +++ b/internal/server/network/ovn/ovn_nb_actions.go @@ -136,6 +136,7 @@ type OVNSwitchPortOpts struct { Location string // Optional, use to indicate the name of the server this port is bound to. RouterPort OVNRouterPort // Optional, the name of the associated logical router port. Promiscuous bool // Optional, controls whether to allow unknown traffic on the port. + Enabled *bool // Optional, if set controls the administrative state of the port. } // OVNACLRule represents an ACL rule that can be added to a logical switch or port group. @@ -1875,6 +1876,10 @@ func (o *NB) CreateLogicalSwitchPort(ctx context.Context, switchName OVNSwitch, if opts.Location != "" { logicalSwitchPort.ExternalIDs[ovnExtIDIncusLocation] = opts.Location } + + if opts.Enabled != nil { + logicalSwitchPort.Enabled = opts.Enabled + } } logicalSwitchPort.ExternalIDs[ovnExtIDIncusSwitch] = string(switchName) From 9be745c34109cae51b4907bb3895c2956d187439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:04:04 -0400 Subject: [PATCH 2/9] incusd/network/ovn: Add UpdateLogicalSwitchPortEnabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/ovn/ovn_nb_actions.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/server/network/ovn/ovn_nb_actions.go b/internal/server/network/ovn/ovn_nb_actions.go index df7257dba7..91f52d9d08 100644 --- a/internal/server/network/ovn/ovn_nb_actions.go +++ b/internal/server/network/ovn/ovn_nb_actions.go @@ -2100,6 +2100,40 @@ func (o *NB) UpdateLogicalSwitchPortOptions(ctx context.Context, portName OVNSwi return nil } +// UpdateLogicalSwitchPortEnabled sets the administrative state of a logical switch port. +func (o *NB) UpdateLogicalSwitchPortEnabled(ctx context.Context, portName OVNSwitchPort, enabled bool) error { + // Get the logical switch port. + lsp := ovnNB.LogicalSwitchPort{ + Name: string(portName), + } + + err := o.get(ctx, &lsp) + if err != nil { + return err + } + + lsp.Enabled = &enabled + + // Update the record. + operations, err := o.client.Where(&lsp).Update(&lsp) + if err != nil { + return err + } + + // Apply the changes. + resp, err := o.client.Transact(ctx, operations...) + if err != nil { + return err + } + + _, err = ovsdb.CheckOperationResults(resp, operations) + if err != nil { + return err + } + + return nil +} + // UpdateLogicalSwitchPortDNS sets up the switch port DNS records for the DNS name. // Returns the DNS record UUID, IPv4 and IPv6 addresses used for DNS records. func (o *NB) UpdateLogicalSwitchPortDNS(ctx context.Context, switchName OVNSwitch, portName OVNSwitchPort, dnsName string, dnsIPs []net.IP) (OVNDNSUUID, error) { From 6433878d0d0ed2ba5308fae3ddad9e5a0fec92c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:04:21 -0400 Subject: [PATCH 3/9] incusd/network/ovn: Add GetLogicalSwitchActivePorts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/ovn/ovn_nb_actions.go | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/server/network/ovn/ovn_nb_actions.go b/internal/server/network/ovn/ovn_nb_actions.go index 91f52d9d08..812f7b2c61 100644 --- a/internal/server/network/ovn/ovn_nb_actions.go +++ b/internal/server/network/ovn/ovn_nb_actions.go @@ -1735,6 +1735,36 @@ func (o *NB) GetLogicalSwitchPorts(ctx context.Context, switchName OVNSwitch) (m return ports, nil } +// GetLogicalSwitchActivePorts returns a map of enabled logical switch ports (name and UUID) for a switch. +func (o *NB) GetLogicalSwitchActivePorts(ctx context.Context, switchName OVNSwitch) (map[OVNSwitchPort]OVNSwitchPortUUID, error) { + // Get the logical switch. + logicalSwitch, err := o.GetLogicalSwitch(ctx, switchName) + if err != nil { + return nil, err + } + + ports := make(map[OVNSwitchPort]OVNSwitchPortUUID, len(logicalSwitch.Ports)) + for _, portUUID := range logicalSwitch.Ports { + // Get the logical switch port. + lsp := ovnNB.LogicalSwitchPort{ + UUID: portUUID, + } + + err := o.get(ctx, &lsp) + if err != nil { + return nil, err + } + + if lsp.Enabled != nil && !*lsp.Enabled { + continue + } + + ports[OVNSwitchPort(lsp.Name)] = OVNSwitchPortUUID(lsp.UUID) + } + + return ports, nil +} + // GetLogicalSwitchIPs returns a list of IPs associated to each port connected to switch. func (o *NB) GetLogicalSwitchIPs(ctx context.Context, switchName OVNSwitch) (map[OVNSwitchPort][]net.IP, error) { lsps := []ovnNB.LogicalSwitchPort{} From a3df3dafed8d807d977388a7038b2628e732753e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:04:35 -0400 Subject: [PATCH 4/9] incusd/network/ovn: Make SetLogicalSwitchQoSRules replace existing rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/driver_ovn.go | 2 +- internal/server/network/ovn/ovn_nb_actions.go | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/server/network/driver_ovn.go b/internal/server/network/driver_ovn.go index 2386b170ed..563a6259d6 100644 --- a/internal/server/network/driver_ovn.go +++ b/internal/server/network/driver_ovn.go @@ -5421,7 +5421,7 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL rules = append(rules, ingressRule) } - err = n.ovnnb.AddLogicalSwitchQoSRules(context.TODO(), n.getIntSwitchName(), instancePortName, rules...) + err = n.ovnnb.SetLogicalSwitchQoSRules(context.TODO(), n.getIntSwitchName(), instancePortName, rules...) if err != nil { return "", nil, err } diff --git a/internal/server/network/ovn/ovn_nb_actions.go b/internal/server/network/ovn/ovn_nb_actions.go index 812f7b2c61..af8a515909 100644 --- a/internal/server/network/ovn/ovn_nb_actions.go +++ b/internal/server/network/ovn/ovn_nb_actions.go @@ -3056,10 +3056,23 @@ func (o *NB) UpdatePortGroupACLRules(ctx context.Context, portGroupName OVNPortG return nil } -// AddLogicalSwitchQoSRules applies a set of rules to the specified logical switch port. -func (o *NB) AddLogicalSwitchQoSRules(ctx context.Context, switchName OVNSwitch, switchPortName OVNSwitchPort, qosRules ...OVNQoSRule) error { +// SetLogicalSwitchQoSRules replaces the set of QoS rules assigned to the specified logical switch port. +func (o *NB) SetLogicalSwitchQoSRules(ctx context.Context, switchName OVNSwitch, switchPortName OVNSwitchPort, qosRules ...OVNQoSRule) error { var operations []ovsdb.Operation + // Remove any existing rules for the port. + removeQoSRuleUUIDs, err := o.logicalSwitchPortQoSRules(ctx, switchPortName) + if err != nil { + return err + } + + deleteOps, err := o.qosRuleDeleteOperations(ctx, "logical_switch", string(switchName), removeQoSRuleUUIDs) + if err != nil { + return err + } + + operations = append(operations, deleteOps...) + // Add new rules. externalIDs := map[string]string{ ovnExtIDIncusSwitch: string(switchName), From 7208e535a4a016b4097c951934a37d15f3164fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:05:41 -0400 Subject: [PATCH 5/9] incusd/network/ovn: Extract instanceDevicePortOpts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/driver_ovn.go | 137 +++++++++++++++----------- 1 file changed, 78 insertions(+), 59 deletions(-) diff --git a/internal/server/network/driver_ovn.go b/internal/server/network/driver_ovn.go index 563a6259d6..e6546e1170 100644 --- a/internal/server/network/driver_ovn.go +++ b/internal/server/network/driver_ovn.go @@ -4637,6 +4637,75 @@ func (n *ovn) InstanceDevicePortValidateExternalRoutes(deviceInstance instance.I return nil } +// instanceDevicePortOpts derives the logical switch port options for an instance NIC device. +func (n *ovn) instanceDevicePortOpts(instanceUUID string, deviceName string, devConfig deviceConfig.Device, ipv4 string, ipv6 string, enabled bool, location string) (*networkOVN.OVNSwitchPortOpts, error) { + mac, err := net.ParseMAC(devConfig["hwaddr"]) + if err != nil { + return nil, err + } + + dhcpv4Subnet := n.DHCPv4Subnet() + dhcpv6Subnet := n.DHCPv6Subnet() + var dhcpV4UUID, dhcpV6UUID networkOVN.OVNDHCPOptionsUUID + + if dhcpv4Subnet != nil || dhcpv6Subnet != nil { + dhcpV4UUID, dhcpV6UUID, err = n.getDhcpOptionUUIDs() + if err != nil { + return nil, err + } + } + + if dhcpv4Subnet != nil && dhcpV4UUID == "" { + return nil, fmt.Errorf("Could not find DHCPv4 options for instance port for subnet %q", dhcpv4Subnet.String()) + } + + if dhcpv6Subnet != nil { + if dhcpV6UUID == "" { + return nil, fmt.Errorf("Could not find DHCPv6 options for instance port for subnet %q", dhcpv6Subnet.String()) + } + + // If port isn't going to have fully dynamic IPs allocated by OVN, and instead only static + // IPv4 addresses have been added, then add an EUI64 static IPv6 address so that the switch + // port has an IPv6 address that will be used to generate a DNS record. This works around a + // limitation in OVN that prevents us requesting dynamic IPv6 address allocation when + // static IPv4 allocation is used. + if ipv4 != "" && ipv6 == "" { + eui64IP, err := eui64.ParseMAC(dhcpv6Subnet.IP, mac) + if err != nil { + return nil, fmt.Errorf("Failed generating EUI64 for instance port %q: %w", mac.String(), err) + } + + // Add EUI64 as the IPv6 address. + ipv6 = eui64IP.String() + } + } + + var nestedPortParentName networkOVN.OVNSwitchPort + var nestedPortVLAN uint16 + if devConfig["nested"] != "" { + nestedPortParentName = n.getInstanceDevicePortName(instanceUUID, devConfig["nested"]) + nestedPortVLANInt64, err := strconv.ParseUint(devConfig["vlan"], 10, 16) + if err != nil { + return nil, fmt.Errorf("Invalid VLAN ID %q: %w", devConfig["vlan"], err) + } + + nestedPortVLAN = uint16(nestedPortVLANInt64) + } + + return &networkOVN.OVNSwitchPortOpts{ + DHCPv4OptsID: dhcpV4UUID, + DHCPv6OptsID: dhcpV6UUID, + MAC: mac, + IPV4: ipv4, + IPV6: ipv6, + Parent: nestedPortParentName, + VLAN: nestedPortVLAN, + Location: location, + Promiscuous: util.IsTrue(devConfig["security.promiscuous"]), + Enabled: &enabled, + }, nil +} + // InstanceDevicePortAdd adds empty DNS record (to indicate port has been added) and any DHCP reservations for // instance device port. func (n *ovn) InstanceDevicePortAdd(instanceUUID string, deviceName string, devConfig deviceConfig.Device) error { @@ -4723,11 +4792,6 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL return "", nil, errors.New("Instance UUID is required") } - mac, err := net.ParseMAC(opts.DeviceConfig["hwaddr"]) - if err != nil { - return "", nil, err - } - ipv4 := opts.DeviceConfig["ipv4.address"] ipv6 := opts.DeviceConfig["ipv6.address"] @@ -4736,6 +4800,8 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL return "", nil, fmt.Errorf("Failed parsing NIC device routes: %w", err) } + instancePortName := n.getInstanceDevicePortName(opts.InstanceUUID, opts.DeviceName) + reverter := revert.New() defer reverter.Fail() @@ -4749,19 +4815,8 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL dhcpv4Subnet := n.DHCPv4Subnet() dhcpv6Subnet := n.DHCPv6Subnet() - var dhcpV4UUID, dhcpV6UUID networkOVN.OVNDHCPOptionsUUID - if dhcpv4Subnet != nil || dhcpv6Subnet != nil { - dhcpV4UUID, dhcpV6UUID, err = n.getDhcpOptionUUIDs() - if err != nil { - return "", nil, err - } - } if dhcpv4Subnet != nil { - if dhcpV4UUID == "" { - return "", nil, fmt.Errorf("Could not find DHCPv4 options for instance port for subnet %q", dhcpv4Subnet.String()) - } - // If using dynamic IPv4, look for previously used sticky IPs from the NIC's last state. var dhcpV4StickyIP net.IP if opts.DeviceConfig["ipv4.address"] == "" { @@ -4800,56 +4855,20 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL } } - if dhcpv6Subnet != nil { - if dhcpV6UUID == "" { - return "", nil, fmt.Errorf("Could not find DHCPv6 options for instance port for subnet %q", dhcpv6Subnet.String()) - } - - // If port isn't going to have fully dynamic IPs allocated by OVN, and instead only static - // IPv4 addresses have been added, then add an EUI64 static IPv6 address so that the switch - // port has an IPv6 address that will be used to generate a DNS record. This works around a - // limitation in OVN that prevents us requesting dynamic IPv6 address allocation when - // static IPv4 allocation is used. - if ipv4 != "" && ipv6 == "" { - eui64IP, err := eui64.ParseMAC(dhcpv6Subnet.IP, mac) - if err != nil { - return "", nil, fmt.Errorf("Failed generating EUI64 for instance port %q: %w", mac.String(), err) - } - - // Add EUI64 as the IPv6 address. - ipv6 = eui64IP.String() - } + portOpts, err := n.instanceDevicePortOpts(opts.InstanceUUID, opts.DeviceName, opts.DeviceConfig, ipv4, ipv6, true, n.state.ServerName) + if err != nil { + return "", nil, err } - instancePortName := n.getInstanceDevicePortName(opts.InstanceUUID, opts.DeviceName) - - var nestedPortParentName networkOVN.OVNSwitchPort - var nestedPortVLAN uint16 - if opts.DeviceConfig["nested"] != "" { - nestedPortParentName = n.getInstanceDevicePortName(opts.InstanceUUID, opts.DeviceConfig["nested"]) - nestedPortVLANInt64, err := strconv.ParseUint(opts.DeviceConfig["vlan"], 10, 16) - if err != nil { - return "", nil, fmt.Errorf("Invalid VLAN ID %q: %w", opts.DeviceConfig["vlan"], err) - } - - nestedPortVLAN = uint16(nestedPortVLANInt64) - } + // Pick up any address override from the port options (such as the EUI64 IPv6 address). + ipv4 = portOpts.IPV4 + ipv6 = portOpts.IPV6 // Add port with mayExist set to true, so that if instance port exists, we don't fail and continue below // to configure the port as needed. This is required in case the OVN northbound database was unavailable // when the instance NIC was stopped and was unable to remove the port on last stop, which would otherwise // prevent future NIC starts. - err = n.ovnnb.CreateLogicalSwitchPort(context.TODO(), n.getIntSwitchName(), instancePortName, &networkOVN.OVNSwitchPortOpts{ - DHCPv4OptsID: dhcpV4UUID, - DHCPv6OptsID: dhcpV6UUID, - MAC: mac, - IPV4: ipv4, - IPV6: ipv6, - Parent: nestedPortParentName, - VLAN: nestedPortVLAN, - Location: n.state.ServerName, - Promiscuous: util.IsTrue(opts.DeviceConfig["security.promiscuous"]), - }, true) + err = n.ovnnb.CreateLogicalSwitchPort(context.TODO(), n.getIntSwitchName(), instancePortName, portOpts, true) if err != nil { return "", nil, err } From 006d0cbc6efc1878d6132ed3e8aad86ab239afcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:06:03 -0400 Subject: [PATCH 6/9] incusd/network/ovn: Only consider enabled switch ports as active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/driver_ovn.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/server/network/driver_ovn.go b/internal/server/network/driver_ovn.go index e6546e1170..c1c122759d 100644 --- a/internal/server/network/driver_ovn.go +++ b/internal/server/network/driver_ovn.go @@ -4224,7 +4224,7 @@ func (n *ovn) Update(newNetwork api.NetworkPut, targetNode string, clientType re removeChangeSet := map[networkOVN.OVNPortGroup][]networkOVN.OVNSwitchPortUUID{} // Get list of active switch ports (avoids repeated querying of OVN NB). - activePorts, err := n.ovnnb.GetLogicalSwitchPorts(context.TODO(), n.getIntSwitchName()) + activePorts, err := n.ovnnb.GetLogicalSwitchActivePorts(context.TODO(), n.getIntSwitchName()) if err != nil { return fmt.Errorf("Failed getting active ports: %w", err) } @@ -5982,7 +5982,7 @@ func (n *ovn) handleDependencyChange(uplinkName string, uplinkConfig map[string] if slices.Contains([]string{"l2proxy", ""}, uplinkConfig["ovn.ingress_mode"]) { // Get list of active switch ports (avoids repeated querying of OVN NB). - activePorts, err := n.ovnnb.GetLogicalSwitchPorts(context.TODO(), n.getIntSwitchName()) + activePorts, err := n.ovnnb.GetLogicalSwitchActivePorts(context.TODO(), n.getIntSwitchName()) if err != nil { return fmt.Errorf("Failed getting active ports: %w", err) } @@ -7292,7 +7292,7 @@ func (n *ovn) localPeerCreate(peer api.NetworkPeersPost) error { return fmt.Errorf("Failed applying local router security policy: %w", err) } - activeLocalNICPorts, err := n.ovnnb.GetLogicalSwitchPorts(context.TODO(), n.getIntSwitchName()) + activeLocalNICPorts, err := n.ovnnb.GetLogicalSwitchActivePorts(context.TODO(), n.getIntSwitchName()) if err != nil { return fmt.Errorf("Failed getting active NIC ports: %w", err) } @@ -7828,7 +7828,7 @@ func (n *ovn) peerSetup(ovnnb *networkOVN.NB, targetOVNNet *ovn, opts networkOVN } // Get list of active switch ports (avoids repeated querying of OVN NB). - activeTargetNICPorts, err := n.ovnnb.GetLogicalSwitchPorts(context.TODO(), targetOVNNet.getIntSwitchName()) + activeTargetNICPorts, err := n.ovnnb.GetLogicalSwitchActivePorts(context.TODO(), targetOVNNet.getIntSwitchName()) if err != nil { return fmt.Errorf("Failed getting active NIC ports: %w", err) } From 0c166fb875c3a4de3dbad5b0776344839703c1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:06:34 -0400 Subject: [PATCH 7/9] incusd/network/ovn: Tweak instance port startup logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This handles upgrading from the current setup where the port will be missing on a clean start as well as make Incus tolerant to missing/deleted switch ports in the future. Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/driver_ovn.go | 39 ++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/internal/server/network/driver_ovn.go b/internal/server/network/driver_ovn.go index c1c122759d..d7bee5dacf 100644 --- a/internal/server/network/driver_ovn.go +++ b/internal/server/network/driver_ovn.go @@ -4784,7 +4784,7 @@ func (n *ovn) forwardHasDefaultTarget(listenAddress string) (bool, error) { return hasDefaultTarget, nil } -// InstanceDevicePortStart sets up an instance device port to the internal logical switch. +// InstanceDevicePortStart sets up and enables an instance device port on the internal logical switch. // Accepts a list of ACLs being removed from the NIC device (if called as part of a NIC update). // Returns the logical switch port name and a list of IPs that were allocated to the port for DNS. func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACLsRemove []string) (networkOVN.OVNSwitchPort, []net.IP, error) { @@ -4805,6 +4805,14 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL reverter := revert.New() defer reverter.Fail() + // Check if the persistent port already exists (may be missing after an upgrade or database restore). + existingPortUUID, err := n.ovnnb.GetLogicalSwitchPortUUID(context.TODO(), instancePortName) + if err != nil && !errors.Is(err, networkOVN.ErrNotFound) { + return "", nil, err + } + + portExists := existingPortUUID != "" + // Get existing DHCPv4 static reservations. // This is used for both checking sticky DHCPv4 allocation availability and for ensuring static DHCP // reservations exist. @@ -4816,7 +4824,8 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL dhcpv4Subnet := n.DHCPv4Subnet() dhcpv6Subnet := n.DHCPv6Subnet() - if dhcpv4Subnet != nil { + // Sticky IPs are only needed when re-creating the port as an existing port keeps its allocation. + if dhcpv4Subnet != nil && !portExists { // If using dynamic IPv4, look for previously used sticky IPs from the NIC's last state. var dhcpV4StickyIP net.IP if opts.DeviceConfig["ipv4.address"] == "" { @@ -4864,17 +4873,18 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL ipv4 = portOpts.IPV4 ipv6 = portOpts.IPV6 - // Add port with mayExist set to true, so that if instance port exists, we don't fail and continue below - // to configure the port as needed. This is required in case the OVN northbound database was unavailable - // when the instance NIC was stopped and was unable to remove the port on last stop, which would otherwise - // prevent future NIC starts. + // Create the port if missing, otherwise update its configuration, and enable it. err = n.ovnnb.CreateLogicalSwitchPort(context.TODO(), n.getIntSwitchName(), instancePortName, portOpts, true) if err != nil { return "", nil, err } reverter.Add(func() { - _ = n.ovnnb.DeleteLogicalSwitchPort(context.TODO(), n.getIntSwitchName(), instancePortName) + if portExists { + _ = n.ovnnb.UpdateLogicalSwitchPortEnabled(context.TODO(), instancePortName, false) + } else { + _ = n.ovnnb.DeleteLogicalSwitchPort(context.TODO(), n.getIntSwitchName(), instancePortName) + } }) // Add DNS records for port's IPs, and retrieve the IP addresses used. @@ -5346,6 +5356,21 @@ func (n *ovn) InstanceDevicePortStart(opts *OVNInstanceNICSetupOpts, securityACL } } + // Remove the persistent port from any port group no longer requested above. + currentPortGroups, err := n.ovnnb.GetPortGroupsByPort(context.TODO(), portUUID) + if err != nil { + return "", nil, fmt.Errorf("Failed getting port groups for instance NIC port: %w", err) + } + + for _, portGroup := range currentPortGroups { + _, foundAdd := addChangeSet[portGroup] + _, foundRemove := removeChangeSet[portGroup] + if !foundAdd && !foundRemove { + acl.OVNPortGroupInstanceNICSchedule(portUUID, removeChangeSet, portGroup) + n.logger.Debug("Scheduled logical port for port group removal", logger.Ctx{"portGroup": portGroup, "port": instancePortName}) + } + } + // Add instance NIC switch port to port groups required. Always run this as the addChangeSet should always // be populated even if no ACLs being applied, because the NIC port needs to be added to the network level // port group. From 3f330feb61aa6d49e072c9307c90525278652850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:07:09 -0400 Subject: [PATCH 8/9] incusd/network/ovn: Keep instance ports until device removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/driver_ovn.go | 78 +++++++++++++-------------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/internal/server/network/driver_ovn.go b/internal/server/network/driver_ovn.go index d7bee5dacf..eec4adaed4 100644 --- a/internal/server/network/driver_ovn.go +++ b/internal/server/network/driver_ovn.go @@ -5511,7 +5511,7 @@ func (n *ovn) InstanceDevicePortIPs(instanceUUID string, deviceName string) ([]n return devIPs, nil } -// InstanceDevicePortStop deletes an instance device port from the internal logical switch. +// InstanceDevicePortStop disables an instance device port and removes its routes and NAT rules. func (n *ovn) InstanceDevicePortStop(ovsExternalOVNPort networkOVN.OVNSwitchPort, opts *OVNInstanceNICStopOpts) error { // Decide whether to use OVS provided OVN port name or internally derived OVN port name. instancePortName := ovsExternalOVNPort @@ -5530,12 +5530,12 @@ func (n *ovn) InstanceDevicePortStop(ovsExternalOVNPort networkOVN.OVNSwitchPort return fmt.Errorf("Failed getting instance switch port options: %w", err) } - // Don't delete logical switch port if already active on another chassis (i.e during live cluster move). + // Don't disable logical switch port if already active on another chassis (i.e during live cluster move). if portLocation != "" && portLocation != n.state.ServerName { return nil } - n.logger.Debug("Deleting instance port", logger.Ctx{"port": instancePortName, "source": source}) + n.logger.Debug("Disabling instance port", logger.Ctx{"port": instancePortName, "source": source}) internalRoutes, externalRoutes, err := n.instanceDevicePortRoutesParse(opts.DeviceConfig) if err != nil { @@ -5557,38 +5557,14 @@ func (n *ovn) InstanceDevicePortStop(ovsExternalOVNPort networkOVN.OVNSwitchPort } // Get DNS records. - dnsUUID, _, dnsIPs, err := n.ovnnb.GetLogicalSwitchPortDNS(context.TODO(), instancePortName) + _, _, dnsIPs, err := n.ovnnb.GetLogicalSwitchPortDNS(context.TODO(), instancePortName) if err != nil { return err } - portUUID, err := n.ovnnb.GetLogicalSwitchPortUUID(context.TODO(), instancePortName) - if err != nil { - return fmt.Errorf("Failed getting logical port UUID for port group removal: %w", err) - } - - if portUUID != "" { - portGroups, err := n.ovnnb.GetPortGroupsByPort(context.TODO(), portUUID) - if err != nil { - return fmt.Errorf("Failed getting port groups for instance NIC port: %w", err) - } - - if len(portGroups) > 0 { - removeChangeSet := map[networkOVN.OVNPortGroup][]networkOVN.OVNSwitchPortUUID{} - for _, pg := range portGroups { - acl.OVNPortGroupInstanceNICSchedule(portUUID, removeChangeSet, pg) - } - - err = n.ovnnb.UpdatePortGroupMembers(context.TODO(), map[networkOVN.OVNPortGroup][]networkOVN.OVNSwitchPortUUID{}, removeChangeSet) - if err != nil { - return fmt.Errorf("Failed removing instance NIC port from port groups: %w", err) - } - } - } - - // Cleanup logical switch port and associated config. - err = n.ovnnb.CleanupLogicalSwitchPort(context.TODO(), instancePortName, n.getIntSwitchName(), acl.OVNIntSwitchPortGroupName(n.ID()), dnsUUID) - if err != nil { + // Disable the logical switch port. + err = n.ovnnb.UpdateLogicalSwitchPortEnabled(context.TODO(), instancePortName, false) + if err != nil && !errors.Is(err, networkOVN.ErrNotFound) { return err } @@ -5691,9 +5667,7 @@ func (n *ovn) InstanceDevicePortStop(ovsExternalOVNPort networkOVN.OVNSwitchPort return nil } -// InstanceDevicePortRemove unregisters the NIC device in the OVN database by removing the DNS entry that should -// have been created during InstanceDevicePortAdd(). If the DNS record exists at remove time then this indicates -// the NIC device was successfully added and this function also clears any DHCP reservations for the NIC's IPs. +// InstanceDevicePortRemove deletes an instance device port and all its associated OVN config. func (n *ovn) InstanceDevicePortRemove(instanceUUID string, devName string, devConfig deviceConfig.Device, hasDuplicate bool) error { instancePortName := n.getInstanceDevicePortName(instanceUUID, devName) @@ -5731,17 +5705,41 @@ func (n *ovn) InstanceDevicePortRemove(instanceUUID string, devName string, devC } } - // Remove DNS record if exists. + // Remove the port from any port groups it is a member of. + portUUID, err := n.ovnnb.GetLogicalSwitchPortUUID(context.TODO(), instancePortName) + if err != nil && !errors.Is(err, networkOVN.ErrNotFound) { + return fmt.Errorf("Failed getting logical port UUID for port group removal: %w", err) + } + + if portUUID != "" { + portGroups, err := n.ovnnb.GetPortGroupsByPort(context.TODO(), portUUID) + if err != nil { + return fmt.Errorf("Failed getting port groups for instance NIC port: %w", err) + } + + if len(portGroups) > 0 { + removeChangeSet := map[networkOVN.OVNPortGroup][]networkOVN.OVNSwitchPortUUID{} + for _, pg := range portGroups { + acl.OVNPortGroupInstanceNICSchedule(portUUID, removeChangeSet, pg) + } + + err = n.ovnnb.UpdatePortGroupMembers(context.TODO(), map[networkOVN.OVNPortGroup][]networkOVN.OVNSwitchPortUUID{}, removeChangeSet) + if err != nil { + return fmt.Errorf("Failed removing instance NIC port from port groups: %w", err) + } + } + } + + // Get DNS records. dnsUUID, _, _, err := n.ovnnb.GetLogicalSwitchPortDNS(context.TODO(), instancePortName) if err != nil { return err } - if dnsUUID != "" { - err = n.ovnnb.DeleteLogicalSwitchPortDNS(context.TODO(), n.getIntSwitchName(), dnsUUID, true) - if err != nil { - return fmt.Errorf("Failed deleting DNS record: %w", err) - } + // Cleanup logical switch port and associated config. + err = n.ovnnb.CleanupLogicalSwitchPort(context.TODO(), instancePortName, n.getIntSwitchName(), acl.OVNIntSwitchPortGroupName(n.ID()), dnsUUID) + if err != nil { + return err } reverter.Success() From 34763eba166fecfb68becb5ac308e2c279f54c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Wed, 1 Jul 2026 22:08:00 -0400 Subject: [PATCH 9/9] incusd/network/ovn: Create instance ports on device add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Graber Sponsored-by: Magalu Cloud (https://magalu.cloud) --- internal/server/network/driver_ovn.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/server/network/driver_ovn.go b/internal/server/network/driver_ovn.go index eec4adaed4..01892f7c2f 100644 --- a/internal/server/network/driver_ovn.go +++ b/internal/server/network/driver_ovn.go @@ -4706,9 +4706,13 @@ func (n *ovn) instanceDevicePortOpts(instanceUUID string, deviceName string, dev }, nil } -// InstanceDevicePortAdd adds empty DNS record (to indicate port has been added) and any DHCP reservations for -// instance device port. +// InstanceDevicePortAdd creates the disabled logical switch port, empty DNS record and any DHCP +// reservations for an instance device port. func (n *ovn) InstanceDevicePortAdd(instanceUUID string, deviceName string, devConfig deviceConfig.Device) error { + if instanceUUID == "" { + return errors.New("Instance UUID is required") + } + instancePortName := n.getInstanceDevicePortName(instanceUUID, deviceName) reverter := revert.New() @@ -4742,6 +4746,21 @@ func (n *ovn) InstanceDevicePortAdd(instanceUUID string, deviceName string, devC } } + // Create the logical switch port in disabled state. + portOpts, err := n.instanceDevicePortOpts(instanceUUID, deviceName, devConfig, devConfig["ipv4.address"], devConfig["ipv6.address"], false, "") + if err != nil { + return err + } + + err = n.ovnnb.CreateLogicalSwitchPort(context.TODO(), n.getIntSwitchName(), instancePortName, portOpts, true) + if err != nil { + return err + } + + reverter.Add(func() { + _ = n.ovnnb.DeleteLogicalSwitchPort(context.TODO(), n.getIntSwitchName(), instancePortName) + }) + reverter.Success() return nil }