This is an automated email from the ASF dual-hosted git repository.

shwstppr pushed a commit to branch 4.20
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/4.20 by this push:
     new a4d3c664823 KVM: assign the hot-plugged NIC the next PCI slot above 
existing NICs (monotonic interface naming) (#12826)
a4d3c664823 is described below

commit a4d3c664823f428abc1ca402772861e937928345
Author: James Peru Mmbono <[email protected]>
AuthorDate: Fri Sep 11 12:51:00 2026 +0300

    KVM: assign the hot-plugged NIC the next PCI slot above existing NICs 
(monotonic interface naming) (#12826)
    
    * KVM: assign explicit PCI slot when hot-plugging NIC to ensure sequential 
naming
    
    When hot-plugging a NIC to a running VM, libvirt auto-assigns the next
    free PCI slot. Since non-NIC devices (virtio-serial, disk, balloon,
    watchdog) occupy slots immediately after existing NICs, the hot-plugged
    NIC gets a much higher slot number (e.g. 0x09 instead of 0x05), causing
    the guest to see non-sequential interface names (ens9 instead of ens5).
    
    This fix queries the domain XML to find all used PCI slots and assigns
    the next free slot after the highest existing NIC slot. This matches
    the approach already used by LibvirtReplugNicCommandWrapper which
    preserves PCI slots during re-plug operations.
    
    Fixes #12825
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    
    * fix(kvm): NPE in PlugNic when libvirt domain XML is unavailable
    
    LibvirtPlugNicCommandWrapper.findNextAvailablePciSlot calls
    vm.getXMLDesc(0) and pipes the result straight into Pattern.matcher,
    which NPEs if libvirt returned null (or, in the
    LibvirtComputingResourceTest.testPlugNicCommandNoMatchMack unit test,
    when the Domain mock isn't stubbed for getXMLDesc). Reported by
    @DaanHoogland after the SL packaging run on #12826.
    
    Defensive null check returns null from findNextAvailablePciSlot when
    the domain XML can't be parsed, which falls through to libvirt's
    auto-assignment of the PCI slot — same behaviour as before this PR
    when nextSlot is null.
    
    Also stubs Domain.getXMLDesc(0) in testPlugNicCommandNoMatchMack with
    a minimal <domain> XML that exercises the parser path (rather than
    just relying on the null-fallback), so the test continues to cover
    the happy path of the new logic.
    
    * address review (#12826): parse PCI addresses with an XML parser, split 
slot selection into helpers, add unit tests
    
    - getUsedPciSlots() parses the domain XML with the safer 
DocumentBuilderFactory
      and only considers <address type='pci'> elements, replacing the regex.
    - getHighestNicSlot() and getFirstFreeSlotAbove() are separate methods.
    - The javadoc now states the guarantee precisely: deterministic and 
monotonic
      after the last NIC, not contiguous when other devices sit in between.
    - LibvirtPlugNicCommandWrapperTest covers parsing, selection and the 
fallbacks.
    
    Signed-off-by: James Peru <[email protected]>
    
    ---------
    
    Signed-off-by: James Peru <[email protected]>
    Co-authored-by: James Peru <[email protected]>
    Co-authored-by: Claude Opus 4.6 <[email protected]>
    Co-authored-by: jmsperu <[email protected]>
---
 .../wrapper/LibvirtPlugNicCommandWrapper.java      | 105 +++++++++++++++
 .../kvm/resource/LibvirtComputingResourceTest.java |   9 ++
 .../wrapper/LibvirtPlugNicCommandWrapperTest.java  | 147 +++++++++++++++++++++
 3 files changed, 261 insertions(+)

diff --git 
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapper.java
 
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapper.java
index b0950376a93..acf95edeb95 100644
--- 
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapper.java
+++ 
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapper.java
@@ -30,15 +30,29 @@ import com.cloud.hypervisor.kvm.resource.VifDriver;
 import com.cloud.resource.CommandWrapper;
 import com.cloud.resource.ResourceWrapper;
 import com.cloud.vm.VirtualMachine;
+import org.apache.cloudstack.utils.security.ParserUtils;
 import org.libvirt.Connect;
 import org.libvirt.Domain;
 import org.libvirt.LibvirtException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
 
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 @ResourceWrapper(handles =  PlugNicCommand.class)
 public final class LibvirtPlugNicCommandWrapper extends 
CommandWrapper<PlugNicCommand, Answer, LibvirtComputingResource> {
 
+    /** Highest PCI slot number on a bus (0x00 is the host bridge). */
+    private static final int MAX_PCI_SLOT = 0x1f;
 
     @Override
     public Answer execute(final PlugNicCommand command, final 
LibvirtComputingResource libvirtComputingResource) {
@@ -65,6 +79,15 @@ public final class LibvirtPlugNicCommandWrapper extends 
CommandWrapper<PlugNicCo
             if (command.getDetails() != null) {
                 
libvirtComputingResource.setInterfaceDefQueueSettings(command.getDetails(), 
null, interfaceDef);
             }
+
+            // Pin the PCI slot to the lowest free one above the existing NICs 
so the guest sees a
+            // deterministic, monotonic NIC order across hot-plugs (see 
findNextAvailablePciSlot).
+            Integer nextSlot = findNextAvailablePciSlot(vm, pluggedNics);
+            if (nextSlot != null) {
+                interfaceDef.setSlot(nextSlot);
+                logger.debug("Assigning PCI slot 0x" + String.format("%02x", 
nextSlot) + " to hot-plugged NIC");
+            }
+
             vm.attachDevice(interfaceDef.toString());
 
             // apply default network rules on new nic
@@ -96,4 +119,86 @@ public final class LibvirtPlugNicCommandWrapper extends 
CommandWrapper<PlugNicCo
             }
         }
     }
+
+    /**
+     * Picks the PCI slot for the NIC being hot-plugged: the lowest free slot 
above the highest slot
+     * already used by a NIC. The choice is deterministic and monotonic with 
respect to the NICs that
+     * are already present (a new NIC never lands below an existing one), 
which is what keeps the
+     * guest's predictable interface names stable across hot-plugs. It does 
not guarantee contiguity:
+     * slots between the last NIC and the new one may already be taken by 
other devices (disks,
+     * controllers, balloon), in which case the next free slot above them is 
used.
+     *
+     * @return the slot to assign, or {@code null} to let libvirt auto-assign 
(domain XML unavailable
+     *         or unparseable, or no free slot left).
+     */
+    protected Integer findNextAvailablePciSlot(final Domain vm, final 
List<InterfaceDef> pluggedNics) {
+        try {
+            final String domXml = vm.getXMLDesc(0);
+            // getXMLDesc can return null on certain libvirt error paths; fall 
back to libvirt's own choice.
+            if (domXml == null) {
+                logger.debug("Domain XML unavailable, letting libvirt 
auto-assign PCI slot");
+                return null;
+            }
+            final Set<Integer> usedSlots = getUsedPciSlots(domXml);
+            if (usedSlots == null) {
+                return null;
+            }
+            final Integer slot = 
getFirstFreeSlotAbove(getHighestNicSlot(pluggedNics), usedSlots);
+            if (slot == null) {
+                logger.warn("No free PCI slots available, letting libvirt 
auto-assign");
+            }
+            return slot;
+        } catch (final LibvirtException e) {
+            logger.warn("Failed to get domain XML for PCI slot calculation, 
letting libvirt auto-assign", e);
+            return null;
+        }
+    }
+
+    /**
+     * Collects the slot numbers of every {@code <address type='pci' .../>} 
element in the domain XML,
+     * whichever device or bus they belong to. Returns {@code null} if the XML 
cannot be parsed.
+     */
+    protected Set<Integer> getUsedPciSlots(final String domXml) {
+        final Set<Integer> usedSlots = new HashSet<>();
+        try {
+            final DocumentBuilder builder = 
ParserUtils.getSaferDocumentBuilderFactory().newDocumentBuilder();
+            final Document doc = builder.parse(new InputSource(new 
StringReader(domXml)));
+            final NodeList addresses = doc.getElementsByTagName("address");
+            for (int i = 0; i < addresses.getLength(); i++) {
+                final Element address = (Element) addresses.item(i);
+                if (!"pci".equals(address.getAttribute("type")) || 
address.getAttribute("slot").isEmpty()) {
+                    continue;
+                }
+                usedSlots.add(Integer.decode(address.getAttribute("slot")));
+            }
+        } catch (final ParserConfigurationException | SAXException | 
IOException | NumberFormatException e) {
+            logger.warn("Failed to parse domain XML for PCI slot calculation, 
letting libvirt auto-assign", e);
+            return null;
+        }
+        return usedSlots;
+    }
+
+    /** Highest PCI slot used by an existing NIC, or 0 when no NIC carries a 
slot. */
+    protected static int getHighestNicSlot(final List<InterfaceDef> 
pluggedNics) {
+        int highest = 0;
+        for (final InterfaceDef pluggedNic : pluggedNics) {
+            if (pluggedNic.getSlot() != null && pluggedNic.getSlot() > 
highest) {
+                highest = pluggedNic.getSlot();
+            }
+        }
+        return highest;
+    }
+
+    /**
+     * Lowest slot strictly above {@code from} (and no higher than {@link 
#MAX_PCI_SLOT}) that is not in
+     * {@code usedSlots}; {@code null} when the range is exhausted. Slot 0 is 
reserved for the host bridge.
+     */
+    protected static Integer getFirstFreeSlotAbove(final int from, final 
Set<Integer> usedSlots) {
+        for (int slot = from + 1; slot <= MAX_PCI_SLOT; slot++) {
+            if (!usedSlots.contains(slot)) {
+                return slot;
+            }
+        }
+        return null;
+    }
 }
diff --git 
a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java
 
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java
index 4683a76fac9..b7d66e66542 100644
--- 
a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java
+++ 
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java
@@ -3549,6 +3549,15 @@ public class LibvirtComputingResourceTest {
             when(vifDriver.plug(nic, "Other PV", "", 
null)).thenReturn(interfaceDef);
             when(interfaceDef.toString()).thenReturn("Interface");
 
+            // Stub vm.getXMLDesc(0) so findNextAvailablePciSlot can scan the 
domain XML
+            // for in-use PCI slots. Returning a minimal <domain> with a 
single NIC at
+            // slot 0x03 exercises the production parser without forcing the 
production
+            // code into its null-fallback path.
+            when(vm.getXMLDesc(0)).thenReturn(
+                    "<domain><devices><interface type='bridge'>" +
+                    "<address type='pci' domain='0x0000' bus='0x00' 
slot='0x03' function='0x0'/>" +
+                    "</interface></devices></domain>");
+
             final String interfaceDefStr = interfaceDef.toString();
             doNothing().when(vm).attachDevice(interfaceDefStr);
 
diff --git 
a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapperTest.java
 
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapperTest.java
new file mode 100644
index 00000000000..979ffda2f27
--- /dev/null
+++ 
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapperTest.java
@@ -0,0 +1,147 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package com.cloud.hypervisor.kvm.resource.wrapper;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.libvirt.Domain;
+import org.libvirt.LibvirtException;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.cloud.hypervisor.kvm.resource.LibvirtVMDef.InterfaceDef;
+
+@RunWith(MockitoJUnitRunner.class)
+public class LibvirtPlugNicCommandWrapperTest {
+
+    /**
+     * Typical q35-less layout: NICs at 0x03 and 0x06, other PCI devices at 
0x01, 0x02, 0x04, 0x05 and 0x07,
+     * plus non-PCI addresses (drive, usb) that must be ignored.
+     */
+    private static final String DOMAIN_XML =
+            "<domain type='kvm'>\n"
+            + "  <name>i-2-42-VM</name>\n"
+            + "  <devices>\n"
+            + "    <disk type='file' device='disk'>\n"
+            + "      <target dev='vda' bus='virtio'/>\n"
+            + "      <address type='pci' domain='0x0000' bus='0x00' 
slot='0x05' function='0x0'/>\n"
+            + "    </disk>\n"
+            + "    <disk type='file' device='cdrom'>\n"
+            + "      <target dev='hdc' bus='ide'/>\n"
+            + "      <address type='drive' controller='0' bus='1' target='0' 
unit='0'/>\n"
+            + "    </disk>\n"
+            + "    <controller type='usb' index='0'>\n"
+            + "      <address type='pci' domain='0x0000' bus='0x00' 
slot='0x01' function='0x2'/>\n"
+            + "    </controller>\n"
+            + "    <controller type='virtio-serial' index='0'>\n"
+            + "      <address type='pci' domain='0x0000' bus='0x00' 
slot='0x04' function='0x0'/>\n"
+            + "    </controller>\n"
+            + "    <interface type='bridge'>\n"
+            + "      <mac address='02:00:7c:98:00:01'/>\n"
+            + "      <address type='pci' domain='0x0000' bus='0x00' 
slot='0x03' function='0x0'/>\n"
+            + "    </interface>\n"
+            + "    <interface type='bridge'>\n"
+            + "      <mac address='02:00:7c:98:00:02'/>\n"
+            + "      <address type='pci' domain='0x0000' bus='0x00' 
slot='0x06' function='0x0'/>\n"
+            + "    </interface>\n"
+            + "    <channel type='unix'>\n"
+            + "      <address type='virtio-serial' controller='0' bus='0' 
port='1'/>\n"
+            + "    </channel>\n"
+            + "    <video>\n"
+            + "      <address type='pci' domain='0x0000' bus='0x00' 
slot='0x02' function='0x0'/>\n"
+            + "    </video>\n"
+            + "    <memballoon model='virtio'>\n"
+            + "      <address type='pci' domain='0x0000' bus='0x00' 
slot='0x07' function='0x0'/>\n"
+            + "    </memballoon>\n"
+            + "  </devices>\n"
+            + "</domain>\n";
+
+    @Mock
+    private Domain domain;
+
+    private final LibvirtPlugNicCommandWrapper wrapper = new 
LibvirtPlugNicCommandWrapper();
+
+    private static InterfaceDef nicAtSlot(final Integer slot) {
+        final InterfaceDef nic = new InterfaceDef();
+        nic.setSlot(slot);
+        return nic;
+    }
+
+    private static List<InterfaceDef> nicsFromXml() {
+        return Arrays.asList(nicAtSlot(0x03), nicAtSlot(0x06));
+    }
+
+    @Test
+    public void getUsedPciSlotsOnlyCountsPciAddresses() {
+        final Set<Integer> used = wrapper.getUsedPciSlots(DOMAIN_XML);
+        assertEquals(Set.of(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07), used);
+    }
+
+    @Test
+    public void getUsedPciSlotsReturnsNullOnMalformedXml() {
+        assertNull(wrapper.getUsedPciSlots("<domain><devices><interface>"));
+    }
+
+    @Test
+    public void getHighestNicSlotIgnoresNicsWithoutAddress() {
+        assertEquals(0x06, 
LibvirtPlugNicCommandWrapper.getHighestNicSlot(Arrays.asList(nicAtSlot(0x03), 
nicAtSlot(null), nicAtSlot(0x06))));
+        assertEquals(0, 
LibvirtPlugNicCommandWrapper.getHighestNicSlot(Collections.emptyList()));
+    }
+
+    @Test
+    public void getFirstFreeSlotAboveSkipsOccupiedSlotsAndStopsAtBusEnd() {
+        assertEquals(Integer.valueOf(0x08), 
LibvirtPlugNicCommandWrapper.getFirstFreeSlotAbove(0x06, Set.of(0x07)));
+        assertEquals(Integer.valueOf(0x07), 
LibvirtPlugNicCommandWrapper.getFirstFreeSlotAbove(0x06, 
Collections.emptySet()));
+        assertNull(LibvirtPlugNicCommandWrapper.getFirstFreeSlotAbove(0x1f, 
Collections.emptySet()));
+    }
+
+    @Test
+    public void findNextAvailablePciSlotPicksLowestFreeSlotAboveLastNic() 
throws LibvirtException {
+        when(domain.getXMLDesc(0)).thenReturn(DOMAIN_XML);
+        // 0x07 is taken by the balloon, so the NIC goes to 0x08: monotonic 
after the last NIC, not contiguous.
+        assertEquals(Integer.valueOf(0x08), 
wrapper.findNextAvailablePciSlot(domain, nicsFromXml()));
+    }
+
+    @Test
+    public void findNextAvailablePciSlotFallsBackWhenXmlUnavailable() throws 
LibvirtException {
+        when(domain.getXMLDesc(0)).thenReturn(null);
+        assertNull(wrapper.findNextAvailablePciSlot(domain, nicsFromXml()));
+    }
+
+    @Test
+    public void findNextAvailablePciSlotFallsBackWhenLibvirtFails() throws 
LibvirtException {
+        
when(domain.getXMLDesc(0)).thenThrow(Mockito.mock(LibvirtException.class));
+        assertNull(wrapper.findNextAvailablePciSlot(domain, nicsFromXml()));
+    }
+
+    @Test
+    public void findNextAvailablePciSlotFallsBackWhenBusIsFull() throws 
LibvirtException {
+        when(domain.getXMLDesc(0)).thenReturn(DOMAIN_XML);
+        assertNull(wrapper.findNextAvailablePciSlot(domain, 
Collections.singletonList(nicAtSlot(0x1f))));
+    }
+}

Reply via email to