Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e45ea95
engine-schema: upgrade path for 24.0.0
shwstppr Sep 14, 2026
c2ebb3a
Support multi-VLAN trunk NICs
Pearl1594 Sep 14, 2026
219f9cb
Merge branch 'main' of github.com:apache/cloudstack into support-mult…
Pearl1594 Sep 14, 2026
eb97acf
deliver multi-VLAN trunk nics via libvirt vlan XML and bridge fallback
Pearl1594 Sep 16, 2026
d143862
Fix nic_network_map unique constraint blocking re-association
Pearl1594 Sep 28, 2026
a7b6a8e
Deliver multi-VLAN trunk NICs on KVM via libvirt vlan XML and bridge …
Pearl1594 Sep 28, 2026
bcb89ab
Bill usage for a trunk nic's associated networks
Pearl1594 Sep 28, 2026
3d27d89
Expose trunk nic and associated-network info in NicResponse
Pearl1594 Sep 28, 2026
dd4ee2a
Add ApiConstants for NicResponse trunk fields
Pearl1594 Sep 28, 2026
c16a58d
Add multi-VLAN trunk NIC association service
Pearl1594 Sep 28, 2026
88585ed
Add EventTypes constants for the association service commit
Pearl1594 Sep 28, 2026
df74bd9
Support multi-VLAN trunk NICs at VM deploy time
Pearl1594 Sep 28, 2026
3a7519d
Fix static NAT lookup in disassociate guard
Pearl1594 Sep 28, 2026
68d7771
Fix VNF management nic trunkable via live associate API
Pearl1594 Sep 28, 2026
485ad26
Fix stale nic fields after changing its primary network
Pearl1594 Sep 28, 2026
721bae1
Clear multiNetwork flag when a nic's last trunk association is removed
Pearl1594 Sep 29, 2026
1a3ddfb
Push DHCP entries to a trunk nic's associated networks
Pearl1594 Sep 29, 2026
5088e0b
Add KVM live migration support for multi-VLAN trunk NICs (Phase 7)
Pearl1594 Oct 2, 2026
01729b9
Merge branch 'main' of github.com:apache/cloudstack into support-mult…
Pearl1594 Oct 2, 2026
c9b98ea
fix test
Pearl1594 Oct 2, 2026
5f95946
Fix CI build failures from the multi-VLAN trunk NIC changes
Pearl1594 Oct 2, 2026
b4c18d6
Fix NullPointerException in LibvirtComputingResourceTest.testMigrateC…
Pearl1594 Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Support multi-VLAN trunk NICs at VM deploy time
- New nicnetworkslist deploy param (nicnetworkslist[N].networkids,
  first id primary) lets a VM be deployed with trunk nics directly,
  mutually exclusive with networkids/iptonetworklist/vApp nicnetworklist
- Also carries per-entry ip4address/ip6address for the primary and
  ip4addresses/ip6addresses (comma-separated) for its associated
  networks - a network with no requested IP auto-allocates
- VNF: rejects a management-device nic from being requested as a
  trunk, both at deploy time and via the live associate API
  • Loading branch information
Pearl1594 committed Sep 28, 2026
commit df74bd94d22f74ec91d4810a8a1af491b431d322
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ public class ApiConstants {
public static final String NIC = "nic";
public static final String NICS = "nics";
public static final String NIC_NETWORK_LIST = "nicnetworklist";
public static final String NIC_NETWORKS_LIST = "nicnetworkslist";
public static final String NIC_IP_ADDRESS_LIST = "nicipaddresslist";
public static final String NIC_MULTIQUEUE_NUMBER = "nicmultiqueuenumber";
public static final String NIC_PACKED_VIRTQUEUES_ENABLED = "nicpackedvirtqueuesenabled";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
Expand Down Expand Up @@ -190,6 +193,15 @@ public abstract class BaseDeployVMCmd extends BaseAsyncCreateCustomIdCmd impleme
+ " Example: iptonetworklist[0].ip=10.10.10.11&iptonetworklist[0].ipv6=fc00:1234:5678::abcd&iptonetworklist[0].networkid=uuid&iptonetworklist[0].mac=aa:bb:cc:dd:ee::ff - requests to use ip 10.10.10.11 in network id=uuid")
private Map ipToNetworkList;

@Parameter(name = ApiConstants.NIC_NETWORKS_LIST, type = CommandType.MAP, since = "24.0.0",
authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin},
description = "one entry per nic, each naming the set of networks that nic is associated with as a multi-VLAN trunk. The first network id in each entry is the nic's primary network."
+ " Can't be specified with networkIds or ipToNetworkList parameters."
+ " Optional ip4addresses/ip6addresses request specific IPs for the entry's associated (non-primary) networks - comma-separated,"
+ " positionally aligned to networkids after its first (primary) entry; a blank token auto-allocates that network's IP, same as omitting it."
+ " Example: nicnetworkslist[0].networkids=uuid1,uuid2,uuid3&nicnetworkslist[0].ip4address=10.10.10.11&nicnetworkslist[0].ip4addresses=10.10.20.5,10.10.30.5")
private Map nicNetworksList;

@Parameter(name = ApiConstants.IP_ADDRESS, type = CommandType.STRING, description = "the ip address for default vm's network")
private String ipAddress;

Expand Down Expand Up @@ -510,6 +522,18 @@ public VMLeaseManager.ExpiryAction getLeaseExpiryAction() {
}

public List<Long> getNetworkIds() {
if (MapUtils.isNotEmpty(nicNetworksList)) {
if (CollectionUtils.isNotEmpty(networkIds) || ipAddress != null || getIp6Address() != null
|| MapUtils.isNotEmpty(ipToNetworkList) || MapUtils.isNotEmpty(vAppNetworks)) {
throw new InvalidParameterValueException(String.format("%s can't be specified along with %s, %s, %s, %s",
ApiConstants.NIC_NETWORKS_LIST, ApiConstants.NETWORK_IDS, ApiConstants.IP_ADDRESS, ApiConstants.IP_NETWORK_LIST, ApiConstants.NIC_NETWORK_LIST));
}
List<Long> networks = new ArrayList<>();
for (NicNetworkGrouping grouping : getNicNetworksList()) {
networks.add(grouping.getPrimaryNetworkId());
}
return networks;
}
if (MapUtils.isNotEmpty(vAppNetworks)) {
if (CollectionUtils.isNotEmpty(networkIds) || ipAddress != null || getIp6Address() != null || MapUtils.isNotEmpty(ipToNetworkList)) {
throw new InvalidParameterValueException(String.format("%s can't be specified along with %s, %s, %s", ApiConstants.NIC_NETWORK_LIST, ApiConstants.NETWORK_IDS, ApiConstants.IP_ADDRESS, ApiConstants.IP_NETWORK_LIST));
Expand Down Expand Up @@ -624,6 +648,20 @@ public boolean getStartVm() {
}

public Map<Long, IpAddresses> getIpToNetworkMap() {
if (MapUtils.isNotEmpty(nicNetworksList)) {
LinkedHashMap<Long, IpAddresses> ipToNetworkMap = new LinkedHashMap<>();
for (NicNetworkGrouping grouping : getNicNetworksList()) {
if (grouping.getIp4Address() == null && grouping.getIp6Address() == null && grouping.getMacAddress() == null) {
continue;
}
HashMap<String, String> ips = new HashMap<>();
ips.put("ip", grouping.getIp4Address());
ips.put("ipv6", grouping.getIp6Address());
ips.put("mac", grouping.getMacAddress());
ipToNetworkMap.put(grouping.getPrimaryNetworkId(), getIpAddressesFromIpMap(ips));
}
return ipToNetworkMap.isEmpty() ? null : ipToNetworkMap;
}
if ((networkIds != null || ipAddress != null || getIp6Address() != null) && ipToNetworkList != null) {
throw new InvalidParameterValueException("NetworkIds and ipAddress can't be specified along with ipToNetworkMap parameter");
}
Expand All @@ -643,6 +681,125 @@ public Map<Long, IpAddresses> getIpToNetworkMap() {
return ipToNetworkMap;
}

/**
* Parses nicnetworkslist into one grouping per requested nic, each naming the full ordered set of networks
* (primary first) that nic is associated with. Legacy networkIds/ipToNetworkList/vAppNetworks are untouched
* when nicnetworkslist is absent - this only returns a non-empty list when it was actually supplied.
* <p>
* Entries are read in ascending order of their nicnetworkslist[N] index, not map iteration order - the index
* is what both the deploy-time nic creation order (device id) and VNF's per-device-id validation rely on, and
* the underlying request map is not guaranteed to preserve insertion order.
*/
public List<NicNetworkGrouping> getNicNetworksList() {
List<NicNetworkGrouping> groupings = new ArrayList<>();
if (MapUtils.isEmpty(nicNetworksList)) {
return groupings;
}
// the request-binding framework hands back these keys as Integer, not String - sort on the parsed value
// directly rather than assuming a type, so this works regardless of which one it actually is
List<Object> indices = new ArrayList<>(nicNetworksList.keySet());
try {
indices.sort(Comparator.comparingInt(index -> Integer.parseInt(index.toString())));
} catch (NumberFormatException e) {
throw new InvalidParameterValueException(String.format("%s indices must be integers", ApiConstants.NIC_NETWORKS_LIST));
}
for (Object index : indices) {
HashMap<String, String> entry = (HashMap<String, String>) nicNetworksList.get(index);
String networkIdsCsv = entry.get("networkids");
if (StringUtils.isBlank(networkIdsCsv)) {
throw new InvalidParameterValueException(String.format("%s entries must specify networkids", ApiConstants.NIC_NETWORKS_LIST));
}
List<Long> resolvedNetworkIds = new ArrayList<>();
for (String token : networkIdsCsv.split(",")) {
HashMap<String, String> networkIdMap = new HashMap<>();
networkIdMap.put("networkid", token.trim());
resolvedNetworkIds.add(getNetworkIdFomIpMap(networkIdMap));
}
if (resolvedNetworkIds.size() != new HashSet<>(resolvedNetworkIds).size()) {
throw new InvalidParameterValueException(String.format("%s entry lists the same network more than once: %s", ApiConstants.NIC_NETWORKS_LIST, networkIdsCsv));
}
Map<Long, IpAddresses> associatedNetworkIps = parseAssociatedNetworkIps(resolvedNetworkIds, entry.get("ip4addresses"), entry.get("ip6addresses"));
groupings.add(new NicNetworkGrouping(resolvedNetworkIds, entry.get("ip4address"), entry.get("ip6address"), entry.get("macaddress"), associatedNetworkIps));
}
return groupings;
}

/**
* Parses a nicnetworkslist[N] entry's optional ip4addresses/ip6addresses - comma-separated, positionally
* aligned to that entry's networkids starting from its 2nd id (the associated networks; the primary's own
* ip4address/ip6address is handled separately). A blank token, or a shorter list than the associated-network
* count, leaves that network to auto-allocate - same as not requesting an IP for it at all.
*/
private Map<Long, IpAddresses> parseAssociatedNetworkIps(List<Long> networkIds, String ip4AddressesCsv, String ip6AddressesCsv) {
if (StringUtils.isBlank(ip4AddressesCsv) && StringUtils.isBlank(ip6AddressesCsv)) {
return Collections.emptyMap();
}
List<Long> associatedNetworkIds = networkIds.subList(1, networkIds.size());
String[] ip4Tokens = ip4AddressesCsv != null ? ip4AddressesCsv.split(",", -1) : new String[0];
String[] ip6Tokens = ip6AddressesCsv != null ? ip6AddressesCsv.split(",", -1) : new String[0];
if (ip4Tokens.length > associatedNetworkIds.size() || ip6Tokens.length > associatedNetworkIds.size()) {
throw new InvalidParameterValueException(String.format(
"%s entry's ip4addresses/ip6addresses can't list more entries than its associated networks (%d)",
ApiConstants.NIC_NETWORKS_LIST, associatedNetworkIds.size()));
}
Map<Long, IpAddresses> associatedNetworkIps = new HashMap<>();
for (int i = 0; i < associatedNetworkIds.size(); i++) {
String ip4 = i < ip4Tokens.length ? StringUtils.trimToNull(ip4Tokens[i]) : null;
String ip6 = i < ip6Tokens.length ? StringUtils.trimToNull(ip6Tokens[i]) : null;
if (ip4 != null || ip6 != null) {
associatedNetworkIps.put(associatedNetworkIds.get(i), new IpAddresses(ip4, ip6));
}
}
return associatedNetworkIps;
}

/**
* One requested nic's full network association set, primary network first, from a nicnetworkslist entry.
*/
public static class NicNetworkGrouping {
private final List<Long> networkIds;
private final String ip4Address;
private final String ip6Address;
private final String macAddress;
private final Map<Long, IpAddresses> associatedNetworkIps;

public NicNetworkGrouping(List<Long> networkIds, String ip4Address, String ip6Address, String macAddress, Map<Long, IpAddresses> associatedNetworkIps) {
this.networkIds = networkIds;
this.ip4Address = ip4Address;
this.ip6Address = ip6Address;
this.macAddress = macAddress;
this.associatedNetworkIps = associatedNetworkIps;
}

public Long getPrimaryNetworkId() {
return networkIds.get(0);
}

public List<Long> getAssociatedNetworkIds() {
return networkIds.subList(1, networkIds.size());
}

public String getIp4Address() {
return ip4Address;
}

public String getIp6Address() {
return ip6Address;
}

public String getMacAddress() {
return macAddress;
}

/**
* Requested ip4/ip6 addresses for this entry's associated (non-primary) networks, keyed by network id.
* A network with no entry here auto-allocates, same as today.
*/
public Map<Long, IpAddresses> getAssociatedNetworkIps() {
return associatedNetworkIps;
}
}

@Nonnull
private IpAddresses getIpAddressesFromIpMap(HashMap<String, String> ips) {
String requestedIp = ips.get("ip");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.cloud.uservm.UserVm;
import org.apache.cloudstack.api.command.user.template.RegisterVnfTemplateCmd;
import org.apache.cloudstack.api.command.user.template.UpdateVnfTemplateCmd;
import org.apache.cloudstack.api.command.user.vm.BaseDeployVMCmd;
import org.apache.cloudstack.api.command.user.vm.DeployVnfApplianceCmd;
import org.apache.cloudstack.framework.config.ConfigKey;
import java.util.List;
Expand All @@ -45,6 +46,13 @@ public interface VnfTemplateManager {

void validateVnfApplianceNics(VirtualMachineTemplate template, List<Long> networkIds, Map<Integer, Long> vmNetworkMap);

/**
* Rejects a nicnetworkslist request that would make a VNF template's management nic a multi-VLAN trunk nic -
* management access must stay single-segment. No-op when nicNetworksList is empty (i.e. the legacy
* networkids/deploy-as-is paths, which cannot address a nic as a trunk at all).
*/
void validateVnfApplianceTrunkNics(VirtualMachineTemplate template, List<BaseDeployVMCmd.NicNetworkGrouping> nicNetworksList);

SecurityGroup createSecurityGroupForVnfAppliance(DataCenter zone, VirtualMachineTemplate template, Account owner, DeployVnfApplianceCmd cmd);

void createIsolatedNetworkRulesForVnfAppliance(DataCenter zone, VirtualMachineTemplate template, Account owner,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,99 @@ public void testGetNetworkIdsIpToNetworkListAndNetworkIds() {
assertTrue(thrownException.getMessage().contains("ipToNetworkMap can't be specified along with networkIds or ipAddress"));
}

@Test
public void testGetNetworkIdsNicNetworksList() {
// the request-binding framework hands back these keys as Integer, not String
Map<Object, Object> nicNetworksList = new HashMap<>();
Map<String, String> entry0 = new HashMap<>();
entry0.put("networkids", "1,2,3");
Map<String, String> entry1 = new HashMap<>();
entry1.put("networkids", "4");
nicNetworksList.put(1, entry1);
nicNetworksList.put(0, entry0);
ReflectionTestUtils.setField(cmd, "nicNetworksList", nicNetworksList);
ReflectionTestUtils.setField(cmd, "networkIds", null);
ReflectionTestUtils.setField(cmd, "ipToNetworkList", null);
ReflectionTestUtils.setField(cmd, "vAppNetworks", null);
ReflectionTestUtils.setField(cmd, "ipAddress", null);
ReflectionTestUtils.setField(cmd, "ip6Address", null);
ReflectionTestUtils.setField(cmd, "_networkService", mock(NetworkService.class));

List<Long> result = cmd.getNetworkIds();

// primaries only, in ascending index order (0 then 1) despite being inserted out of order
assertEquals(Arrays.asList(1L, 4L), result);
}

@Test
public void testGetNicNetworksListResolvesPrimaryAndAssociatedNetworkIds() {
Map<Object, Object> nicNetworksList = new HashMap<>();
Map<String, String> entry0 = new HashMap<>();
entry0.put("networkids", "1,2,3");
nicNetworksList.put(0, entry0);
ReflectionTestUtils.setField(cmd, "nicNetworksList", nicNetworksList);
ReflectionTestUtils.setField(cmd, "_networkService", mock(NetworkService.class));

List<BaseDeployVMCmd.NicNetworkGrouping> result = cmd.getNicNetworksList();

assertEquals(1, result.size());
assertEquals(Long.valueOf(1L), result.get(0).getPrimaryNetworkId());
assertEquals(Arrays.asList(2L, 3L), result.get(0).getAssociatedNetworkIds());
}

@Test
public void testGetNicNetworksListParsesAssociatedNetworkIps() {
// ip4addresses/ip6addresses are positionally aligned to networkids after its first (primary) entry -
// here network 2 gets an explicit ip4/ip6, network 3 is left blank (auto-allocates)
Map<Object, Object> nicNetworksList = new HashMap<>();
Map<String, String> entry0 = new HashMap<>();
entry0.put("networkids", "1,2,3");
entry0.put("ip4addresses", "10.1.1.5,");
entry0.put("ip6addresses", "fd00::5,");
nicNetworksList.put(0, entry0);
ReflectionTestUtils.setField(cmd, "nicNetworksList", nicNetworksList);
ReflectionTestUtils.setField(cmd, "_networkService", mock(NetworkService.class));

List<BaseDeployVMCmd.NicNetworkGrouping> result = cmd.getNicNetworksList();

Map<Long, IpAddresses> associatedNetworkIps = result.get(0).getAssociatedNetworkIps();
assertEquals(1, associatedNetworkIps.size());
assertEquals("10.1.1.5", associatedNetworkIps.get(2L).getIp4Address());
assertEquals("fd00::5", associatedNetworkIps.get(2L).getIp6Address());
assertFalse(associatedNetworkIps.containsKey(3L));
}

@Test
public void testGetNicNetworksListRejectsTooManyAssociatedNetworkIps() {
Map<Object, Object> nicNetworksList = new HashMap<>();
Map<String, String> entry0 = new HashMap<>();
entry0.put("networkids", "1,2");
entry0.put("ip4addresses", "10.1.1.5,10.1.1.6,10.1.1.7");
nicNetworksList.put(0, entry0);
ReflectionTestUtils.setField(cmd, "nicNetworksList", nicNetworksList);
ReflectionTestUtils.setField(cmd, "_networkService", mock(NetworkService.class));

InvalidParameterValueException thrownException = assertThrows(InvalidParameterValueException.class, () -> {
cmd.getNicNetworksList();
});
assertTrue(thrownException.getMessage().contains("ip4addresses"));
}

@Test
public void testGetNetworkIdsNicNetworksListAndNetworkIds() {
Map<Object, Object> nicNetworksList = new HashMap<>();
Map<String, String> entry0 = new HashMap<>();
entry0.put("networkids", "1,2");
nicNetworksList.put(0, entry0);
ReflectionTestUtils.setField(cmd, "nicNetworksList", nicNetworksList);
ReflectionTestUtils.setField(cmd, "networkIds", Arrays.asList(1L, 2L));

InvalidParameterValueException thrownException = assertThrows(InvalidParameterValueException.class, () -> {
cmd.getNetworkIds();
});
assertTrue(thrownException.getMessage().contains("nicnetworkslist"));
}

@Test
public void testGetIpToNetworkMap_WithNetworkIds() {
ReflectionTestUtils.setField(cmd, "networkIds", Arrays.asList(1L, 2L));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
import org.apache.cloudstack.api.command.user.template.ListVnfTemplatesCmd;
import org.apache.cloudstack.api.command.user.template.RegisterVnfTemplateCmd;
import org.apache.cloudstack.api.command.user.template.UpdateVnfTemplateCmd;
import org.apache.cloudstack.api.command.user.vm.BaseDeployVMCmd;
import org.apache.cloudstack.api.command.user.vm.DeployVnfApplianceCmd;
import org.apache.cloudstack.api.command.user.vm.ListVnfAppliancesCmd;
import org.apache.cloudstack.framework.config.ConfigKey;
Expand Down Expand Up @@ -220,6 +221,22 @@ public void validateVnfApplianceNics(VirtualMachineTemplate template, List<Long>
}
}

@Override
public void validateVnfApplianceTrunkNics(VirtualMachineTemplate template, List<BaseDeployVMCmd.NicNetworkGrouping> nicNetworksList) {
if (CollectionUtils.isEmpty(nicNetworksList)) {
return;
}
List<VnfTemplateNicVO> vnfNics = vnfTemplateNicDao.listByTemplateId(template.getId());
for (VnfTemplateNicVO vnfNic : vnfNics) {
if (!vnfNic.isManagement() || vnfNic.getDeviceId() >= nicNetworksList.size()) {
continue;
}
if (!nicNetworksList.get((int) vnfNic.getDeviceId()).getAssociatedNetworkIds().isEmpty()) {
throw new InvalidParameterValueException("VNF nic is the management interface and cannot be a multi-VLAN trunk nic: " + vnfNic);
}
}
}

private void validateVnfApplianceNetworksMap(VirtualMachineTemplate template, Map<Integer, Long> vmNetworkMap) {
if (MapUtils.isEmpty(vmNetworkMap)) {
throw new InvalidParameterValueException("VNF networks map is empty");
Expand Down