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
Bill usage for a trunk nic's associated networks
- New UsageEventUtils.publishNicNetworkOfferingUsageEvents() emits one
  usage event per network a nic bills against (primary + any trunk
  associations), replacing the old single-event publishUsageEvent at
  every NIC-billing call site
- Ordinary non-trunk nics still produce exactly one event, unchanged
- New nullable usage_network_offering.network_id column disambiguates
  rows when a trunk nic's networks share one network offering
- Backfills network_id on a nic's own still-open usage row(s) the
  moment it first converts to a trunk nic; historic rows otherwise
  left alone
  • Loading branch information
Pearl1594 committed Sep 28, 2026
commit bcb89ab26f0b190082990cfa4973720bbf6c04ae
2 changes: 2 additions & 0 deletions api/src/main/java/com/cloud/event/EventTypes.java
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ public class EventTypes {
public static final String EVENT_NIC_DETAIL_ADD = "NIC.DETAIL.ADD";
public static final String EVENT_NIC_DETAIL_UPDATE = "NIC.DETAIL.UPDATE";
public static final String EVENT_NIC_DETAIL_REMOVE = "NIC.DETAIL.REMOVE";
// internal-only signal consumed by the usage job - never user-facing, not an audit event
public static final String EVENT_NIC_NETWORK_ID_BACKFILL = "NIC.NETWORK.ID.BACKFILL";

// Load Balancers
public static final String EVENT_ASSIGN_TO_LOAD_BALANCER_RULE = "LB.ASSIGN.TO.RULE";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package com.cloud.event;

import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
Expand All @@ -26,6 +27,11 @@
import javax.inject.Inject;

import com.cloud.network.Network;
import com.cloud.network.dao.NetworkDao;
import com.cloud.network.dao.NetworkVO;
import com.cloud.vm.Nic;
import com.cloud.vm.dao.NicNetworkMapDao;
import com.cloud.vm.dao.NicNetworkMapVO;
import org.apache.commons.collections.MapUtils;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;

Expand All @@ -48,6 +54,8 @@ public class UsageEventUtils {
private static UsageEventDao s_usageEventDao;
private static AccountDao s_accountDao;
private static DataCenterDao s_dcDao;
private static NetworkDao s_networkDao;
private static NicNetworkMapDao s_nicNetworkMapDao;
protected static Logger LOGGER = LogManager.getLogger(UsageEventUtils.class);
protected static EventBus s_eventBus = null;
protected static ConfigurationDao s_configDao;
Expand All @@ -61,6 +69,10 @@ public class UsageEventUtils {
DataCenterDao dcDao;
@Inject
ConfigurationDao configDao;
@Inject
NetworkDao networkDao;
@Inject
NicNetworkMapDao nicNetworkMapDao;

public UsageEventUtils() {
}
Expand All @@ -71,6 +83,8 @@ void init() {
s_accountDao = accountDao;
s_dcDao = dcDao;
s_configDao = configDao;
s_networkDao = networkDao;
s_nicNetworkMapDao = nicNetworkMapDao;
}

public static void publishUsageEvent(String usageType, long accountId, long zoneId, long resourceId, String resourceName, Long offeringId, Long templateId,
Expand All @@ -94,6 +108,49 @@ public static void publishUsageEvent(String usageType, long accountId, long zone

}

// named distinctly, rather than as another publishUsageEvent(...) overload, because its parameter shape
// (Map<String, String>, boolean) is only distinguishable at compile time from the existing (Long, boolean)
// overload above when neither argument is a literal null - several existing call sites pass null literals
// here, which would make an overload ambiguous
public static void publishUsageEventWithDetails(String usageType, long accountId, long zoneId, long resourceId, String resourceName, Long offeringId, Long templateId,
Long size, String entityType, String entityUUID, Map<String, String> details, boolean displayResource) {
if (displayResource) {
saveUsageEvent(usageType, accountId, zoneId, resourceId, resourceName, offeringId, templateId, size, details);
}
publishUsageEvent(usageType, accountId, zoneId, entityType, entityUUID);
}

/**
* Emits one {@code usageType} usage event per network a NIC is actually billed against - its primary
* network, plus any additional networks it is associated with as a multi-VLAN trunk nic. An ordinary,
* non-trunk nic has exactly one such network, so this is identical to today's single-event behaviour for
* every legacy nic. Each event carries the network id as a detail, so the usage-processing side can
* disambiguate rows when a trunk nic's networks share the same network offering.
*/
public static void publishNicNetworkOfferingUsageEvents(String usageType, long accountId, long zoneId, long vmId, String entityType, String entityUUID,
Nic nic, long isDefault, boolean displayResource) {
NetworkVO primaryNetwork = s_networkDao.findById(nic.getNetworkId());
if (primaryNetwork != null) {
publishNicNetworkOfferingUsageEvent(usageType, accountId, zoneId, vmId, entityType, entityUUID, nic.getId(), primaryNetwork, isDefault, displayResource);
}
if (nic.getMultiNetwork()) {
for (NicNetworkMapVO association : s_nicNetworkMapDao.listByNicId(nic.getId())) {
NetworkVO associatedNetwork = s_networkDao.findById(association.getNetworkId());
if (associatedNetwork != null) {
publishNicNetworkOfferingUsageEvent(usageType, accountId, zoneId, vmId, entityType, entityUUID, nic.getId(), associatedNetwork, isDefault,
displayResource);
}
}
}
}

private static void publishNicNetworkOfferingUsageEvent(String usageType, long accountId, long zoneId, long vmId, String entityType, String entityUUID,
long nicId, NetworkVO network, long isDefault, boolean displayResource) {
Map<String, String> details = Collections.singletonMap(UsageEventVO.DynamicParameters.networkId.name(), String.valueOf(network.getId()));
publishUsageEventWithDetails(usageType, accountId, zoneId, vmId, Long.toString(nicId), network.getNetworkOfferingId(), null, isDefault, entityType, entityUUID,
details, displayResource);
}

public static void publishUsageEvent(String usageType, long accountId, long zoneId, long resourceId, String resourceName, Long offeringId, Long templateId,
Long size, String entityType, String entityUUID, Long vmId, boolean displayResource) {
if (displayResource) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// 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.event;

import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.inject.Inject;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;

import com.cloud.dc.dao.DataCenterDao;
import com.cloud.event.dao.UsageEventDao;
import com.cloud.network.dao.NetworkDao;
import com.cloud.network.dao.NetworkVO;
import com.cloud.user.dao.AccountDao;
import com.cloud.vm.Nic;
import com.cloud.vm.dao.NicNetworkMapDao;
import com.cloud.vm.dao.NicNetworkMapVO;

import org.apache.cloudstack.framework.config.dao.ConfigurationDao;

/**
* Verifies {@link UsageEventUtils#publishNicNetworkOfferingUsageEvents} - the mechanism that emits one usage
* event per (nic, network) a nic actually bills against, rather than one per nic. The key non-regression
* assertion is that an ordinary, non-trunk nic still produces exactly the single event it always has.
*/
@RunWith(MockitoJUnitRunner.class)
public class UsageEventUtilsTest {

private static final String USAGE_TYPE = EventTypes.EVENT_NETWORK_OFFERING_ASSIGN;
private static final long ACCOUNT_ID = 1L;
private static final long ZONE_ID = 2L;
private static final long VM_ID = 3L;
private static final long NIC_ID = 5L;
private static final String ENTITY_TYPE = "VirtualMachine";
private static final String ENTITY_UUID = "vm-uuid";

protected Map<String, Object> staticFieldValues = new HashMap<>();

@Mock
protected UsageEventDao usageEventDao;
@Mock
protected AccountDao accountDao;
@Mock
protected DataCenterDao dcDao;
@Mock
protected ConfigurationDao configDao;
@Mock
protected NetworkDao networkDao;
@Mock
protected NicNetworkMapDao nicNetworkMapDao;

@Before
public void setup() throws Exception {
staticFieldValues = new HashMap<>();
UsageEventUtils utils = new UsageEventUtils();

for (Field field : UsageEventUtils.class.getDeclaredFields()) {
if (field.getAnnotation(Inject.class) != null) {
field.setAccessible(true);
try {
Field mockField = this.getClass().getDeclaredField(field.getName());
mockField.setAccessible(true);
field.set(utils, mockField.get(this));
Field staticField = UsageEventUtils.class.getDeclaredField("s_" + field.getName());
staticField.setAccessible(true);
staticFieldValues.put(field.getName(), staticField.get(null));
} catch (Exception e) {
// ignore fields with no matching mock/static counterpart
}
}
}
utils.init();

// configDao.getValue(...) defaults to null -> event-bus publish short-circuits without needing an
// EventDistributor bean; only the usage_event persistence path (what billing actually reads) matters here
Mockito.when(usageEventDao.persist(Mockito.any(UsageEventVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
}

@After
public void teardown() throws Exception {
UsageEventUtils utils = new UsageEventUtils();
for (String fieldName : staticFieldValues.keySet()) {
Field field = UsageEventUtils.class.getDeclaredField("s_" + fieldName);
field.setAccessible(true);
field.set(utils, staticFieldValues.get(fieldName));
}
}

private NetworkVO mockNetwork(long id, long offeringId) {
NetworkVO network = Mockito.mock(NetworkVO.class);
Mockito.when(network.getId()).thenReturn(id);
Mockito.when(network.getNetworkOfferingId()).thenReturn(offeringId);
return network;
}

@Test
public void ordinaryNicEmitsExactlyOneEventForItsPrimaryNetworkOnly() {
Nic nic = Mockito.mock(Nic.class);
Mockito.when(nic.getId()).thenReturn(NIC_ID);
Mockito.when(nic.getNetworkId()).thenReturn(100L);
Mockito.when(nic.getMultiNetwork()).thenReturn(false);
NetworkVO primaryNetwork = mockNetwork(100L, 7L);
Mockito.when(networkDao.findById(100L)).thenReturn(primaryNetwork);

UsageEventUtils.publishNicNetworkOfferingUsageEvents(USAGE_TYPE, ACCOUNT_ID, ZONE_ID, VM_ID, ENTITY_TYPE, ENTITY_UUID, nic, 1L, true);

Mockito.verifyNoInteractions(nicNetworkMapDao);

ArgumentCaptor<UsageEventVO> eventCaptor = ArgumentCaptor.forClass(UsageEventVO.class);
Mockito.verify(usageEventDao, Mockito.times(1)).persist(eventCaptor.capture());
UsageEventVO persisted = eventCaptor.getValue();
Assert.assertEquals(Long.toString(NIC_ID), persisted.getResourceName());
Assert.assertEquals(Long.valueOf(7L), persisted.getOfferingId());
Assert.assertEquals(Long.valueOf(1L), persisted.getSize());

ArgumentCaptor<Map<String, String>> detailsCaptor = ArgumentCaptor.forClass(Map.class);
Mockito.verify(usageEventDao, Mockito.times(1)).saveDetails(Mockito.anyLong(), detailsCaptor.capture());
Assert.assertEquals("100", detailsCaptor.getValue().get(UsageEventVO.DynamicParameters.networkId.name()));
}

@Test
public void trunkNicEmitsOneEventForItsPrimaryNetworkAndOneForEachAssociatedNetwork() {
Nic nic = Mockito.mock(Nic.class);
Mockito.when(nic.getId()).thenReturn(NIC_ID);
Mockito.when(nic.getNetworkId()).thenReturn(100L);
Mockito.when(nic.getMultiNetwork()).thenReturn(true);
NetworkVO primaryNetwork = mockNetwork(100L, 7L);
NetworkVO associatedNetwork1 = mockNetwork(200L, 8L);
NetworkVO associatedNetwork2 = mockNetwork(300L, 9L);
Mockito.when(networkDao.findById(100L)).thenReturn(primaryNetwork);
Mockito.when(networkDao.findById(200L)).thenReturn(associatedNetwork1);
Mockito.when(networkDao.findById(300L)).thenReturn(associatedNetwork2);
List<NicNetworkMapVO> associations = Arrays.asList(new NicNetworkMapVO(NIC_ID, 200L), new NicNetworkMapVO(NIC_ID, 300L));
Mockito.when(nicNetworkMapDao.listByNicId(NIC_ID)).thenReturn(associations);

UsageEventUtils.publishNicNetworkOfferingUsageEvents(USAGE_TYPE, ACCOUNT_ID, ZONE_ID, VM_ID, ENTITY_TYPE, ENTITY_UUID, nic, 0L, true);

ArgumentCaptor<UsageEventVO> eventCaptor = ArgumentCaptor.forClass(UsageEventVO.class);
Mockito.verify(usageEventDao, Mockito.times(3)).persist(eventCaptor.capture());
List<Long> offeringIds = eventCaptor.getAllValues().stream().map(UsageEventVO::getOfferingId).collect(java.util.stream.Collectors.toList());
Assert.assertTrue(offeringIds.containsAll(Arrays.asList(7L, 8L, 9L)));
// every one of the three rows still belongs to the same nic
for (UsageEventVO event : eventCaptor.getAllValues()) {
Assert.assertEquals(Long.toString(NIC_ID), event.getResourceName());
}

ArgumentCaptor<Map<String, String>> detailsCaptor = ArgumentCaptor.forClass(Map.class);
Mockito.verify(usageEventDao, Mockito.times(3)).saveDetails(Mockito.anyLong(), detailsCaptor.capture());
List<String> networkIds = detailsCaptor.getAllValues().stream()
.map(details -> details.get(UsageEventVO.DynamicParameters.networkId.name())).collect(java.util.stream.Collectors.toList());
Assert.assertTrue(networkIds.containsAll(Arrays.asList("100", "200", "300")));
}

@Test
public void displayResourceFalseNeverPersistsAnyUsageRow() {
Nic nic = Mockito.mock(Nic.class);
Mockito.when(nic.getId()).thenReturn(NIC_ID);
Mockito.when(nic.getNetworkId()).thenReturn(100L);
Mockito.when(nic.getMultiNetwork()).thenReturn(false);
NetworkVO primaryNetwork = mockNetwork(100L, 7L);
Mockito.when(networkDao.findById(100L)).thenReturn(primaryNetwork);

UsageEventUtils.publishNicNetworkOfferingUsageEvents(USAGE_TYPE, ACCOUNT_ID, ZONE_ID, VM_ID, ENTITY_TYPE, ENTITY_UUID, nic, 1L, false);

Mockito.verify(usageEventDao, Mockito.never()).persist(Mockito.any());
Mockito.verify(usageEventDao, Mockito.never()).saveDetails(Mockito.anyLong(), Mockito.anyMap());
}

@Test
public void missingAssociatedNetworkIsSkippedRatherThanFailing() {
Nic nic = Mockito.mock(Nic.class);
Mockito.when(nic.getId()).thenReturn(NIC_ID);
Mockito.when(nic.getNetworkId()).thenReturn(100L);
Mockito.when(nic.getMultiNetwork()).thenReturn(true);
NetworkVO primaryNetwork = mockNetwork(100L, 7L);
Mockito.when(networkDao.findById(100L)).thenReturn(primaryNetwork);
Mockito.when(networkDao.findById(200L)).thenReturn(null);
Mockito.when(nicNetworkMapDao.listByNicId(NIC_ID)).thenReturn(Collections.singletonList(new NicNetworkMapVO(NIC_ID, 200L)));

UsageEventUtils.publishNicNetworkOfferingUsageEvents(USAGE_TYPE, ACCOUNT_ID, ZONE_ID, VM_ID, ENTITY_TYPE, ENTITY_UUID, nic, 1L, true);

Mockito.verify(usageEventDao, Mockito.times(1)).persist(Mockito.any());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4689,8 +4689,9 @@ private NicProfile orchestrateAddVmToNetwork(final VirtualMachine vm, final Netw
final long isDefault = nic.isDefaultNic() ? 1 : 0;

if(VirtualMachine.Type.User.equals(vmVO.getType())) {
UsageEventUtils.publishUsageEvent(EventTypes.EVENT_NETWORK_OFFERING_ASSIGN, vmVO.getAccountId(), vmVO.getDataCenterId(), vmVO.getId(),
Long.toString(nic.getId()), network.getNetworkOfferingId(), null, isDefault, VirtualMachine.class.getName(), vmVO.getUuid(), vm.isDisplay());
NicVO nicVO = _nicsDao.findById(nic.getId());
UsageEventUtils.publishNicNetworkOfferingUsageEvents(EventTypes.EVENT_NETWORK_OFFERING_ASSIGN, vmVO.getAccountId(), vmVO.getDataCenterId(),
vmVO.getId(), VirtualMachine.class.getName(), vmVO.getUuid(), nicVO, isDefault, vm.isDisplay());
}
return nic;
} else {
Expand Down Expand Up @@ -4776,8 +4777,8 @@ private boolean orchestrateRemoveNicFromVm(final VirtualMachine vm, final Nic ni
_userVmMgr.setupVmForPvlan(false, vm.getHostId(), nicProfile);
logger.debug("NIC is unplugged successfully for Instance {} in Network {}.", vm, network);
final long isDefault = nic.isDefaultNic() ? 1 : 0;
UsageEventUtils.publishUsageEvent(EventTypes.EVENT_NETWORK_OFFERING_REMOVE, vm.getAccountId(), vm.getDataCenterId(), vm.getId(),
Long.toString(nic.getId()), network.getNetworkOfferingId(), null, isDefault, VirtualMachine.class.getName(), vm.getUuid(), vm.isDisplay());
UsageEventUtils.publishNicNetworkOfferingUsageEvents(EventTypes.EVENT_NETWORK_OFFERING_REMOVE, vm.getAccountId(), vm.getDataCenterId(), vm.getId(),
VirtualMachine.class.getName(), vm.getUuid(), nic, isDefault, vm.isDisplay());
} else {
logger.warn("Failed to unplug NIC for the Instance {} from Network {}.", vm, network);
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
@Table(name = "usage_event")
public class UsageEventVO implements UsageEvent {
public enum DynamicParameters {
cpuSpeed, cpuNumber, memory, vmSnapshotId
cpuSpeed, cpuNumber, memory, vmSnapshotId, networkId
};

@Id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,19 @@ public class UsageNetworkOfferingVO implements InternalIdentity {
@Column(name = "nic_id")
private Long nicId;

@Column(name = "network_id")
private Long networkId;

protected UsageNetworkOfferingVO() {
}

public UsageNetworkOfferingVO(long zoneId, long accountId, long domainId, long vmInstanceId, long networkOfferingId, long nicId, boolean isDefault, Date created,
Date deleted) {
this(zoneId, accountId, domainId, vmInstanceId, networkOfferingId, nicId, null, isDefault, created, deleted);
}

public UsageNetworkOfferingVO(long zoneId, long accountId, long domainId, long vmInstanceId, long networkOfferingId, long nicId, Long networkId, boolean isDefault,
Date created, Date deleted) {
this.zoneId = zoneId;
this.accountId = accountId;
this.domainId = domainId;
Expand All @@ -81,6 +89,7 @@ public UsageNetworkOfferingVO(long zoneId, long accountId, long domainId, long v
this.created = created;
this.deleted = deleted;
this.nicId = nicId;
this.networkId = networkId;
}

public long getZoneId() {
Expand Down Expand Up @@ -127,6 +136,14 @@ public void setNicId(Long nicId) {
this.nicId = nicId;
}

public Long getNetworkId() {
return networkId;
}

public void setNetworkId(Long networkId) {
this.networkId = networkId;
}

@Override
public long getId() {
return this.id;
Expand Down
Loading