You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cloudstack.apache.org by pe...@apache.org on 2026/10/07 18:40:42 UTC
(cloudstack) 03/03: Finish VM deploy/VNF UI, migration host-readiness, and trunk-association rule/listing support
This is an automated email from the ASF dual-hosted git repository.
Pearl1594 pushed a commit to branch support-multi-vlan-guestnet
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
commit fb2e91f41d5c4c57c65765c2de2034be3df761f7
Author: Pearl1594 <pe...@gmail.com>
AuthorDate: Wed Oct 7 12:55:30 2026 -0400
Finish VM deploy/VNF UI, migration host-readiness, and trunk-association rule/listing support
- VM deploy/VNF UI: nicnetworkslist grouping in DeployVM/DeployVnfAppliance,
zone-flag gating in NicsTab/NicsTable/host list, MigrateWizard unsuitable-
reason tooltip
- Migration: expose getMultiNetworkNicUnsuitableReason via
findHostsForMigration/HostForMigrationResponse; destination-capability-
driven bridge/VLAN-XML rewrite in LibvirtMigrateCommandWrapper now also
covers a destination-only vlan_filtering boundary, not just the source's
- DHCP: VR full-rebuild (createDhcpEntryCommandsForVMs) now includes a nic's
trunk associations, not just its primary network
- Billing: live associate/disassociate on a Running Instance now bills
immediately instead of waiting for the next VM start
- Association API hardening: admin-only authorization on associate/change-
primary/disassociate, access check on requested networks, CIDR-overlap
error includes network names
- listNics/listVirtualMachines now also match a nic via its trunk
associations, not just its primary network, so Static NAT/Port Forwarding/
Load Balancing pickers can target an associated network's IP, labeled
distinctly from primary/secondary IPs
---
.../java/com/cloud/server/ManagementService.java | 7 +
.../admin/host/FindHostsForMigrationCmd.java | 7 +
.../admin/network/AssociateNetworkToNicCmd.java | 4 +-
.../admin/network/ChangeNicPrimaryNetworkCmd.java | 4 +-
.../network/DisassociateNetworkFromNicCmd.java | 4 +-
.../api/command/user/vm/BaseDeployVMCmd.java | 2 +-
.../api/response/HostForMigrationResponse.java | 8 +
.../main/java/com/cloud/event/UsageEventUtils.java | 5 +-
.../engine/orchestration/NetworkOrchestrator.java | 15 ++
.../wrapper/LibvirtMigrateCommandWrapper.java | 7 +-
.../wrapper/LibvirtMigrateCommandWrapperTest.java | 18 ++
.../java/com/cloud/api/query/QueryManagerImpl.java | 41 ++++
.../java/com/cloud/network/NetworkServiceImpl.java | 45 +++-
.../cloud/network/router/CommandSetupHelper.java | 30 +++
.../com/cloud/server/ManagementServerImpl.java | 14 ++
.../com/cloud/network/NetworkServiceImplTest.java | 38 ++++
ui/public/locales/en.json | 22 ++
ui/src/components/view/ListView.vue | 13 +-
ui/src/config/section/infra/hosts.js | 2 +-
ui/src/utils/multiNetworkNic.js | 89 ++++++++
ui/src/views/compute/DeployVM.vue | 76 +++++--
ui/src/views/compute/DeployVnfAppliance.vue | 37 +++-
ui/src/views/compute/MigrateWizard.vue | 5 +
.../views/compute/wizard/NetworkConfiguration.vue | 228 ++++++++++++++++++---
ui/src/views/compute/wizard/VnfNicsSelection.vue | 173 +++++++++++++++-
ui/src/views/infra/Resources.vue | 47 ++++-
ui/src/views/network/EnableStaticNat.vue | 51 ++---
ui/src/views/network/InternalLBAssignVmForm.vue | 34 +--
ui/src/views/network/LoadBalancing.vue | 38 ++--
ui/src/views/network/NicsTab.vue | 159 +++++++++++++-
ui/src/views/network/NicsTable.vue | 47 ++++-
ui/src/views/network/PortForwarding.vue | 43 ++--
32 files changed, 1171 insertions(+), 142 deletions(-)
diff --git a/api/src/main/java/com/cloud/server/ManagementService.java b/api/src/main/java/com/cloud/server/ManagementService.java
index bcca229c06b..029882f95dd 100644
--- a/api/src/main/java/com/cloud/server/ManagementService.java
+++ b/api/src/main/java/com/cloud/server/ManagementService.java
@@ -472,6 +472,13 @@ public interface ManagementService {
Ternary<Pair<List<? extends Host>, Integer>, List<? extends Host>, Map<Host, Boolean>> listHostsForMigrationOfVM(VirtualMachine vm, Long startIndex, Long pageSize, String keyword, List<VirtualMachine> vmList);
+ /**
+ * If vm has a multi-VLAN trunk NIC and host is not ready for it (VLAN filtering not enabled), returns a
+ * human-readable reason; null otherwise (including when vm has no such NIC, in which case host readiness
+ * for this concern is irrelevant).
+ */
+ String getMultiNetworkNicUnsuitableReason(VirtualMachine vm, Host host);
+
/**
* Apply affinity group constraints and other exclusion rules for VM migration.
* This is a helper method that can be used independently for per-iteration affinity checks in DRS.
diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/FindHostsForMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/FindHostsForMigrationCmd.java
index 4d6ef741961..a56880de66c 100644
--- a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/FindHostsForMigrationCmd.java
+++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/FindHostsForMigrationCmd.java
@@ -32,6 +32,7 @@ import org.apache.cloudstack.api.response.UserVmResponse;
import com.cloud.host.Host;
import com.cloud.utils.Pair;
import com.cloud.utils.Ternary;
+import com.cloud.vm.VirtualMachine;
@APICommand(name = "findHostsForMigration", description = "Find hosts suitable for migrating an Instance.", responseObject = HostForMigrationResponse.class,
requestHasSensitiveInfo = false, responseHasSensitiveInfo = false)
@@ -73,6 +74,8 @@ public class FindHostsForMigrationCmd extends BaseListCmd {
List<? extends Host> hostsWithCapacity = hostsForMigration.second();
hostsRequiringStorageMotion = hostsForMigration.third();
+ VirtualMachine vm = _entityMgr.findById(VirtualMachine.class, getVirtualMachineId());
+
response = new ListResponse<HostForMigrationResponse>();
List<HostForMigrationResponse> hostResponses = new ArrayList<HostForMigrationResponse>();
for (Host host : result.first()) {
@@ -90,6 +93,10 @@ public class FindHostsForMigrationCmd extends BaseListCmd {
hostResponse.setRequiresStorageMotion(false);
}
+ if (!suitableForMigration && vm != null) {
+ hostResponse.setUnsuitableReason(_mgr.getMultiNetworkNicUnsuitableReason(vm, host));
+ }
+
hostResponse.setObjectName("host");
hostResponses.add(hostResponse);
}
diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/AssociateNetworkToNicCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/AssociateNetworkToNicCmd.java
index 4b47a5e9462..2fa45197ea5 100644
--- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/AssociateNetworkToNicCmd.java
+++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/AssociateNetworkToNicCmd.java
@@ -21,6 +21,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import org.apache.cloudstack.acl.RoleType;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiCommandResourceType;
import org.apache.cloudstack.api.ApiConstants;
@@ -45,7 +46,8 @@ import com.cloud.vm.Nic;
import com.cloud.vm.VirtualMachine;
@APICommand(name = "associateNetworkToNic", description = "Associates one or more additional networks with an existing NIC, delivering them as a multi-VLAN trunk",
- responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0")
+ responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0",
+ authorized = {RoleType.Admin})
public class AssociateNetworkToNicCmd extends BaseAsyncCmd {
private static final String s_name = "associatenetworktonicresponse";
diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/ChangeNicPrimaryNetworkCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/ChangeNicPrimaryNetworkCmd.java
index ac2e2ce2d55..93d48aa1dd2 100644
--- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/ChangeNicPrimaryNetworkCmd.java
+++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/ChangeNicPrimaryNetworkCmd.java
@@ -16,6 +16,7 @@
// under the License.
package org.apache.cloudstack.api.command.admin.network;
+import org.apache.cloudstack.acl.RoleType;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiCommandResourceType;
import org.apache.cloudstack.api.ApiConstants;
@@ -36,7 +37,8 @@ import com.cloud.vm.VirtualMachine;
@APICommand(name = "changeNicPrimaryNetwork", description = "Changes a multi-VLAN trunk NIC's primary (native/untagged) network to one of its already-associated networks. "
+ "Only allowed while the owning Instance is stopped. The operator is responsible for confirming the guest will "
+ "reacquire its address on the new primary network (e.g. via DHCP) after the next start - CloudStack cannot verify this from the host side.",
- responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0")
+ responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0",
+ authorized = {RoleType.Admin})
public class ChangeNicPrimaryNetworkCmd extends BaseAsyncCmd {
private static final String s_name = "changenicprimarynetworkresponse";
diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/DisassociateNetworkFromNicCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/DisassociateNetworkFromNicCmd.java
index ad860a4efe2..58b1450c032 100644
--- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/DisassociateNetworkFromNicCmd.java
+++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/DisassociateNetworkFromNicCmd.java
@@ -16,6 +16,7 @@
// under the License.
package org.apache.cloudstack.api.command.admin.network;
+import org.apache.cloudstack.acl.RoleType;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiCommandResourceType;
import org.apache.cloudstack.api.ApiConstants;
@@ -35,7 +36,8 @@ import com.cloud.vm.Nic;
import com.cloud.vm.VirtualMachine;
@APICommand(name = "disassociateNetworkFromNic", description = "Disassociates a network from an existing NIC, shrinking its multi-VLAN trunk",
- responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0")
+ responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0",
+ authorized = {RoleType.Admin})
public class DisassociateNetworkFromNicCmd extends BaseAsyncCmd {
private static final String s_name = "disassociatenetworkfromnicresponse";
diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java
index 81b1debd1d8..7975c9279ea 100644
--- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java
+++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java
@@ -194,7 +194,7 @@ public abstract class BaseDeployVMCmd extends BaseAsyncCreateCustomIdCmd impleme
private Map ipToNetworkList;
@Parameter(name = ApiConstants.NIC_NETWORKS_LIST, type = CommandType.MAP, since = "24.0.0",
- authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin},
+ authorized = {RoleType.Admin},
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,"
diff --git a/api/src/main/java/org/apache/cloudstack/api/response/HostForMigrationResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/HostForMigrationResponse.java
index b3853d9cc24..6143ce8de72 100644
--- a/api/src/main/java/org/apache/cloudstack/api/response/HostForMigrationResponse.java
+++ b/api/src/main/java/org/apache/cloudstack/api/response/HostForMigrationResponse.java
@@ -29,7 +29,15 @@ public class HostForMigrationResponse extends HostResponse {
@Param(description = "True if migrating an Instance to this host requires storage motion, false otherwise")
private Boolean requiresStorageMotion;
+ @SerializedName("unsuitablereason")
+ @Param(description = "If this host is not suitable for migration, the reason why; null when suitable")
+ private String unsuitableReason;
+
public void setRequiresStorageMotion(Boolean requiresStorageMotion) {
this.requiresStorageMotion = requiresStorageMotion;
}
+
+ public void setUnsuitableReason(String unsuitableReason) {
+ this.unsuitableReason = unsuitableReason;
+ }
}
diff --git a/engine/components-api/src/main/java/com/cloud/event/UsageEventUtils.java b/engine/components-api/src/main/java/com/cloud/event/UsageEventUtils.java
index 753774f9360..6209cc04b4a 100644
--- a/engine/components-api/src/main/java/com/cloud/event/UsageEventUtils.java
+++ b/engine/components-api/src/main/java/com/cloud/event/UsageEventUtils.java
@@ -144,7 +144,10 @@ public class UsageEventUtils {
}
}
- private static void publishNicNetworkOfferingUsageEvent(String usageType, long accountId, long zoneId, long vmId, String entityType, String entityUUID,
+ // Public so a single association can be billed immediately outside the nic-wide loop above - e.g. a live
+ // associateNetworkToNic/disassociateNetworkFromNic on a Running VM, where only the one network just
+ // (dis)associated should be billed, not every network the nic already has.
+ public 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,
diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java
index f676882211a..7901532b0b0 100644
--- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java
+++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java
@@ -4130,6 +4130,9 @@ public class NetworkOrchestrator extends ManagerBase implements NetworkOrchestra
result = _nicDao.listByVmId(vmId);
} else {
result = _nicDao.listByVmIdAndNicIdAndNtwkId(vmId, nicId, networkId);
+ if (networkId != null) {
+ result = addNicsWithNetworkAssociation(result, vmId, nicId, networkId);
+ }
}
} else {
result = _nicDao.listByVmIdAndKeyword(vmId, keyword);
@@ -4158,6 +4161,18 @@ public class NetworkOrchestrator extends ManagerBase implements NetworkOrchestra
return result;
}
+ // A nic whose primary network is something else but is also associated with networkId (multi-VLAN trunk nic)
+ // is invisible to the primary-only search above, so it needs a separate associations-based lookup here.
+ private List<NicVO> addNicsWithNetworkAssociation(final List<NicVO> primaryMatches, final long vmId, final Long nicId, final long networkId) {
+ final List<NicVO> result = new ArrayList<>(primaryMatches);
+ for (final NicVO candidate : _nicDao.listByVmIdAndNicIdAndNtwkId(vmId, nicId, null)) {
+ if (!result.contains(candidate) && _nicNetworkMapDao.findByNicIdAndNetworkId(candidate.getId(), networkId) != null) {
+ result.add(candidate);
+ }
+ }
+ return result;
+ }
+
@DB
@Override
public boolean reallocate(final VirtualMachineProfile vm, final DataCenterDeployment dest) throws InsufficientCapacityException, ConcurrentOperationException {
diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java
index 48f58e25d5d..2396227bc49 100644
--- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java
+++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java
@@ -699,9 +699,12 @@ public final class LibvirtMigrateCommandWrapper extends CommandWrapper<MigrateCo
throws ParserConfigurationException, IOException, SAXException, TransformerException {
NicTO[] nics = command.getVirtualMachine().getNics();
boolean sourceVlanFilteringEnabled = libvirtComputingResource.hostSupportsVlanFiltering();
+ // destination-shared-bridge is also a rewrite trigger: a legacy-source nic's bridge name (breth1-<vid>)
+ // doesn't exist on a shared-bridge destination, needs rewriting to cloudbr1 just as much as the reverse
+ boolean destVlanFilteringEnabled = Boolean.TRUE.equals(command.getDestVlanFilteringEnabled());
boolean anyNicNeedsHandling = false;
for (NicTO nic : nics) {
- if (nic.getBroadcastType() == Networks.BroadcastDomainType.Vlan && (sourceVlanFilteringEnabled || nic.isTrunkVlan())) {
+ if (nic.getBroadcastType() == Networks.BroadcastDomainType.Vlan && (sourceVlanFilteringEnabled || destVlanFilteringEnabled || nic.isTrunkVlan())) {
anyNicNeedsHandling = true;
break;
}
@@ -732,7 +735,7 @@ public final class LibvirtMigrateCommandWrapper extends CommandWrapper<MigrateCo
String mac = findInterfaceMacAddress(interfaceElement);
NicTO nic = mac == null ? null : findNicByMac(nics, mac);
if (nic == null || nic.getBroadcastType() != Networks.BroadcastDomainType.Vlan
- || !(sourceVlanFilteringEnabled || nic.isTrunkVlan())) {
+ || !(sourceVlanFilteringEnabled || destVlanFilteringEnabled || nic.isTrunkVlan())) {
continue;
}
replaceVlanTrunkInterface(doc, interfaceElement, nic, command);
diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapperTest.java
index 9e9a5ce61c3..410f5137cbd 100644
--- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapperTest.java
+++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapperTest.java
@@ -1005,6 +1005,24 @@ public class LibvirtMigrateCommandWrapperTest {
assertEquals(vlanNicDomainXml, result);
}
+ @Test
+ public void replaceVlanTrunkInterfacesRewritesOrdinaryNicBridgeWhenOnlyDestIsFilteringEnabled()
+ throws ParserConfigurationException, IOException, SAXException, TransformerException {
+ // legacy (non-filtering) source -> shared-bridge destination: the source's own per-VLAN bridge name
+ // doesn't exist on the destination, so the rewrite must fire even though the source isn't shared-bridge
+ NicTO nic = buildVlanNicTO(false, 100, null);
+ Mockito.doReturn(new NicTO[]{nic}).when(virtualMachineTOMock).getNics();
+ Mockito.doReturn(virtualMachineTOMock).when(migrateCommandMock).getVirtualMachine();
+ Mockito.doReturn(false).when(libvirtComputingResourceMock).hostSupportsVlanFiltering();
+ Mockito.doReturn(true).when(migrateCommandMock).getDestVlanFilteringEnabled();
+ Mockito.doReturn(false).when(migrateCommandMock).getDestVlanTrunkXmlSupported();
+ Mockito.doReturn(Map.of(vlanNicMac, "destcloudbr0")).when(migrateCommandMock).getNicBridgeMapping();
+
+ String result = libvirtMigrateCmdWrapper.replaceVlanTrunkInterfaces(vlanNicDomainXml, migrateCommandMock, libvirtComputingResourceMock);
+
+ assertTrue(result.contains("bridge=\"destcloudbr0\""));
+ }
+
@Test
public void replaceVlanTrunkInterfacesInsertsTrunkVlanBlockWhenDestSupportsTrunkXml()
throws ParserConfigurationException, IOException, SAXException, TransformerException {
diff --git a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java
index d700fba5a78..d76c7fc6742 100644
--- a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java
+++ b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java
@@ -376,6 +376,8 @@ import com.cloud.vm.VmDetailConstants;
import com.cloud.vm.dao.DomainRouterDao;
import com.cloud.vm.dao.InstanceGroupVMMapDao;
import com.cloud.vm.dao.NicDao;
+import com.cloud.vm.dao.NicNetworkMapDao;
+import com.cloud.vm.dao.NicNetworkMapVO;
import com.cloud.vm.dao.UserVmDao;
import com.cloud.vm.dao.VMInstanceDao;
import com.cloud.vm.dao.VMInstanceDetailsDao;
@@ -592,6 +594,9 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
@Inject
NicDao nicDao;
+ @Inject
+ NicNetworkMapDao nicNetworkMapDao;
+
@Inject
HostDao hostDao;
@@ -1776,9 +1781,45 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
Integer count = uniqueVmPair.second();
List<Long> vmIds = uniqueVmPair.first().stream().map(VMInstanceVO::getId).collect(Collectors.toList());
+
+ if (networkId != null) {
+ return addVmsWithNetworkAssociation(vmIds, count, networkId, domainId, isRecursive, permittedAccounts, listProjectResourcesCriteria);
+ }
return new Pair<>(vmIds, count);
}
+ // A nic whose primary network is something else but is also associated with networkId (multi-VLAN trunk nic)
+ // is invisible to the primary-nic-only search above, so it needs a separate, additive lookup here. The ACL
+ // check is re-applied via the same builder the primary search above already used, rather than re-implemented,
+ // so visibility rules can't drift between the two.
+ private Pair<List<Long>, Integer> addVmsWithNetworkAssociation(List<Long> vmIds, Integer count, Long networkId, Long domainId, Boolean isRecursive,
+ List<Long> permittedAccounts, ListProjectResourcesCriteria listProjectResourcesCriteria) {
+ Set<Long> candidateVmIds = new HashSet<>();
+ for (Long nicId : nicNetworkMapDao.listNicIdsByNetworkId(networkId)) {
+ NicVO nic = nicDao.findById(nicId);
+ if (nic != null && !vmIds.contains(nic.getInstanceId())) {
+ candidateVmIds.add(nic.getInstanceId());
+ }
+ }
+ if (candidateVmIds.isEmpty()) {
+ return new Pair<>(vmIds, count);
+ }
+
+ SearchBuilder<UserVmVO> assocSearchBuilder = userVmDao.createSearchBuilder();
+ accountMgr.buildACLSearchBuilder(assocSearchBuilder, domainId, isRecursive, permittedAccounts, listProjectResourcesCriteria);
+ assocSearchBuilder.and("idIN", assocSearchBuilder.entity().getId(), Op.IN);
+ SearchCriteria<UserVmVO> assocCriteria = assocSearchBuilder.create();
+ accountMgr.buildACLSearchCriteria(assocCriteria, domainId, isRecursive, permittedAccounts, listProjectResourcesCriteria);
+ assocCriteria.setParameters("idIN", candidateVmIds.toArray());
+ List<UserVmVO> visibleAssociatedVms = userVmDao.search(assocCriteria, null);
+
+ List<Long> mergedIds = new ArrayList<>(vmIds);
+ for (UserVmVO vm : visibleAssociatedVms) {
+ mergedIds.add(vm.getId());
+ }
+ return new Pair<>(mergedIds, count + visibleAssociatedVms.size());
+ }
+
@Override
public ListResponse<SecurityGroupResponse> searchForSecurityGroups(ListSecurityGroupsCmd cmd) {
Pair<List<SecurityGroupJoinVO>, Integer> result = searchForSecurityGroupsInternal(cmd);
diff --git a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java
index 7c2e356fb28..4f94f00864d 100644
--- a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java
+++ b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java
@@ -939,6 +939,7 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
refreshNicVlanMappingMetadata(nic);
for (NetworkVO network : newlyAssociatedNetworks) {
pushDhcpEntryForAssociation(vm, nic, network);
+ publishAssociationUsageEvent(vm, nic, network, EventTypes.EVENT_NETWORK_OFFERING_ASSIGN);
}
}
@@ -1006,14 +1007,14 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
vnfTemplateManager.validateVnfApplianceTrunkNic(template, nic.getDeviceId());
}
- Pair<List<NetworkVO>, List<NetworkVO>> networkSets = buildResultingNetworkSet(vm, nic, primaryNetwork, requestedNetworkIds);
+ Pair<List<NetworkVO>, List<NetworkVO>> networkSets = buildResultingNetworkSet(vm, nic, primaryNetwork, requestedNetworkIds, caller);
List<NetworkVO> resultingSet = networkSets.first();
List<NetworkVO> requestedNetworks = networkSets.second();
validateTrunkSet(primaryNetwork, resultingSet);
validateNoOverlappingCidrs(resultingSet);
implementRequestedNetworks(requestedNetworks, vm, caller);
- validateNoDuplicateVlanAfterImplement(resultingSet);
+ validateNoDuplicateVlanAfterImplement(resultingSet, requestedNetworks);
persistAssociations(nic, vm, requestedNetworks, ipAddressesMap);
return requestedNetworks;
}
@@ -1059,6 +1060,10 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
if (vmRunning) {
cleanupDhcpEntryForAssociation(vm, nic, targetNetworkId, association.getIp4Address(), association.getIp6Address());
+ NetworkVO targetNetwork = _networksDao.findById(targetNetworkId);
+ if (targetNetwork != null) {
+ publishAssociationUsageEvent(vm, nic, targetNetwork, EventTypes.EVENT_NETWORK_OFFERING_REMOVE);
+ }
updateLiveVlanTrunkMembership(vm, nic);
refreshNicVlanMappingMetadata(nic);
}
@@ -1148,6 +1153,14 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
sendDhcpEntryCommandForAssociation(vm, nic, network, association.getIp4Address(), association.getIp6Address(), false);
}
+ // A live associate/disassociate on a Running Instance has no nic-plug/VM-start of its own to piggyback
+ // billing on, so without this the association would go unbilled until the VM's next stop/start. Bills
+ // only the one network just (dis)associated, never isDefault (an association is never a nic's default).
+ private void publishAssociationUsageEvent(VirtualMachine vm, NicVO nic, NetworkVO network, String eventType) {
+ UsageEventUtils.publishNicNetworkOfferingUsageEvent(eventType, vm.getAccountId(), vm.getDataCenterId(), vm.getId(),
+ VirtualMachine.class.getName(), vm.getUuid(), nic.getId(), network, 0L, vm.isDisplay());
+ }
+
// Counterpart to pushDhcpEntryForAssociation, for a Running Instance's disassociate.
private void cleanupDhcpEntryForAssociation(VirtualMachine vm, NicVO nic, long networkId, String ip4Address, String ip6Address) throws ResourceUnavailableException {
NetworkVO network = _networksDao.findById(networkId);
@@ -1235,7 +1248,7 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
return vm;
}
- private Pair<List<NetworkVO>, List<NetworkVO>> buildResultingNetworkSet(VirtualMachine vm, NicVO nic, NetworkVO primaryNetwork, List<Long> requestedNetworkIds) {
+ private Pair<List<NetworkVO>, List<NetworkVO>> buildResultingNetworkSet(VirtualMachine vm, NicVO nic, NetworkVO primaryNetwork, List<Long> requestedNetworkIds, Account caller) {
List<NicNetworkMapVO> existingAssociations = _nicNetworkMapDao.listByNicId(nic.getId());
Set<Long> associatedNetworkIds = new HashSet<>();
associatedNetworkIds.add(primaryNetwork.getId());
@@ -1268,6 +1281,7 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
if (requestedNetwork == null) {
throw new InvalidParameterValueException(String.format("Unable to find network with id %d", requestedNetworkId));
}
+ _accountMgr.checkAccess(caller, null, true, requestedNetwork);
requestedNetworks.add(requestedNetwork);
resultingSet.add(requestedNetwork);
}
@@ -1329,9 +1343,9 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
String cidrB = networkCidrs.get(j).second();
if (NetUtils.isNetworksOverlap(cidrA, cidrB)) {
throw new InvalidParameterValueException(String.format(
- "Networks %s and %s have overlapping subnets (%s and %s) and cannot be associated with the same nic; " +
+ "Networks %s (%s) and %s (%s) have overlapping subnets (%s and %s) and cannot be associated with the same nic; " +
"a guest cannot unambiguously route between associated networks that share address space",
- networkA.getUuid(), networkB.getUuid(), cidrA, cidrB));
+ networkA.getName(), networkA.getUuid(), networkB.getName(), networkB.getUuid(), cidrA, cidrB));
}
}
}
@@ -1344,7 +1358,7 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
}
}
- private void validateNoDuplicateVlanAfterImplement(List<NetworkVO> resultingSet) {
+ private void validateNoDuplicateVlanAfterImplement(List<NetworkVO> resultingSet, List<NetworkVO> requestedNetworks) {
// re-fetch: implementNetworkIfNeeded may have just assigned a broadcastUri to one of the requested
// networks, and existing associations are re-read too rather than trusted stale
List<NetworkVO> implementedNetworks = new ArrayList<>();
@@ -1355,7 +1369,11 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
}
implementedNetworks.add(refreshed);
}
- validateNoDuplicateVlan(implementedNetworks);
+ Set<Long> justRequestedIds = new HashSet<>();
+ for (NetworkVO network : requestedNetworks) {
+ justRequestedIds.add(network.getId());
+ }
+ validateNoDuplicateVlan(implementedNetworks, justRequestedIds);
}
private void persistAssociations(NicVO nic, VirtualMachine vm, List<NetworkVO> requestedNetworks, Map<Long, Network.IpAddresses> requestedIps)
@@ -1422,12 +1440,21 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C
}
}
- private void validateNoDuplicateVlan(List<NetworkVO> resultingSet) {
+ private void validateNoDuplicateVlan(List<NetworkVO> resultingSet, Set<Long> justRequestedIds) {
Map<URI, NetworkVO> seenBroadcastUris = new HashMap<>();
for (NetworkVO network : resultingSet) {
URI broadcastUri = network.getBroadcastUri();
if (broadcastUri == null) {
- throw new CloudRuntimeException(String.format("Network %s has no VLAN assigned after implementation; this should not happen", network.getUuid()));
+ if (!justRequestedIds.contains(network.getId())) {
+ // this call never attempted to implement this network (it's the nic's existing primary,
+ // untouched here and only implemented later at the VM's actual start; or an association
+ // that already existed before this call) - whatever reason it currently lacks a VLAN is
+ // unrelated to this request. It isn't this method's job to flag that; a genuinely missing
+ // VLAN at actual nic-plug time already fails closed with its own clear error there.
+ continue;
+ }
+ throw new CloudRuntimeException(String.format("Network %s (%s) has no VLAN assigned after implementation; this should not happen",
+ network.getName(), network.getUuid()));
}
NetworkVO conflicting = seenBroadcastUris.put(broadcastUri, network);
if (conflicting != null) {
diff --git a/server/src/main/java/com/cloud/network/router/CommandSetupHelper.java b/server/src/main/java/com/cloud/network/router/CommandSetupHelper.java
index 2fb10b622b6..9875e33be36 100644
--- a/server/src/main/java/com/cloud/network/router/CommandSetupHelper.java
+++ b/server/src/main/java/com/cloud/network/router/CommandSetupHelper.java
@@ -841,6 +841,36 @@ public class CommandSetupHelper {
createDhcpEntryCommand(router, vm, nic, false, cmds);
}
}
+ createDhcpEntryCommandsForAssociatedNics(router, cmds, guestNetworkId, dc, dnsBasicZoneUpdates);
+ }
+
+ // A nic whose primary network is something else but is also associated with guestNetworkId (multi-VLAN
+ // trunk nic) is invisible to the primary-only lookup above, so a full VR rebuild would otherwise drop its
+ // entry - it would keep its current lease but fail to renew once this VR is the only one it can ask.
+ private void createDhcpEntryCommandsForAssociatedNics(final DomainRouterVO router, final Commands cmds, final long guestNetworkId,
+ final DataCenterVO dc, final String dnsBasicZoneUpdates) {
+ final NetworkVO network = _networkDao.findById(guestNetworkId);
+ if (network == null) {
+ return;
+ }
+ for (final NicNetworkMapVO association : _nicNetworkMapDao.listByNetworkId(guestNetworkId)) {
+ final NicVO nic = _nicDao.findById(association.getNicId());
+ if (nic == null) {
+ continue;
+ }
+ final UserVmVO vm = _userVmDao.findById(nic.getInstanceId());
+ if (vm == null || !(vm.getState() == VirtualMachine.State.Running || vm.getState() == VirtualMachine.State.Migrating
+ || vm.getState() == VirtualMachine.State.Stopping)) {
+ continue;
+ }
+ if (dc.getNetworkType() == NetworkType.Basic && router.getPodIdToDeployIn().longValue() != vm.getPodIdToDeployIn().longValue()
+ && dnsBasicZoneUpdates.equalsIgnoreCase("pod")) {
+ continue;
+ }
+ logger.debug("Creating dhcp entry for vm " + vm + "'s association with network " + guestNetworkId + " on domR " + router + ".");
+ createDhcpEntryCommand(router, vm, nic.getMacAddress(), association.getIp4Address(), association.getIp6Address(),
+ network.getGateway(), network.getIp6Gateway(), guestNetworkId, false, false, cmds);
+ }
}
public void createDeleteIpAliasCommand(final DomainRouterVO router, final List<IpAliasTO> deleteIpAliasTOs, final List<IpAliasTO> createIpAliasTos, final long networkId,
diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java
index 7412d2e5fa9..282cd082e97 100644
--- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java
+++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java
@@ -1766,6 +1766,20 @@ public class ManagementServerImpl extends MutualExclusiveIdsManagerBase implemen
return new Ternary<>(otherHosts, suitableHosts, requiresStorageMotion);
}
+ @Override
+ public String getMultiNetworkNicUnsuitableReason(final VirtualMachine vm, final Host host) {
+ boolean vmHasMultiNetworkNic = nicDao.listByVmId(vm.getId()).stream().anyMatch(NicVO::getMultiNetwork);
+ if (!vmHasMultiNetworkNic) {
+ return null;
+ }
+ final DetailVO detail = _detailsDao.findDetail(host.getId(), Host.HOST_VLAN_FILTERING_ENABLED);
+ final boolean vlanFilteringEnabled = detail != null && Boolean.parseBoolean(detail.getValue());
+ if (!vlanFilteringEnabled) {
+ return "Instance has a multi-VLAN trunk NIC, but this host does not have VLAN filtering enabled";
+ }
+ return null;
+ }
+
private void validateVgpuProfileForVmMigration(final VirtualMachineProfile vmProfile) {
// Validate if the VM is using a vGPU profile that supports migration.
ServiceOffering serviceOffering = vmProfile.getServiceOffering();
diff --git a/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java b/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java
index 008ce3f4dc5..ba1f9043f15 100644
--- a/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java
+++ b/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java
@@ -92,6 +92,7 @@ import com.cloud.domain.dao.DomainDao;
import com.cloud.exception.InsufficientAddressCapacityException;
import com.cloud.exception.InsufficientCapacityException;
import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.exception.PermissionDeniedException;
import com.cloud.exception.ResourceAllocationException;
import com.cloud.host.DetailVO;
import com.cloud.host.Host;
@@ -1750,6 +1751,43 @@ public class NetworkServiceImplTest {
Assert.assertNotNull(result);
}
+ @Test
+ public void associateNetworkToNicChecksCallerAccessToRequestedNetwork() throws Exception {
+ NicVO nic = mockNic(11L, 5L, 205L, false);
+ Mockito.when(nicDao.findById(11L)).thenReturn(nic);
+ UserVmVO vm = mockVm(VirtualMachine.State.Stopped, null);
+ Mockito.when(userVmDao.findById(5L)).thenReturn(vm);
+ NetworkVO primaryNetwork = mockNetwork(205L, Network.GuestType.Isolated, 1L, 1L, null, Networks.BroadcastDomainType.Vlan, vlanUri(1174));
+ Mockito.when(primaryNetwork.getCidr()).thenReturn("10.1.1.0/24");
+ Mockito.when(networkDao.findById(205L)).thenReturn(primaryNetwork);
+ Mockito.when(nicNetworkMapDao.listByNicId(11L)).thenReturn(new ArrayList<>());
+ NetworkVO requestedNetwork = mockNetwork(206L, Network.GuestType.Isolated, 1L, 1L, null, Networks.BroadcastDomainType.Vlan, vlanUri(1180));
+ Mockito.when(requestedNetwork.getCidr()).thenReturn("10.1.2.0/24");
+ Mockito.when(networkDao.findById(206L)).thenReturn(requestedNetwork);
+ Mockito.when(ipAddressManagerMock.allocateGuestIP(requestedNetwork, null)).thenReturn("10.1.2.50");
+
+ service.associateNetworkToNic(mockAssociateCmd(11L, Arrays.asList(206L)));
+
+ Mockito.verify(accountManager).checkAccess(accountMock, null, true, requestedNetwork);
+ }
+
+ @Test(expected = PermissionDeniedException.class)
+ public void associateNetworkToNicFailsWhenCallerLacksAccessToRequestedNetwork() throws Exception {
+ NicVO nic = mockNic(11L, 5L, 205L, false);
+ Mockito.when(nicDao.findById(11L)).thenReturn(nic);
+ UserVmVO vm = mockVm(VirtualMachine.State.Stopped, null);
+ Mockito.when(userVmDao.findById(5L)).thenReturn(vm);
+ NetworkVO primaryNetwork = mockNetwork(205L, Network.GuestType.Isolated, 1L, 1L, null, Networks.BroadcastDomainType.Vlan, vlanUri(1174));
+ Mockito.when(networkDao.findById(205L)).thenReturn(primaryNetwork);
+ Mockito.when(nicNetworkMapDao.listByNicId(11L)).thenReturn(new ArrayList<>());
+ NetworkVO requestedNetwork = mockNetwork(206L, Network.GuestType.Isolated, 1L, 1L, null, Networks.BroadcastDomainType.Vlan, vlanUri(1180));
+ Mockito.when(networkDao.findById(206L)).thenReturn(requestedNetwork);
+ Mockito.doThrow(new PermissionDeniedException("no access to network 206"))
+ .when(accountManager).checkAccess(accountMock, null, true, requestedNetwork);
+
+ service.associateNetworkToNic(mockAssociateCmd(11L, Arrays.asList(206L)));
+ }
+
@Test(expected = InvalidParameterValueException.class)
public void associateNetworkToNicFailsWhenRequestedNetworkIsOnADifferentPhysicalNetwork() throws Exception {
NicVO nic = mockNic(11L, 5L, 205L, false);
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 99bf2cf7aef..b4470132c47 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -443,9 +443,11 @@
"label.assign.instance.another": "Assign Instance to another Account",
"label.assign.vms": "Assign Instances",
"label.assigning.vms": "Assigning Instances",
+"label.associate.network": "Associate Network",
"label.associatednetwork": "Associated Network",
"label.associatednetworkid": "Associated Network ID",
"label.associatednetworkname": "Network name",
+"label.associatednetworks": "Associated Networks (trunk)",
"label.asyncbackup": "Async backup",
"label.attach.vol.to.instance": "Attach the created Volume to an existing Instance",
"label.attaching": "Attaching",
@@ -937,6 +939,7 @@
"label.disable.vpn": "Disable remote access VPN",
"label.disable.webhook": "Disable Webhook",
"label.disabled": "Disabled",
+"label.disassociate.network": "Disassociate Network",
"label.disconnected": "Last disconnected",
"label.discover.gpu.devices": "Discover GPU devices",
"label.disk": "Disk",
@@ -1317,6 +1320,7 @@
"label.host.vddk.support": "VDDK Support",
"label.host.vddk.version": "VDDK Version",
"label.host.tag": "Host tag",
+"label.host.vlanfilteringenabled": "VLAN filtering",
"label.host.virtv2v.version": "Virt-v2v Version",
"label.hostcontrolstate": "Compute Resource Status",
"label.hostid": "Host",
@@ -1767,6 +1771,8 @@
"label.move.to.bottom": "Move to bottom",
"label.move.to.top": "Move to top",
"label.move.up.row": "Move up one row",
+"label.multinetworknic.hosts.ready": "Multi-VLAN trunk NIC ready hosts",
+"label.multinetworknic.hosts.ready.count": "{ready} of {total} hosts ready",
"label.my.isos": "My ISOs",
"label.my.templates": "My Templates",
"label.na": "N/A",
@@ -1794,6 +1800,7 @@
"label.network": "Network",
"label.network.acl": "Network ACL",
"label.network.acls": "Network ACLs",
+"label.network.add.associated": "Add Associated Networks",
"label.network.addvm": "Add Network to Instance",
"label.network.desc": "Network desc",
"label.network.domain": "Network Domain",
@@ -1812,6 +1819,8 @@
"label.networkdevicetype": "Type",
"label.networkdomain": "Network domain",
"label.networkid": "Network",
+"label.nic.associatednetworks": "Associated Networks",
+"label.nic.trunked": "Trunked",
"label.networkkbsread": "Network read",
"label.networkkbswrite": "Network write",
"label.networklimit": "Network limits",
@@ -2895,8 +2904,10 @@
"label.vlan": "VLAN/VNI",
"label.vlan.range": "VLAN/VNI range",
"label.vlan.vni.ranges": "VLAN/VNI range(s)",
+"label.vlanfilteringenabled": "VLAN filtering enabled",
"label.vlanid": "VLAN/VNI ID",
"label.vlanrange": "VLAN/VNI range",
+"label.vlantrunkxmlsupported": "VLAN trunk XML supported",
"label.vm": "Instance",
"label.vm.add": "Add Instance",
"label.vm.password": "Password of the Instance is",
@@ -3321,6 +3332,9 @@
"message.assign.instance.another": "Please specify the Account type, domain, Account name and Network (optional) of the new Account. <br> If the default NIC of the Instance is on a shared Network, CloudStack will check if the Network can be used by the new Account if you do not specify one Network. <br> If the default NIC of the Instance is on a isolated Network, and the new Account has more one isolated Networks, you should specify one.",
"message.assign.vm.failed": "Failed to assign Instance",
"message.assign.vm.processing": "Assigning Instance...",
+"message.associate.network.desc": "Select one or more additional Networks to associate with this NIC, delivering them as a multi-VLAN trunk. Requires VLAN-filtering support on the host(s) the Instance may run on.",
+"message.associate.network.loop.warning": "Instances attached to this NIC will receive multiple L2 segments. Misconfiguration inside guest VMs may cause L2 loops. Ensure proper VLAN handling.",
+"message.associate.network.processing": "Associating Network(s)...",
"message.attach.volume": "Please fill in the following data to attach a new volume. If you are attaching a disk volume to a Windows based Instance, you will need to reboot the Instance to see the attached disk.",
"message.attach.volume.failed": "Failed to attach volume.",
"message.attach.volume.progress": "Attaching volume",
@@ -3560,6 +3574,8 @@
"message.disable.vpn.failed": "Failed to disable VPN.",
"message.disable.vpn.processing": "Disabling VPN...",
"message.disable.webhook.ssl.verification": "Disabling SSL verification is not recommended",
+"message.disassociate.network.confirm": "Please confirm you want to remove this Network association from the NIC.",
+"message.disassociate.network.processing": "Disassociating Network...",
"message.discovering.feature": "Discovering features, please wait...",
"message.disk.offering.created": "Disk offering created:",
"message.success.clone.backup.offering": "Successfully cloned backup offering",
@@ -3593,6 +3609,8 @@
"message.error.access.key": "Please enter access key.",
"message.error.account.delete.name.mismatch": "Name entered doesn't match the account name.",
"message.error.add.guest.network": "Either IPv4 fields or IPv6 fields need to be filled when adding a guest Network.",
+"message.error.associate.network": "Failed to associate Network(s) with NIC",
+"message.error.disassociate.network": "Failed to disassociate Network from NIC",
"message.error.add.interface.static.route": "Adding interface Static Route failed",
"message.error.add.logical.router": "Adding Logical Router failed",
"message.error.add.network.static.route": "Adding Network Static Route failed",
@@ -3772,6 +3790,7 @@
"message.host.dedicated": "Host Dedicated",
"message.host.dedication.released": "Host dedication released.",
"message.host.external.datadisk": "Usage of data disks for the selected template is not applicable",
+"message.host.vlanfilteringenabled.tooltip": "VLAN filtering is enabled on this host's guest bridge, so it can accept multi-VLAN trunk NICs",
"message.import.running.instance.warning": "The selected VM is powered-on on the VMware Datacenter. The recommended state to convert a VMware VM into KVM is powered-off after a graceful shutdown of the guest OS.",
"message.import.vm.tasks": "Import from VMware to KVM tasks",
"message.import.volume": "Please specify the domain, account or project name. <br>If not set, the volume will be imported for the caller.",
@@ -3898,6 +3917,7 @@
"message.network.restart.required": "Restart is required for network(s). Click here to view network(s) which require restart.",
"message.network.secondaryip": "Please confirm that you would like to acquire a new secondary IP for this NIC. \n NOTE: You need to manually configure the newly-acquired secondary IP inside the virtual machine.",
"message.network.selection": "Choose one or more Networks to attach the Instance to.",
+"message.nic.trunked.tooltip": "This NIC is a multi-VLAN trunk: in addition to its primary Network, it carries traffic for the associated Networks listed below.",
"message.network.selection.new.network": "A new Network can also be created here.",
"message.network.updateip": "Please confirm that you would like to change the IP address for this NIC on the Instance.",
"message.network.update.nic": "Please confirm that you would like to update this NIC.",
@@ -4065,6 +4085,8 @@
"message.success.add.kuberversion": "Successfully added Kubernetes version",
"message.success.add.logical.router": "Successfully added Logical Router",
"message.success.add.network": "Successfully added Network",
+"message.success.associate.network": "Successfully associated Network(s) with NIC",
+"message.success.disassociate.network": "Successfully disassociated Network from NIC",
"message.success.add.network.acl": "Successfully added Network ACL",
"message.success.add.network.static.route": "Successfully added Network Static Route",
"message.success.add.network.permissions": "Successfully added Network permissions",
diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue
index 9a7d874fef3..cc3a98de37a 100644
--- a/ui/src/components/view/ListView.vue
+++ b/ui/src/components/view/ListView.vue
@@ -178,6 +178,12 @@
<warning-outlined :style="{ color: $config.theme['@warning-color'] }" />
</a-tooltip>
</span>
+ <span v-else-if="$route.path.startsWith('/host') && record.vlanfilteringenabled && multiNetworkNicEnabledZones[record.zoneid]">
+
+ <a-tooltip :title="$t('message.host.vlanfilteringenabled.tooltip')">
+ <a-tag color="blue">{{ $t('label.host.vlanfilteringenabled') }}</a-tag>
+ </a-tooltip>
+ </span>
</span>
<span
v-if="record.leaseduration !== undefined"
@@ -1082,6 +1088,7 @@ import cronstrue from 'cronstrue/i18n'
import moment from 'moment-timezone'
import { timeZoneName } from '@/utils/timezone'
import { FileTextOutlined } from '@ant-design/icons-vue'
+import { multiNetworkNicEnabledZones, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic'
export default {
name: 'ListView',
@@ -1182,7 +1189,8 @@ export default {
},
usageTypeMap: {},
resourceIdToValidLinksMap: {},
- listDayOfWeek: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
+ listDayOfWeek: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
+ multiNetworkNicEnabledZones: multiNetworkNicEnabledZones
}
},
watch: {
@@ -1192,6 +1200,9 @@ export default {
if (newData === oldData) return
this.items.forEach(record => {
this.resourceIdToValidLinksMap[record.id] = validateLinks(this.$router, false, record)
+ if (this.$route.path.startsWith('/host') && record.vlanfilteringenabled && record.zoneid) {
+ fetchMultiNetworkNicEnabledForZone(record.zoneid)
+ }
})
}
}
diff --git a/ui/src/config/section/infra/hosts.js b/ui/src/config/section/infra/hosts.js
index 046f120dd37..74727d7f8ae 100644
--- a/ui/src/config/section/infra/hosts.js
+++ b/ui/src/config/section/infra/hosts.js
@@ -45,7 +45,7 @@ export default {
fields.push('managementservername')
return fields
},
- details: ['name', 'id', 'resourcestate', 'ipaddress', 'hypervisor', 'externalprovisioner', 'arch', 'type', 'clustername', 'podname', 'zonename', 'lastpinged', 'storageaccessgroups', 'clusterstorageaccessgroups', 'podstorageaccessgroups', 'zonestorageaccessgroups', 'managementservername', 'disconnected', 'created', 'externaldetails'],
+ details: ['name', 'id', 'resourcestate', 'ipaddress', 'hypervisor', 'externalprovisioner', 'arch', 'type', 'clustername', 'podname', 'zonename', 'vlanfilteringenabled', 'vlantrunkxmlsupported', 'lastpinged', 'storageaccessgroups', 'clusterstorageaccessgroups', 'podstorageaccessgroups', 'zonestorageaccessgroups', 'managementservername', 'disconnected', 'created', 'externaldetails'],
tabs: [{
name: 'details',
component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue')))
diff --git a/ui/src/utils/multiNetworkNic.js b/ui/src/utils/multiNetworkNic.js
new file mode 100644
index 00000000000..7997ac30711
--- /dev/null
+++ b/ui/src/utils/multiNetworkNic.js
@@ -0,0 +1,89 @@
+// 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.
+
+// Shared, module-level cache of the effective (zone-override-or-global) value of the
+// multi.network.nic.enabled ConfigKey, keyed by zoneid. Reused by any view that needs to
+// gate multi-VLAN trunk NIC UI on whether the feature is actually usable in a given zone
+// (e.g. the host list's VLAN-filtering badge, the deploy wizard's associate-network control).
+import { reactive } from 'vue'
+import { getAPI } from '@/api'
+
+export const multiNetworkNicEnabledZones = reactive({})
+
+// in-flight requests only, keyed by zoneid - dedupes simultaneous callers (e.g. many host rows
+// in the same zone on one page) without skipping a fresh re-check on the next page visit
+const pendingRequests = {}
+
+export function isMultiNetworkNicEnabledForZone (zoneid) {
+ return !!multiNetworkNicEnabledZones[zoneid]
+}
+
+export function fetchMultiNetworkNicEnabledForZone (zoneid) {
+ if (!zoneid || pendingRequests[zoneid]) {
+ return
+ }
+ pendingRequests[zoneid] = getAPI('listConfigurations', { name: 'multi.network.nic.enabled', zoneid }).then(json => {
+ const config = json?.listconfigurationsresponse?.configuration?.[0]
+ multiNetworkNicEnabledZones[zoneid] = !!(config && config.value === 'true')
+ }).catch(() => {}).finally(() => {
+ delete pendingRequests[zoneid]
+ })
+}
+
+// Flattens a nic's selectable IP targets for rule-creation pickers (Static NAT / Port Forwarding / Load
+// Balancing), distinguishing three kinds that are otherwise easy to conflate in the UI:
+// - primary: the nic's own network (nic.networkid / nic.ipaddress)
+// - secondary: another IP on that SAME network (nic.secondaryip[], pre-existing nic_secondary_ips feature)
+// - associated: an IP on a DIFFERENT network this nic trunks to (nic.associatednetworks[], nic_network_map)
+// Each option carries the networkid a rule-creation call (enableStaticNat/createPortForwardingRule/
+// assignToLoadBalancerRule) must submit alongside its ip - an associated option's networkid differs from
+// the nic's own, which is the one real behavioral difference from a secondary IP.
+export function buildNicIpOptions (nic) {
+ const options = []
+ if (nic.ipaddress) {
+ options.push({ kind: 'primary', ip: nic.ipaddress, networkid: nic.networkid, networkname: nic.networkname, nicid: nic.id })
+ }
+ for (const secondary of (nic.secondaryip || [])) {
+ options.push({ kind: 'secondary', ip: secondary.ipaddress, networkid: nic.networkid, networkname: nic.networkname, nicid: nic.id })
+ }
+ for (const association of (nic.associatednetworks || [])) {
+ options.push({ kind: 'associated', ip: association.ipaddress, networkid: association.networkid, networkname: association.networkname, nicid: nic.id })
+ }
+ return options
+}
+
+// Groups buildNicIpOptions() output into labeled sections for a picker (e.g. <a-select-opt-group>),
+// in a fixed, meaningful order - primary first, then secondary, then associated - omitting any section
+// that has nothing to show (the common legacy case has only a primary section).
+export function groupNicIpOptionsByKind (options, translate) {
+ return [
+ { kind: 'primary', label: translate('label.primary') },
+ { kind: 'secondary', label: translate('label.secondaryips') },
+ { kind: 'associated', label: translate('label.associatednetworks') }
+ ]
+ .map(({ kind, label }) => ({ kind, label, items: options.filter(item => item.kind === kind) }))
+ .filter(group => group.items.length > 0)
+}
+
+// Resolves the networkid a rule-creation call (enableStaticNat/createPortForwardingRule/
+// assignToLoadBalancerRule) must submit for a chosen ip, falling back to fallbackNetworkId when ip
+// doesn't match any option (e.g. options haven't loaded yet) - keeps existing callers' behavior for
+// every pre-existing case, only changing the outcome for an associated-network selection.
+export function resolveNetworkIdForIp (options, ip, fallbackNetworkId) {
+ const match = (options || []).find(item => item.ip === ip)
+ return match ? match.networkid : fallbackNetworkId
+}
diff --git a/ui/src/views/compute/DeployVM.vue b/ui/src/views/compute/DeployVM.vue
index 4fa5c14d86e..cd4c55cb891 100644
--- a/ui/src/views/compute/DeployVM.vue
+++ b/ui/src/views/compute/DeployVM.vue
@@ -448,9 +448,12 @@
v-if="networks.length > 0"
:items="networks"
:preFillContent="dataPreFill"
+ :canTrunkNics="canTrunkNics"
+ :zoneId="zoneId"
@update-network-config="($event) => updateNetworkConfig($event)"
@handler-error="($event) => hasError = $event"
@select-default-network-item="($event) => updateDefaultNetworks($event)"
+ @update-trunk-groups="($event) => updateTrunkGroups($event)"
></network-configuration>
</div>
</div>
@@ -1159,6 +1162,7 @@ export default {
selectedZone: '',
formModel: {},
nicToNetworkSelection: [],
+ trunkGroups: {},
selectedArchitecture: null,
isLeaseFeatureEnabled: this.$store.getters.features.instanceleaseenabled,
showLeaseOptions: false,
@@ -1185,6 +1189,9 @@ export default {
rootDiskSize () {
return this.showRootDiskSizeChanger && this.rootDiskSizeFixed > 0
},
+ canTrunkNics () {
+ return this.hypervisor === 'KVM' && ('associateNetworkToNic' in this.$store.getters.apis)
+ },
isNormalAndDomainUser () {
return ['DomainAdmin', 'User'].includes(this.$store.getters.userInfo.roletype)
},
@@ -2240,6 +2247,9 @@ export default {
updateNetworkConfig (networks) {
this.networkConfig = networks
},
+ updateTrunkGroups (trunkGroups) {
+ this.trunkGroups = trunkGroups
+ },
updateSshKeyPairs (names) {
this.form.keypairs = names
this.sshKeyPairs = names.map((sshKeyPair) => { return sshKeyPair.name })
@@ -2524,9 +2534,48 @@ export default {
deployVmData['nicnetworklist[' + j + '].network'] = nicNetwork.network
}
} else {
- const arrNetwork = []
networkIds = values.networkids
- if (networkIds.length > 0) {
+ if (!networkIds || networkIds.length === 0) {
+ this.$notification.error({
+ message: this.$t('message.request.failed'),
+ description: this.$t('message.step.4.continue')
+ })
+ this.loading.deploy = false
+ return
+ }
+ const groupedAwayNetworkIds = Object.values(this.trunkGroups || {}).flat()
+ const hasTrunkGroups = groupedAwayNetworkIds.length > 0
+ if (hasTrunkGroups) {
+ // one or more nics group several networks onto a single nic (multi-VLAN trunk) - mutually
+ // exclusive with iptonetworklist at the API level, so every nic (grouped or not) goes
+ // through nicnetworkslist here instead.
+ const ownNicNetworkIds = networkIds.filter(id => !groupedAwayNetworkIds.includes(id))
+ const orderedNicNetworkIds = []
+ for (const id of ownNicNetworkIds) {
+ if (id === this.defaultnetworkid) {
+ orderedNicNetworkIds.unshift(id)
+ } else {
+ orderedNicNetworkIds.push(id)
+ }
+ }
+ for (let j = 0; j < orderedNicNetworkIds.length; j++) {
+ const primaryNetworkId = orderedNicNetworkIds[j]
+ const groupNetworkIds = [primaryNetworkId, ...(this.trunkGroups[primaryNetworkId] || [])]
+ deployVmData['nicnetworkslist[' + j + '].networkids'] = groupNetworkIds.join(',')
+ if (this.networkConfig.length > 0) {
+ const networkConfig = this.networkConfig.filter((item) => item.key === primaryNetworkId)
+ if (networkConfig && networkConfig.length > 0) {
+ if (networkConfig[0].ipAddress) {
+ deployVmData['nicnetworkslist[' + j + '].ip4address'] = networkConfig[0].ipAddress
+ }
+ if (networkConfig[0].macAddress) {
+ deployVmData['nicnetworkslist[' + j + '].macaddress'] = networkConfig[0].macAddress
+ }
+ }
+ }
+ }
+ } else {
+ const arrNetwork = []
for (let i = 0; i < networkIds.length; i++) {
if (networkIds[i] === this.defaultnetworkid) {
const ipToNetwork = {
@@ -2540,21 +2589,14 @@ export default {
arrNetwork.push(ipToNetwork)
}
}
- } else {
- this.$notification.error({
- message: this.$t('message.request.failed'),
- description: this.$t('message.step.4.continue')
- })
- this.loading.deploy = false
- return
- }
- for (let j = 0; j < arrNetwork.length; j++) {
- deployVmData['iptonetworklist[' + j + '].networkid'] = arrNetwork[j].networkid
- if (this.networkConfig.length > 0) {
- const networkConfig = this.networkConfig.filter((item) => item.key === arrNetwork[j].networkid)
- if (networkConfig && networkConfig.length > 0) {
- deployVmData['iptonetworklist[' + j + '].ip'] = networkConfig[0].ipAddress ? networkConfig[0].ipAddress : undefined
- deployVmData['iptonetworklist[' + j + '].mac'] = networkConfig[0].macAddress ? networkConfig[0].macAddress : undefined
+ for (let j = 0; j < arrNetwork.length; j++) {
+ deployVmData['iptonetworklist[' + j + '].networkid'] = arrNetwork[j].networkid
+ if (this.networkConfig.length > 0) {
+ const networkConfig = this.networkConfig.filter((item) => item.key === arrNetwork[j].networkid)
+ if (networkConfig && networkConfig.length > 0) {
+ deployVmData['iptonetworklist[' + j + '].ip'] = networkConfig[0].ipAddress ? networkConfig[0].ipAddress : undefined
+ deployVmData['iptonetworklist[' + j + '].mac'] = networkConfig[0].macAddress ? networkConfig[0].macAddress : undefined
+ }
}
}
}
diff --git a/ui/src/views/compute/DeployVnfAppliance.vue b/ui/src/views/compute/DeployVnfAppliance.vue
index 05f804ef4ea..1cd92d46734 100644
--- a/ui/src/views/compute/DeployVnfAppliance.vue
+++ b/ui/src/views/compute/DeployVnfAppliance.vue
@@ -362,7 +362,10 @@
:items="templateVnfNics"
:templateNics="templateNics"
:networks="networks"
- @update-vnf-nic-networks="($event) => updateVnfNicNetworks($event)" />
+ :canTrunkNics="canTrunkNics"
+ :zoneId="zoneId"
+ @update-vnf-nic-networks="($event) => updateVnfNicNetworks($event)"
+ @update-vnf-trunk-groups="($event) => updateVnfTrunkGroups($event)" />
</div>
<div style="margin-top: 15px" v-if="showVnfConfigureManagement">
<a-form-item name="vnfconfiguremanagement" ref="vnfconfiguremanagement">
@@ -985,6 +988,7 @@ export default {
templateProperties: {},
templateVnfNics: [],
vnfNicNetworks: {},
+ vnfTrunkGroups: {},
selectedTemplateConfiguration: {},
iso: {},
hypervisor: '',
@@ -1055,6 +1059,9 @@ export default {
rootDiskSize () {
return this.showRootDiskSizeChanger && this.rootDiskSizeFixed > 0
},
+ canTrunkNics () {
+ return this.hypervisor === 'KVM' && ('associateNetworkToNic' in this.$store.getters.apis)
+ },
isNormalAndDomainUser () {
return ['DomainAdmin', 'User'].includes(this.$store.getters.userInfo.roletype)
},
@@ -1944,6 +1951,9 @@ export default {
updateVnfNicNetworks (vnfNicNetworks) {
this.vnfNicNetworks = vnfNicNetworks || {}
},
+ updateVnfTrunkGroups (vnfTrunkGroups) {
+ this.vnfTrunkGroups = vnfTrunkGroups || {}
+ },
updateSshKeyPairs (names) {
this.form.keypairs = names
this.sshKeyPairs = names.map((sshKeyPair) => { return sshKeyPair.name })
@@ -2264,9 +2274,32 @@ export default {
networkIds = values.networkids
if (networkIds.length > 0) {
if (this.templateVnfNics && this.templateVnfNics.length > 0) {
+ const hasVnfTrunkGroups = this.vnfTrunkGroups && Object.values(this.vnfTrunkGroups).some(ids => ids && ids.length > 0)
+ let k = 0
for (const templateVnfNic of this.templateVnfNics) {
const vnfNicNetworkId = this.vnfNicNetworks[String(templateVnfNic.deviceid)]?.id || null
- if (vnfNicNetworkId) {
+ if (!vnfNicNetworkId) {
+ continue
+ }
+ if (hasVnfTrunkGroups) {
+ // one or more VNF nics group several networks onto a single nic (multi-VLAN trunk) -
+ // mutually exclusive with iptonetworklist at the API level, so every nic (grouped or
+ // not) goes through nicnetworkslist here instead.
+ const assocIds = this.vnfTrunkGroups[String(templateVnfNic.deviceid)] || []
+ createVnfAppData['nicnetworkslist[' + k + '].networkids'] = [vnfNicNetworkId, ...assocIds].join(',')
+ if (this.networkConfig.length > 0) {
+ const networkConfig = this.networkConfig.filter((item) => item.key === vnfNicNetworkId)
+ if (networkConfig && networkConfig.length > 0) {
+ if (networkConfig[0].ipAddress) {
+ createVnfAppData['nicnetworkslist[' + k + '].ip4address'] = networkConfig[0].ipAddress
+ }
+ if (networkConfig[0].macAddress) {
+ createVnfAppData['nicnetworkslist[' + k + '].macaddress'] = networkConfig[0].macAddress
+ }
+ }
+ }
+ k++
+ } else {
const ipToNetwork = {
networkid: vnfNicNetworkId
}
diff --git a/ui/src/views/compute/MigrateWizard.vue b/ui/src/views/compute/MigrateWizard.vue
index 0e8c08d320f..b24c4461834 100644
--- a/ui/src/views/compute/MigrateWizard.vue
+++ b/ui/src/views/compute/MigrateWizard.vue
@@ -49,6 +49,11 @@
class="host-item__suitability-icon"
twoToneColor="#52c41a"
v-if="record.suitableformigration" />
+ <a-tooltip v-else-if="record.unsuitablereason" :title="record.unsuitablereason" placement="top">
+ <close-circle-two-tone
+ class="host-item__suitability-icon"
+ twoToneColor="#f5222d" />
+ </a-tooltip>
<close-circle-two-tone
class="host-item__suitability-icon"
twoToneColor="#f5222d"
diff --git a/ui/src/views/compute/wizard/NetworkConfiguration.vue b/ui/src/views/compute/wizard/NetworkConfiguration.vue
index f3192d278bd..c5649586a5c 100644
--- a/ui/src/views/compute/wizard/NetworkConfiguration.vue
+++ b/ui/src/views/compute/wizard/NetworkConfiguration.vue
@@ -28,7 +28,7 @@
:rules="rules">
<a-table
:columns="columns"
- :dataSource="dataItems"
+ :dataSource="visibleDataItems"
:pagination="false"
:rowSelection="rowSelection"
:customRow="onClickRow"
@@ -39,6 +39,22 @@
<template v-if="column.key === 'name'">
<div>{{ text }}</div>
<small v-if="record.type!=='L2'">{{ $t('label.cidr') + ': ' + record.cidr }}</small>
+ <div v-if="trunkGroups[record.id] && trunkGroups[record.id].length > 0" style="margin-top: 4px">
+ <a-tag v-for="assocId in trunkGroups[record.id]" :key="assocId" closable @close.prevent.stop="removeFromGroup(record.id, assocId)">
+ {{ networkNameById(assocId) }}
+ </a-tag>
+ </div>
+ </template>
+ <template v-if="column.key === 'associate'">
+ <span @click.stop>
+ <tooltip-button
+ tooltipPlacement="top"
+ :tooltip="$t('label.network.add.associated')"
+ icon="plus-outlined"
+ size="small"
+ type="primary"
+ @onClick="openAssociateModal(record)" />
+ </span>
</template>
<template v-if="!this.autoscale">
<template v-if="column.key === 'ipAddress'">
@@ -86,12 +102,52 @@
</a-button>
</div>
</a-form>
+
+ <a-modal
+ :visible="showAssociateModal"
+ :title="$t('label.associate.network')"
+ :maskClosable="false"
+ :closable="true"
+ :footer="null"
+ @cancel="closeAssociateModal">
+ {{ $t('message.associate.network.desc') }}
+ <a-alert
+ type="warning"
+ show-icon
+ style="margin-top: 10px; margin-bottom: 10px"
+ :message="$t('message.associate.network.loop.warning')" />
+ <a-select
+ mode="multiple"
+ style="width: 100%"
+ :loading="associateModalLoading"
+ :placeholder="$t('label.networks')"
+ v-model:value="associateSelection"
+ optionFilterProp="label">
+ <a-select-option
+ v-for="network in associateModalNetworks"
+ :key="network.id"
+ :value="network.id"
+ :label="network.name">
+ {{ network.name }}
+ </a-select-option>
+ </a-select>
+ <div style="margin-top: 20px; text-align: right;">
+ <a-button @click="closeAssociateModal">{{ $t('label.cancel') }}</a-button>
+ <a-button type="primary" style="margin-left: 8px" @click="confirmAssociate">{{ $t('label.ok') }}</a-button>
+ </div>
+ </a-modal>
</template>
<script>
import { ref, reactive } from 'vue'
+import { getAPI } from '@/api'
+import TooltipButton from '@/components/widgets/TooltipButton'
+import { isMultiNetworkNicEnabledForZone, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic'
export default {
name: 'NetworkConfiguration',
+ components: {
+ TooltipButton
+ },
props: {
items: {
type: Array,
@@ -112,33 +168,28 @@ export default {
preFillContent: {
type: Object,
default: () => {}
+ },
+ canTrunkNics: {
+ type: Boolean,
+ default: false
+ },
+ zoneId: {
+ type: String,
+ default: ''
}
},
data () {
return {
networks: [],
- columns: [
- {
- key: 'name',
- dataIndex: 'name',
- title: this.$t('label.network'),
- width: '30%'
- },
- {
- key: 'ipAddress',
- dataIndex: 'ip',
- title: this.$t('label.ip'),
- width: '30%'
- },
- {
- key: 'macAddress',
- dataIndex: 'mac',
- title: this.$t('label.macaddress'),
- width: '30%'
- }
- ],
selectedRowKeys: [],
dataItems: [],
+ trunkGroups: {},
+ showAssociateModal: false,
+ associateTarget: null,
+ associateSelection: [],
+ associateModalNetworks: [],
+ associateModalLoading: false,
+ associatedNetworkNames: {},
macRegex: /^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$/i,
ipV4Regex: /^(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)$/i
}
@@ -153,6 +204,7 @@ export default {
this.selectedRowKeys = [this.dataItems[0].id]
this.$emit('select-default-network-item', this.dataItems[0].id)
}
+ fetchMultiNetworkNicEnabledForZone(this.zoneId)
},
computed: {
rowSelection () {
@@ -164,6 +216,46 @@ export default {
selectedRowKeys: this.selectedRowKeys,
onChange: this.onSelectRow
}
+ },
+ showTrunkNicControls () {
+ return this.canTrunkNics && isMultiNetworkNicEnabledForZone(this.zoneId)
+ },
+ columns () {
+ const nameAndFieldWidth = this.showTrunkNicControls ? '25%' : '30%'
+ const cols = [
+ {
+ key: 'name',
+ dataIndex: 'name',
+ title: this.$t('label.network'),
+ width: nameAndFieldWidth
+ },
+ {
+ key: 'ipAddress',
+ dataIndex: 'ip',
+ title: this.$t('label.ip'),
+ width: nameAndFieldWidth
+ },
+ {
+ key: 'macAddress',
+ dataIndex: 'mac',
+ title: this.$t('label.macaddress'),
+ width: nameAndFieldWidth
+ }
+ ]
+ if (this.showTrunkNicControls) {
+ cols.push({
+ key: 'associate',
+ title: '',
+ width: '25%'
+ })
+ }
+ return cols
+ },
+ groupedAwayNetworkIds () {
+ return Object.values(this.trunkGroups).flat()
+ },
+ visibleDataItems () {
+ return this.dataItems.filter(item => !this.groupedAwayNetworkIds.includes(item.id))
}
},
watch: {
@@ -172,23 +264,109 @@ export default {
this.selectedRowKeys = [newValue]
}
},
+ zoneId (newValue) {
+ fetchMultiNetworkNicEnabledForZone(newValue)
+ },
items: {
deep: true,
handler (newData) {
if (newData && newData.length > 0) {
this.dataItems = newData
this.initForm()
- const keyEx = this.dataItems.filter((item) => this.selectedRowKeys.includes(item.id))
+ const validPrimaryIds = this.dataItems.map(item => item.id)
+ const prunedGroups = {}
+ let groupsChanged = false
+ for (const [primaryId, assocIds] of Object.entries(this.trunkGroups)) {
+ if (!validPrimaryIds.includes(primaryId)) {
+ // the primary network this group was folded under is no longer selected - drop the whole group
+ groupsChanged = true
+ continue
+ }
+ // an associated network is independent of the selection list, except it can't also become its own
+ // selected row - if the user just checked it separately, it needs its own nic, not an association
+ const filtered = assocIds.filter(id => !validPrimaryIds.includes(id))
+ if (filtered.length !== assocIds.length) {
+ groupsChanged = true
+ }
+ if (filtered.length > 0) {
+ prunedGroups[primaryId] = filtered
+ }
+ }
+ if (groupsChanged) {
+ this.trunkGroups = prunedGroups
+ this.$emit('update-trunk-groups', this.trunkGroups)
+ }
+ const keyEx = this.visibleDataItems.filter((item) => this.selectedRowKeys.includes(item.id))
if (!keyEx || keyEx.length === 0) {
- this.selectedRowKeys = [this.dataItems[0].id]
- this.$emit('select-default-network-item', this.dataItems[0].id)
+ this.selectedRowKeys = this.visibleDataItems.length > 0 ? [this.visibleDataItems[0].id] : []
+ this.$emit('select-default-network-item', this.selectedRowKeys[0])
}
}
}
}
},
- emits: ['update-network-config', 'select-default-network-item', 'handler-error'],
+ emits: ['update-network-config', 'select-default-network-item', 'handler-error', 'update-trunk-groups'],
methods: {
+ networkNameById (id) {
+ const network = this.items.find(item => item.id === id)
+ if (network) {
+ return network.name
+ }
+ return this.associatedNetworkNames[id] || id
+ },
+ openAssociateModal (record) {
+ this.associateTarget = record
+ this.associateSelection = []
+ this.associateModalNetworks = []
+ this.showAssociateModal = true
+ this.associateModalLoading = true
+ const alreadyUsedNetworkIds = [record.id, ...this.dataItems.map(item => item.id), ...this.groupedAwayNetworkIds]
+ getAPI('listNetworks', {
+ listAll: true,
+ zoneid: this.zoneId
+ }).then(response => {
+ const allNetworks = response.listnetworksresponse.network || []
+ this.associateModalNetworks = allNetworks.filter(network =>
+ network.type !== 'L2' && !alreadyUsedNetworkIds.includes(network.id))
+ const names = {}
+ allNetworks.forEach(network => { names[network.id] = network.name })
+ this.associatedNetworkNames = { ...this.associatedNetworkNames, ...names }
+ }).finally(() => {
+ this.associateModalLoading = false
+ })
+ },
+ closeAssociateModal () {
+ this.showAssociateModal = false
+ this.associateTarget = null
+ this.associateSelection = []
+ this.associateModalNetworks = []
+ },
+ confirmAssociate () {
+ if (!this.associateTarget || this.associateSelection.length === 0) {
+ this.closeAssociateModal()
+ return
+ }
+ const existing = this.trunkGroups[this.associateTarget.id] || []
+ this.trunkGroups = {
+ ...this.trunkGroups,
+ [this.associateTarget.id]: [...existing, ...this.associateSelection]
+ }
+ if (!this.visibleDataItems.some(item => this.selectedRowKeys.includes(item.id))) {
+ // the current default network just got folded into this group as an association; keep the group's primary as default
+ this.selectedRowKeys = [this.associateTarget.id]
+ this.$emit('select-default-network-item', this.associateTarget.id)
+ }
+ this.$emit('update-trunk-groups', this.trunkGroups)
+ this.closeAssociateModal()
+ },
+ removeFromGroup (primaryId, assocId) {
+ const existing = this.trunkGroups[primaryId] || []
+ this.trunkGroups = {
+ ...this.trunkGroups,
+ [primaryId]: existing.filter(id => id !== assocId)
+ }
+ this.$emit('update-trunk-groups', this.trunkGroups)
+ },
initForm () {
this.formRef = ref()
this.form = reactive({})
diff --git a/ui/src/views/compute/wizard/VnfNicsSelection.vue b/ui/src/views/compute/wizard/VnfNicsSelection.vue
index 538c9553eb4..12b527f1d91 100644
--- a/ui/src/views/compute/wizard/VnfNicsSelection.vue
+++ b/ui/src/views/compute/wizard/VnfNicsSelection.vue
@@ -57,20 +57,75 @@
return option.children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
<a-select-option key="" >{{ }}</a-select-option>
- <a-select-option v-for="network in networks" :key="network.id">
+ <a-select-option v-for="network in availableNetworksForSelect" :key="network.id">
{{ network.name }}
</a-select-option>
</a-select>
+ <div v-if="trunkGroups[record.deviceid] && trunkGroups[record.deviceid].length > 0" style="margin-top: 4px">
+ <a-tag v-for="assocId in trunkGroups[record.deviceid]" :key="assocId" closable @close.prevent.stop="removeFromGroup(record.deviceid, assocId)">
+ {{ networkNameById(assocId) }}
+ </a-tag>
+ </div>
</a-form-item>
</template>
+ <template #associate="{ record }">
+ <tooltip-button
+ v-if="!record.management && values[record.deviceid]"
+ tooltipPlacement="top"
+ :tooltip="$t('label.network.add.associated')"
+ icon="plus-outlined"
+ size="small"
+ type="primary"
+ @onClick="openAssociateModal(record)" />
+ </template>
</a-table>
</a-form>
+
+ <a-modal
+ :visible="showAssociateModal"
+ :title="$t('label.associate.network')"
+ :maskClosable="false"
+ :closable="true"
+ :footer="null"
+ @cancel="closeAssociateModal">
+ {{ $t('message.associate.network.desc') }}
+ <a-alert
+ type="warning"
+ show-icon
+ style="margin-top: 10px; margin-bottom: 10px"
+ :message="$t('message.associate.network.loop.warning')" />
+ <a-select
+ mode="multiple"
+ style="width: 100%"
+ :loading="associateModalLoading"
+ :placeholder="$t('label.networks')"
+ v-model:value="associateSelection"
+ optionFilterProp="label">
+ <a-select-option
+ v-for="network in associateModalNetworks"
+ :key="network.id"
+ :value="network.id"
+ :label="network.name">
+ {{ network.name }}
+ </a-select-option>
+ </a-select>
+ <div style="margin-top: 20px; text-align: right;">
+ <a-button @click="closeAssociateModal">{{ $t('label.cancel') }}</a-button>
+ <a-button type="primary" style="margin-left: 8px" @click="confirmAssociate">{{ $t('label.ok') }}</a-button>
+ </div>
+ </a-modal>
</template>
<script>
import { ref, reactive } from 'vue'
+import { getAPI } from '@/api'
+import TooltipButton from '@/components/widgets/TooltipButton'
+import { isMultiNetworkNicEnabledForZone, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic'
export default {
name: 'VnfNicsSelection',
+ components: {
+ TooltipButton
+ },
props: {
items: {
type: Array,
@@ -87,12 +142,44 @@ export default {
preFillContent: {
type: Object,
default: () => {}
+ },
+ canTrunkNics: {
+ type: Boolean,
+ default: false
+ },
+ zoneId: {
+ type: String,
+ default: ''
}
},
data () {
return {
values: {},
- columns: [
+ trunkGroups: {},
+ showAssociateModal: false,
+ associateTarget: null,
+ associateSelection: [],
+ associateModalNetworks: [],
+ associateModalLoading: false,
+ associatedNetworkNames: {}
+ }
+ },
+ created () {
+ this.initForm()
+ fetchMultiNetworkNicEnabledForZone(this.zoneId)
+ },
+ computed: {
+ showTrunkNicControls () {
+ return this.canTrunkNics && isMultiNetworkNicEnabledForZone(this.zoneId)
+ },
+ groupedAwayNetworkIds () {
+ return Object.values(this.trunkGroups).flat()
+ },
+ availableNetworksForSelect () {
+ return this.networks.filter(network => !this.groupedAwayNetworkIds.includes(network.id))
+ },
+ columns () {
+ const cols = [
{
dataIndex: 'deviceid',
title: this.$t('label.deviceid'),
@@ -120,20 +207,31 @@ export default {
{
dataIndex: 'description',
title: this.$t('label.description'),
- width: '35%',
+ width: this.showTrunkNicControls ? '25%' : '35%',
slots: { customRender: 'description' }
},
{
dataIndex: 'network',
title: this.$t('label.network'),
- width: '25%',
+ width: this.showTrunkNicControls ? '15%' : '25%',
slots: { customRender: 'network' }
}
]
+ if (this.showTrunkNicControls) {
+ cols.push({
+ dataIndex: 'associate',
+ title: '',
+ width: '10%',
+ slots: { customRender: 'associate' }
+ })
+ }
+ return cols
}
},
- created () {
- this.initForm()
+ watch: {
+ zoneId (newValue) {
+ fetchMultiNetworkNicEnabledForZone(newValue)
+ }
},
methods: {
initForm () {
@@ -147,9 +245,72 @@ export default {
this.form = reactive(form)
this.rules = reactive(rules)
},
+ networkNameById (id) {
+ const network = this.networks.find(item => item.id === id)
+ if (network) {
+ return network.name
+ }
+ return this.associatedNetworkNames[id] || id
+ },
updateNicNetworkValue (value, deviceid) {
this.values[deviceid] = this.networks.filter(network => network.id === value)?.[0] || null
+ if (!value && this.trunkGroups[deviceid]) {
+ // no primary network left for this nic, so any associated networks it had no longer make sense either
+ const prunedGroups = { ...this.trunkGroups }
+ delete prunedGroups[deviceid]
+ this.trunkGroups = prunedGroups
+ this.$emit('update-vnf-trunk-groups', this.trunkGroups)
+ }
this.$emit('update-vnf-nic-networks', this.values)
+ },
+ openAssociateModal (record) {
+ this.associateTarget = record
+ this.associateSelection = []
+ this.associateModalNetworks = []
+ this.showAssociateModal = true
+ this.associateModalLoading = true
+ const alreadyUsedDeviceNetworkIds = Object.values(this.values).filter(Boolean).map(network => network.id)
+ const alreadyUsedNetworkIds = [...alreadyUsedDeviceNetworkIds, ...this.groupedAwayNetworkIds]
+ getAPI('listNetworks', {
+ listAll: true,
+ zoneid: this.zoneId
+ }).then(response => {
+ const allNetworks = response.listnetworksresponse.network || []
+ this.associateModalNetworks = allNetworks.filter(network => !alreadyUsedNetworkIds.includes(network.id))
+ const names = {}
+ allNetworks.forEach(network => { names[network.id] = network.name })
+ this.associatedNetworkNames = { ...this.associatedNetworkNames, ...names }
+ }).finally(() => {
+ this.associateModalLoading = false
+ })
+ },
+ closeAssociateModal () {
+ this.showAssociateModal = false
+ this.associateTarget = null
+ this.associateSelection = []
+ this.associateModalNetworks = []
+ },
+ confirmAssociate () {
+ if (!this.associateTarget || this.associateSelection.length === 0) {
+ this.closeAssociateModal()
+ return
+ }
+ const deviceid = this.associateTarget.deviceid
+ const existing = this.trunkGroups[deviceid] || []
+ this.trunkGroups = {
+ ...this.trunkGroups,
+ [deviceid]: [...existing, ...this.associateSelection]
+ }
+ this.$emit('update-vnf-trunk-groups', this.trunkGroups)
+ this.closeAssociateModal()
+ },
+ removeFromGroup (deviceid, assocId) {
+ const existing = this.trunkGroups[deviceid] || []
+ this.trunkGroups = {
+ ...this.trunkGroups,
+ [deviceid]: existing.filter(id => id !== assocId)
+ }
+ this.$emit('update-vnf-trunk-groups', this.trunkGroups)
}
}
}
diff --git a/ui/src/views/infra/Resources.vue b/ui/src/views/infra/Resources.vue
index 0055343b30d..15dd7b26a18 100644
--- a/ui/src/views/infra/Resources.vue
+++ b/ui/src/views/infra/Resources.vue
@@ -61,12 +61,27 @@
</a-collapse>
</div>
</a-list-item>
+ <a-list-item v-if="showHostReadiness" class="list-item">
+ <div class="list-item__container">
+ <div class="list-item__data list-item__title">{{ $t('label.multinetworknic.hosts.ready') }}</div>
+ <div class="list-item__vals">
+ <div class="list-item__data">
+ {{ $t('label.multinetworknic.hosts.ready.count', { ready: hostReadiness.ready, total: hostReadiness.total }) }}
+ </div>
+ <a-progress
+ status="normal"
+ :percent="hostReadinessPercent"
+ :format="p => p.toFixed(2) + '%'" />
+ </div>
+ </div>
+ </a-list-item>
</a-list>
</a-spin>
</template>
<script>
import { getAPI } from '@/api'
+import { isMultiNetworkNicEnabledForZone, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic'
export default {
name: 'Resources',
@@ -90,7 +105,22 @@ export default {
return {
fetchLoading: false,
resourcesList: [],
- collapseActive: {}
+ collapseActive: {},
+ hostReadiness: { ready: 0, total: 0 }
+ }
+ },
+ computed: {
+ zoneId () {
+ return this.$route.meta.name === 'zone' ? this.resource.id : this.resource.zoneid
+ },
+ showHostReadiness () {
+ return isMultiNetworkNicEnabledForZone(this.zoneId) && this.hostReadiness.total > 0
+ },
+ hostReadinessPercent () {
+ if (this.hostReadiness.total === 0) {
+ return 0
+ }
+ return (this.hostReadiness.ready / this.hostReadiness.total) * 100
}
},
created () {
@@ -111,6 +141,21 @@ export default {
}).finally(() => {
this.fetchLoading = false
})
+ fetchMultiNetworkNicEnabledForZone(this.zoneId)
+ this.fetchHostReadiness(entity, params[entity])
+ },
+ fetchHostReadiness (entity, entityId) {
+ const params = { type: 'Routing' }
+ params[entity] = entityId
+ getAPI('listHosts', params).then(response => {
+ const hosts = response.listhostsresponse.host || []
+ this.hostReadiness = {
+ ready: hosts.filter(host => host.vlanfilteringenabled).length,
+ total: hosts.length
+ }
+ }).catch(error => {
+ this.$notifyError(error)
+ })
},
updateTaggedCapacities () {
var resourcesListCopy = [...this.resourcesList]
diff --git a/ui/src/views/network/EnableStaticNat.vue b/ui/src/views/network/EnableStaticNat.vue
index f6f6748630c..bc955cf6083 100644
--- a/ui/src/views/network/EnableStaticNat.vue
+++ b/ui/src/views/network/EnableStaticNat.vue
@@ -58,21 +58,23 @@
{{ record.name }}
</div>
<a-select
- v-if="nicsList.length && selectedVm && selectedVm === record.id"
+ v-if="nicIpOptions.length && selectedVm && selectedVm === record.id"
class="nic-select"
- :defaultValue="selectedNic.ipaddress"
+ :defaultValue="selectedNic.ip"
showSearch
optionFilterProp="label"
:filterOption="(input, option) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}">
- <a-select-option
- @click="selectedNic = item"
- v-for="item in nicsList"
- :key="item.id"
- :label="item.ipaddress">
- {{ item.ipaddress }}
- </a-select-option>
+ <a-select-opt-group v-for="group in groupedNicIpOptions" :key="group.kind" :label="group.label">
+ <a-select-option
+ @click="selectedNic = item"
+ v-for="item in group.items"
+ :key="item.nicid + '-' + item.ip"
+ :label="item.ip + ' ' + item.networkname">
+ {{ item.ip }} <span class="nic-select__network">({{ item.networkname }})</span>
+ </a-select-option>
+ </a-select-opt-group>
</a-select>
</template>
<template v-if="column.key === 'state'">
@@ -115,6 +117,7 @@
<script>
import { getAPI, postAPI } from '@/api'
import Status from '@/components/widgets/Status'
+import { buildNicIpOptions, groupNicIpOptionsByKind } from '@/utils/multiNetworkNic'
export default {
props: {
@@ -127,12 +130,17 @@ export default {
Status
},
inject: ['parentFetchData'],
+ computed: {
+ groupedNicIpOptions () {
+ return groupNicIpOptionsByKind(this.nicIpOptions, this.$t)
+ }
+ },
data () {
return {
loading: false,
vmsList: [],
selectedVm: null,
- nicsList: [],
+ nicIpOptions: [],
searchQuery: null,
selectedNic: null,
columns: [
@@ -220,22 +228,15 @@ export default {
},
fetchNics (e) {
this.selectedVm = e.target.value
- this.nicsList = []
+ this.nicIpOptions = []
this.loading = true
getAPI('listNics', {
virtualmachineid: this.selectedVm,
networkid: this.resource.associatednetworkid || this.selectedVpcTier
}).then(response => {
- this.nicsList = response.listnicsresponse.nic || []
-
- let secondaryIps = this.nicsList.map(item => item.secondaryip)
-
- if (secondaryIps[0]) {
- secondaryIps = secondaryIps[0]
- this.nicsList = [...this.nicsList, ...secondaryIps]
- }
-
- this.selectedNic = this.nicsList[0]
+ const nics = response.listnicsresponse.nic || []
+ this.nicIpOptions = nics.flatMap(nic => buildNicIpOptions(nic))
+ this.selectedNic = this.nicIpOptions[0]
}).catch(error => {
this.$notifyError(error)
}).finally(() => {
@@ -261,8 +262,8 @@ export default {
postAPI('enableStaticNat', {
ipaddressid: this.resource.id,
virtualmachineid: this.selectedVm,
- vmguestip: this.selectedNic.ipaddress,
- networkid: this.selectedVpcTier
+ vmguestip: this.selectedNic.ip,
+ networkid: this.selectedNic.networkid
}).then(() => {
this.parentFetchData()
this.handleClose()
@@ -394,6 +395,10 @@ export default {
margin-top: 10px;
margin-right: auto;
min-width: 150px;
+
+ &__network {
+ color: rgba(0, 0, 0, 0.45);
+ }
}
.pagination {
diff --git a/ui/src/views/network/InternalLBAssignVmForm.vue b/ui/src/views/network/InternalLBAssignVmForm.vue
index dfa2a24a0f6..73f61f38e3d 100644
--- a/ui/src/views/network/InternalLBAssignVmForm.vue
+++ b/ui/src/views/network/InternalLBAssignVmForm.vue
@@ -47,13 +47,15 @@
:filterOption="(input, option) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
- <a-select-option
- v-for="(nic, nicIndex) in nics[index]"
- :key="nic"
- :value="nic"
- :label="nic">
- {{ nic }}{{ nicIndex === 0 ? ` (${this.$t('label.primary')})` : '' }}
- </a-select-option>
+ <a-select-opt-group v-for="group in groupedNicIpOptionsFor(index)" :key="group.kind" :label="group.label">
+ <a-select-option
+ v-for="item in group.items"
+ :key="item.ip"
+ :value="item.ip"
+ :label="item.ip + ' ' + item.networkname">
+ {{ item.ip }} <span class="nic-select__network">({{ item.networkname }})</span>
+ </a-select-option>
+ </a-select-opt-group>
</a-select>
</span>
<span><status :text="vm.state" displayText /></span>
@@ -99,6 +101,7 @@
<script>
import { getAPI, postAPI } from '@/api'
import Status from '@/components/widgets/Status'
+import { buildNicIpOptions, groupNicIpOptionsByKind } from '@/utils/multiNetworkNic'
export default {
name: 'InternalLBAssignVmForm',
@@ -132,6 +135,9 @@ export default {
this.fetchData()
},
methods: {
+ groupedNicIpOptionsFor (index) {
+ return groupNicIpOptionsByKind(this.nics[index] || [], this.$t)
+ },
fetchData () {
this.fetchLoadBalancers()
this.fetchVirtualMachines()
@@ -187,13 +193,9 @@ export default {
networkid: this.resource.networkid
}).then(response => {
if (!response.listnicsresponse.nic[0]) return
- const newItem = []
- newItem.push(response.listnicsresponse.nic[0].ipaddress)
- if (response.listnicsresponse.nic[0].secondaryip) {
- newItem.push(...response.listnicsresponse.nic[0].secondaryip.map(ip => ip.ipaddress))
- }
- this.nics[index] = newItem
- this.iLb.vmguestip[index] = this.nics[index][0]
+ const nicsReturned = response.listnicsresponse.nic || []
+ this.nics[index] = nicsReturned.flatMap(nic => buildNicIpOptions(nic))
+ this.iLb.vmguestip[index] = this.nics[index][0]?.ip
this.addVmModalNicLoading = false
}).catch(error => {
this.$notifyError(error)
@@ -289,4 +291,8 @@ export default {
}
}
}
+
+.nic-select__network {
+ color: rgba(0, 0, 0, 0.45);
+}
</style>
diff --git a/ui/src/views/network/LoadBalancing.vue b/ui/src/views/network/LoadBalancing.vue
index 0b9ed7684a8..58fd14e203d 100644
--- a/ui/src/views/network/LoadBalancing.vue
+++ b/ui/src/views/network/LoadBalancing.vue
@@ -545,13 +545,15 @@
:filterOption="(input, option) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
- <a-select-option
- v-for="(nic, nicIndex) in nics[index]"
- :key="nic"
- :value="nic"
- :label="nic + nicIndex === 0 ? ` (${$t('label.primary')})` : null">
- {{ nic }}{{ nicIndex === 0 ? ` (${$t('label.primary')})` : null }}
- </a-select-option>
+ <a-select-opt-group v-for="group in groupedNicIpOptionsFor(index)" :key="group.kind" :label="group.label">
+ <a-select-option
+ v-for="item in group.items"
+ :key="item.ip"
+ :value="item.ip"
+ :label="item.ip + ' ' + item.networkname">
+ {{ item.ip }} <span class="nic-select__network">({{ item.networkname }})</span>
+ </a-select-option>
+ </a-select-opt-group>
</a-select>
</template>
@@ -812,6 +814,7 @@ import TooltipButton from '@/components/widgets/TooltipButton'
import BulkActionView from '@/components/view/BulkActionView'
import eventBus from '@/config/eventBus'
import TooltipLabel from '@/components/widgets/TooltipLabel'
+import { buildNicIpOptions, groupNicIpOptionsByKind, resolveNetworkIdForIp } from '@/utils/multiNetworkNic'
export default {
name: 'LoadBalancing',
@@ -1060,6 +1063,9 @@ export default {
}
},
methods: {
+ groupedNicIpOptionsFor (index) {
+ return groupNicIpOptionsByKind(this.nics[index] || [], this.$t)
+ },
initForm () {
this.formRef = ref()
this.form = reactive({})
@@ -1853,13 +1859,9 @@ export default {
networkid: ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid
}).then(response => {
if (!response || !response.listnicsresponse || !response.listnicsresponse.nic[0]) return
- const newItem = []
- newItem.push(response.listnicsresponse.nic[0].ipaddress)
- if (response.listnicsresponse.nic[0].secondaryip) {
- newItem.push(...response.listnicsresponse.nic[0].secondaryip.map(ip => ip.ipaddress))
- }
- this.nics[index] = newItem
- this.newRule.vmguestip[index] = [this.nics[index][0]]
+ const nicsReturned = response.listnicsresponse.nic || []
+ this.nics[index] = nicsReturned.flatMap(nic => buildNicIpOptions(nic))
+ this.newRule.vmguestip[index] = [this.nics[index][0]?.ip]
this.addVmModalNicLoading = false
}).catch(error => {
this.$notifyError(error)
@@ -1956,7 +1958,7 @@ export default {
vmIDIpMap[`vmidipmap[${innerCount}].vmid`] = this.newRule.virtualmachineid[count]
vmIDIpMap[`vmidipmap[${innerCount}].vmip`] = i
if (this.vpcConserveMode) {
- vmIDIpMap[`vmidipmap[${innerCount}].vmnetworkid`] = this.selectedTier
+ vmIDIpMap[`vmidipmap[${innerCount}].vmnetworkid`] = resolveNetworkIdForIp(this.nics[count], i, this.selectedTier)
}
innerCount++
})
@@ -1964,7 +1966,7 @@ export default {
vmIDIpMap[`vmidipmap[${innerCount}].vmid`] = this.newRule.virtualmachineid[count]
vmIDIpMap[`vmidipmap[${innerCount}].vmip`] = ip
if (this.vpcConserveMode && ip != null) {
- vmIDIpMap[`vmidipmap[${innerCount}].vmnetworkid`] = this.selectedTier
+ vmIDIpMap[`vmidipmap[${innerCount}].vmnetworkid`] = resolveNetworkIdForIp(this.nics[count], ip, this.selectedTier)
}
innerCount++
}
@@ -2563,4 +2565,8 @@ export default {
width: 50%;
float: right;
}
+
+ .nic-select__network {
+ color: rgba(0, 0, 0, 0.45);
+ }
</style>
diff --git a/ui/src/views/network/NicsTab.vue b/ui/src/views/network/NicsTab.vue
index 8e21db5b34d..3fd653945ee 100644
--- a/ui/src/views/network/NicsTab.vue
+++ b/ui/src/views/network/NicsTab.vue
@@ -25,7 +25,11 @@
:disabled="!('addNicToVirtualMachine' in $store.getters.apis) || resource.hypervisor === 'External'">
<template #icon><plus-outlined /></template> {{ $t('label.network.addvm') }}
</a-button>
- <NicsTable :resource="resource" :loading="loading">
+ <NicsTable
+ :resource="resource"
+ :loading="loading"
+ :canManageAssociations="canManageAssociations"
+ @disassociate-network="onDisassociateNetwork">
<template #actions="record">
<a-popconfirm
:title="$t('label.set.default.nic')"
@@ -61,6 +65,12 @@
icon="edit-outlined"
:disabled="(!('updateVmNic' in $store.getters.apis))"
@onClick="onUpdateNic(record)" />
+ <tooltip-button
+ v-if="canManageAssociations && record.nic.type !== 'L2'"
+ tooltipPlacement="bottom"
+ :tooltip="$t('label.associate.network')"
+ icon="apartment-outlined"
+ @onClick="onAssociateNetworks(record)" />
<a-popconfirm
:title="$t('message.network.removenic')"
@confirm="removeNIC(record.nic)"
@@ -147,6 +157,58 @@
</a-form>
</a-modal>
+ <a-modal
+ :visible="showAssociateNetworksModal"
+ :title="$t('label.associate.network')"
+ :maskClosable="false"
+ :closable="true"
+ :footer="null"
+ @cancel="closeModals">
+ {{ $t('message.associate.network.desc') }}
+ <a-alert
+ type="warning"
+ show-icon
+ style="margin-top: 10px; margin-bottom: 10px"
+ :message="$t('message.associate.network.loop.warning')" />
+ <a-form
+ @finish="submitAssociateNetworks"
+ v-ctrl-enter="submitAssociateNetworks"
+ layout="vertical">
+ <a-form-item name="networks" ref="networks">
+ <template #label>
+ <tooltip-label :title="$t('label.networks')" :tooltip="$t('message.associate.network.desc')"/>
+ </template>
+ <a-select
+ mode="multiple"
+ :placeholder="$t('label.networks')"
+ v-model:value="associateNetworksData.networkIds"
+ v-focus="true"
+ showSearch
+ optionFilterProp="label"
+ :filterOption="(input, option) => {
+ return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
+ }" >
+ <a-select-option
+ v-for="network in associateNetworksData.availableNetworks"
+ :key="network.id"
+ :value="network.id"
+ :label="network.name">
+ <span>
+ <resource-icon v-if="network.icon" :image="network.icon.base64image" size="1x" style="margin-right: 5px"/>
+ <apartment-outlined v-else style="margin-right: 5px" />
+ {{ network.name }}
+ </span>
+ </a-select-option>
+ </a-select>
+ </a-form-item>
+
+ <div :span="24" class="action-button">
+ <a-button @click="closeModals">{{ $t('label.cancel') }}</a-button>
+ <a-button type="primary" ref="submit" @click="submitAssociateNetworks">{{ $t('label.ok') }}</a-button>
+ </div>
+ </a-form>
+ </a-modal>
+
<a-modal
:visible="showUpdateIpModal"
:title="$t('label.change.ipaddress')"
@@ -288,6 +350,7 @@ import NicsTable from '@/views/network/NicsTable'
import TooltipLabel from '@/components/widgets/TooltipLabel'
import TooltipButton from '@/components/widgets/TooltipButton'
import ResourceIcon from '@/components/view/ResourceIcon'
+import { isMultiNetworkNicEnabledForZone, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic'
export default {
name: 'NicsTab',
@@ -313,6 +376,7 @@ export default {
vm: {},
nic: {},
showAddNetworkModal: false,
+ showAssociateNetworksModal: false,
showUpdateIpModal: false,
showSecondaryIpModal: false,
showUpdateNicModal: false,
@@ -322,6 +386,11 @@ export default {
ip: '',
makedefault: false
},
+ associateNetworksData: {
+ nic: {},
+ availableNetworks: [],
+ networkIds: []
+ },
loadingNic: false,
editIpAddressNic: '',
editIpAddressValue: '',
@@ -338,9 +407,16 @@ export default {
}
}
},
+ computed: {
+ canManageAssociations () {
+ return this.resource.hypervisor === 'KVM' && ('associateNetworkToNic' in this.$store.getters.apis) &&
+ isMultiNetworkNicEnabledForZone(this.resource.zoneid)
+ }
+ },
created () {
this.vm = this.resource
this.addNetworkData.apiParams = this.$getApiParams('addNicToVirtualMachine')
+ fetchMultiNetworkNicEnabledForZone(this.resource.zoneid)
},
methods: {
listNetworks () {
@@ -397,6 +473,7 @@ export default {
},
closeModals () {
this.showAddNetworkModal = false
+ this.showAssociateNetworksModal = false
this.showUpdateIpModal = false
this.showSecondaryIpModal = false
this.showUpdateNicModal = false
@@ -404,10 +481,90 @@ export default {
this.addNetworkData.ipaddress = ''
this.addNetworkData.macaddress = ''
this.addNetworkData.makedefault = false
+ this.associateNetworksData.nic = {}
+ this.associateNetworksData.availableNetworks = []
+ this.associateNetworksData.networkIds = []
this.editIpAddressValue = ''
this.newSecondaryIp = ''
this.newSecondaryIpDescription = ''
},
+ onAssociateNetworks (record) {
+ this.associateNetworksData.nic = record.nic
+ this.associateNetworksData.networkIds = []
+ this.showAssociateNetworksModal = true
+ const alreadyUsedNetworkIds = [record.nic.networkid, ...(record.nic.associatednetworks || []).map(n => n.networkid)]
+ getAPI('listNetworks', {
+ listAll: 'true',
+ showicon: true,
+ zoneid: this.vm.zoneid
+ }).then(response => {
+ this.associateNetworksData.availableNetworks = (response.listnetworksresponse.network || [])
+ .filter(network => !alreadyUsedNetworkIds.includes(network.id))
+ })
+ },
+ submitAssociateNetworks () {
+ if (this.loadingNic || this.associateNetworksData.networkIds.length === 0) return
+ this.loadingNic = true
+ this.showAssociateNetworksModal = false
+ postAPI('associateNetworkToNic', {
+ nicid: this.associateNetworksData.nic.id,
+ networkids: this.associateNetworksData.networkIds
+ }).then(response => {
+ this.$pollJob({
+ jobId: response.associatenetworktonicresponse.jobid,
+ successMessage: this.$t('message.success.associate.network'),
+ successMethod: () => {
+ this.loadingNic = false
+ this.closeModals()
+ this.$emit('refresh')
+ },
+ errorMessage: this.$t('message.error.associate.network'),
+ errorMethod: () => {
+ this.loadingNic = false
+ this.closeModals()
+ },
+ loadingMessage: this.$t('message.associate.network.processing'),
+ catchMessage: this.$t('error.fetching.async.job.result'),
+ catchMethod: () => {
+ this.loadingNic = false
+ this.closeModals()
+ this.$emit('refresh')
+ }
+ })
+ }).catch(error => {
+ this.$notifyError(error)
+ this.loadingNic = false
+ })
+ },
+ onDisassociateNetwork ({ nic, network }) {
+ this.loadingNic = true
+ postAPI('disassociateNetworkFromNic', {
+ nicid: nic.id,
+ networkid: network.networkid
+ }).then(response => {
+ this.$pollJob({
+ jobId: response.disassociatenetworkfromnicresponse.jobid,
+ successMessage: this.$t('message.success.disassociate.network'),
+ successMethod: () => {
+ this.loadingNic = false
+ this.$emit('refresh')
+ },
+ errorMessage: this.$t('message.error.disassociate.network'),
+ errorMethod: () => {
+ this.loadingNic = false
+ },
+ loadingMessage: this.$t('message.disassociate.network.processing'),
+ catchMessage: this.$t('error.fetching.async.job.result'),
+ catchMethod: () => {
+ this.loadingNic = false
+ this.$emit('refresh')
+ }
+ })
+ }).catch(error => {
+ this.$notifyError(error)
+ this.loadingNic = false
+ })
+ },
onChangeIPAddress (record) {
this.editNicResource = record.nic
this.editIpAddressNic = record.nic.id
diff --git a/ui/src/views/network/NicsTable.vue b/ui/src/views/network/NicsTable.vue
index 3a70eb3429c..5a6b358a735 100644
--- a/ui/src/views/network/NicsTable.vue
+++ b/ui/src/views/network/NicsTable.vue
@@ -61,6 +61,39 @@
<a-descriptions-item :label="$t('label.dns.name')" v-if="record.nicdnsname">
{{ record.nicdnsname }}
</a-descriptions-item>
+ <a-descriptions-item :label="$t('label.nic.associatednetworks')" v-if="record.trunked && record.associatednetworks && record.associatednetworks.length > 0">
+ <a-list size="small" :dataSource="record.associatednetworks" :rowKey="item => item.networkid">
+ <template #renderItem="{ item }">
+ <a-list-item>
+ <a-list-item-meta>
+ <template #title>
+ <router-link :to="{ path: '/guestnetwork/' + item.networkid }">{{ item.networkname }}</router-link>
+ <span v-if="item.broadcasturi" style="margin-left: 8px; color: rgba(0, 0, 0, 0.45)">({{ item.broadcasturi }})</span>
+ </template>
+ <template #description>
+ <span v-if="item.ipaddress">{{ $t('label.ipaddress') }}: {{ item.ipaddress }}</span>
+ <span v-if="item.gateway" style="margin-left: 10px">{{ $t('label.gateway') }}: {{ item.gateway }}</span>
+ </template>
+ </a-list-item-meta>
+ <template #actions>
+ <a-popconfirm
+ v-if="canManageAssociations"
+ :title="$t('message.disassociate.network.confirm')"
+ @confirm="$emit('disassociate-network', { nic: record, network: item })"
+ :okText="$t('label.yes')"
+ :cancelText="$t('label.no')">
+ <tooltip-button
+ tooltipPlacement="top"
+ :tooltip="$t('label.disassociate.network')"
+ type="primary"
+ :danger="true"
+ icon="delete-outlined" />
+ </a-popconfirm>
+ </template>
+ </a-list-item>
+ </template>
+ </a-list>
+ </a-descriptions-item>
</a-descriptions>
</template>
<template #bodyCell="{ column, text, record }">
@@ -73,6 +106,11 @@
<a-tag v-if="record.isdefault">
{{ $t('label.default') }}
</a-tag>
+ <a-tooltip v-if="record.trunked" :title="$t('message.nic.trunked.tooltip')">
+ <a-tag color="blue">
+ {{ $t('label.nic.trunked') }}
+ </a-tag>
+ </a-tooltip>
</template>
<template v-if="column.key === 'enabled'">
<status :text="text ? 'enabled' : 'disabled'"/> {{ text ? 'Enabled' : 'Disabled' }}
@@ -85,6 +123,7 @@
import { getAPI } from '@/api'
import ResourceIcon from '@/components/view/ResourceIcon'
import Status from '@/components/widgets/Status'
+import TooltipButton from '@/components/widgets/TooltipButton'
export default {
name: 'NicsTable',
@@ -96,11 +135,17 @@ export default {
loading: {
type: Boolean,
default: false
+ },
+ canManageAssociations: {
+ type: Boolean,
+ default: false
}
},
+ emits: ['disassociate-network'],
components: {
ResourceIcon,
- Status
+ Status,
+ TooltipButton
},
inject: ['parentFetchData'],
data () {
diff --git a/ui/src/views/network/PortForwarding.vue b/ui/src/views/network/PortForwarding.vue
index ffa89e5b581..40262022ebf 100644
--- a/ui/src/views/network/PortForwarding.vue
+++ b/ui/src/views/network/PortForwarding.vue
@@ -274,13 +274,15 @@
:filterOption="(input, option) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
- <a-select-option
- v-for="(nic, nicIndex) in nics"
- :key="nic"
- :value="nic"
- :label="nic">
- {{ nic }}{{ nicIndex === 0 ? ` (${$t('label.primary')})` : null }}
- </a-select-option>
+ <a-select-opt-group v-for="group in groupedNicIpOptions" :key="group.kind" :label="group.label">
+ <a-select-option
+ v-for="item in group.items"
+ :key="item.ip"
+ :value="item.ip"
+ :label="item.ip + ' ' + item.networkname">
+ {{ item.ip }} <span class="nic-select__network">({{ item.networkname }})</span>
+ </a-select-option>
+ </a-select-opt-group>
</a-select>
</template>
@@ -350,6 +352,7 @@ import TooltipButton from '@/components/widgets/TooltipButton'
import BulkActionView from '@/components/view/BulkActionView'
import eventBus from '@/config/eventBus'
import TooltipLabel from '@/components/widgets/TooltipLabel.vue'
+import { buildNicIpOptions, groupNicIpOptionsByKind, resolveNetworkIdForIp } from '@/utils/multiNetworkNic'
export default {
components: {
@@ -399,7 +402,7 @@ export default {
addVmModalLoading: false,
addVmModalNicLoading: false,
vms: [],
- nics: [],
+ nicIpOptions: [],
totalCount: 0,
page: 1,
pageSize: 10,
@@ -480,6 +483,9 @@ export default {
computed: {
hasSelected () {
return this.selectedRowKeys.length > 0
+ },
+ groupedNicIpOptions () {
+ return groupNicIpOptionsByKind(this.nicIpOptions, this.$t)
}
},
beforeCreate () {
@@ -653,7 +659,8 @@ export default {
if (this.loading) return
this.loading = true
this.addVmModalVisible = false
- const networkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid
+ const primaryNetworkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid
+ const networkId = resolveNetworkIdForIp(this.nicIpOptions, this.newRule.vmguestip, primaryNetworkId)
postAPI('createPortForwardingRule', {
...this.newRule,
ipaddressid: this.resource.id,
@@ -706,7 +713,7 @@ export default {
this.addVmModalLoading = false
this.addVmModalNicLoading = false
this.showConfirmationAction = false
- this.nics = []
+ this.nicIpOptions = []
this.checked = false
this.resetTagInputs()
},
@@ -806,20 +813,16 @@ export default {
this.fetchVirtualMachines()
},
fetchNics (e) {
- this.nics = []
+ this.nicIpOptions = []
this.addVmModalNicLoading = true
this.newRule.virtualmachineid = e.target.value
getAPI('listNics', {
virtualmachineid: e.target.value,
networkid: ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid
}).then(response => {
- if (!response.listnicsresponse.nic || response.listnicsresponse.nic.length < 1) return
- const nic = response.listnicsresponse.nic[0]
- this.nics.push(nic.ipaddress)
- if (nic.secondaryip && nic.secondaryip.length > 0) {
- this.nics.push(...nic.secondaryip.map(ip => ip.ipaddress))
- }
- this.newRule.vmguestip = this.nics[0]
+ const nics = response.listnicsresponse.nic || []
+ this.nicIpOptions = nics.flatMap(nic => buildNicIpOptions(nic))
+ this.newRule.vmguestip = this.nicIpOptions[0]?.ip
this.addVmModalNicLoading = false
}).catch(error => {
this.$notifyError(error)
@@ -1085,4 +1088,8 @@ export default {
width: 50%;
float: right;
}
+
+ .nic-select__network {
+ color: rgba(0, 0, 0, 0.45);
+ }
</style>