From d27753e5481c133ac9349a69a73de22560407144 Mon Sep 17 00:00:00 2001 From: Wei Zhou Date: Wed, 15 Apr 2026 08:55:48 +0200 Subject: [PATCH 1/4] Network Extension: Orchestrate external Network devices --- .../main/java/com/cloud/event/EventTypes.java | 1 + .../main/java/com/cloud/network/Network.java | 37 + .../java/com/cloud/network/NetworkModel.java | 14 + .../com/cloud/network/NetworkService.java | 2 +- .../cloud/network/element/NetworkElement.java | 4 + .../network/UpdatePhysicalNetworkCmd.java | 13 +- .../extension/CustomActionResultResponse.java | 8 + .../cloudstack/extension/Extension.java | 3 +- .../extension/ExtensionCustomAction.java | 3 +- .../cloudstack/extension/ExtensionHelper.java | 89 + .../extension/ExtensionResourceMap.java | 3 +- .../NetworkCustomActionProvider.java | 53 + .../java/com/cloud/network/NetworkTest.java | 40 + .../extension/ExtensionCustomActionTest.java | 6 + .../orchestration/NetworkOrchestrator.java | 92 +- .../NetworkOrchestratorTest.java | 2 + .../com/cloud/network/dao/NetworkDaoImpl.java | 3 +- .../network/dao/NetworkServiceMapVO.java | 6 + .../cloud/network/vpc/VpcServiceMapVO.java | 8 +- .../com/cloud/network/vpc/dao/VpcDaoImpl.java | 2 +- .../META-INF/db/schema-42210to42300.sql | 4 + .../extensions/api/ListExtensionsCmd.java | 23 + .../api/UpdateRegisteredExtensionCmd.java | 117 + .../extensions/dao/ExtensionDao.java | 5 + .../extensions/dao/ExtensionDaoImpl.java | 11 +- .../dao/ExtensionResourceMapDao.java | 6 +- .../dao/ExtensionResourceMapDaoImpl.java | 20 + .../extensions/manager/ExtensionsManager.java | 3 + .../manager/ExtensionsManagerImpl.java | 782 ++++- .../network/NetworkExtensionElement.java | 2674 +++++++++++++++++ .../framework/extensions/network/README.md | 1076 +++++++ .../extensions/vo/ExtensionDetailsVO.java | 2 +- .../vo/ExtensionResourceMapDetailsVO.java | 2 +- ...ring-framework-extensions-core-context.xml | 3 + .../api/UpdateRegisteredExtensionCmdTest.java | 115 + .../manager/ExtensionsManagerImplTest.java | 407 ++- .../management/ManagementServerMock.java | 2 +- .../main/java/com/cloud/api/ApiDBUtils.java | 4 + .../java/com/cloud/api/ApiResponseHelper.java | 4 +- .../ConfigurationManagerImpl.java | 4 +- .../com/cloud/network/NetworkModelImpl.java | 243 +- .../com/cloud/network/NetworkServiceImpl.java | 37 +- .../network/as/AutoScaleManagerImpl.java | 4 +- .../network/firewall/FirewallManagerImpl.java | 42 +- .../lb/LoadBalancingRulesManagerImpl.java | 9 + .../network/vpc/NetworkACLManagerImpl.java | 21 +- .../com/cloud/network/vpc/VpcManagerImpl.java | 53 +- .../cloud/network/MockNetworkModelImpl.java | 10 + .../cloud/network/NetworkModelImplTest.java | 9 +- .../network/UpdatePhysicalNetworkTest.java | 2 +- .../network/as/AutoScaleManagerImplTest.java | 2 + .../ConfigDriveNetworkElementTest.java | 5 + .../network/vpc/NetworkACLManagerTest.java | 11 +- .../cloud/network/vpc/VpcManagerImplTest.java | 10 +- .../com/cloud/vpc/MockNetworkManagerImpl.java | 2 +- .../com/cloud/vpc/MockNetworkModelImpl.java | 9 + .../smoke/test_network_extension_namespace.py | 2589 ++++++++++++++++ ui/public/locales/en.json | 21 +- ui/src/config/section/extension.js | 2 +- ui/src/config/section/infra/phynetworks.js | 2 +- ui/src/config/section/network.js | 14 + ui/src/views/extension/AddCustomAction.vue | 41 +- ui/src/views/extension/CreateExtension.vue | 2 +- .../views/extension/ExtensionResourcesTab.vue | 48 +- ui/src/views/extension/RegisterExtension.vue | 2 +- .../extension/UpdateRegisteredExtension.vue | 131 + .../infra/network/ServiceProvidersTab.vue | 179 +- .../network/providers/ProviderListView.vue | 10 +- ui/src/views/offering/AddNetworkOffering.vue | 102 +- ui/src/views/offering/AddVpcOffering.vue | 125 +- 70 files changed, 9173 insertions(+), 217 deletions(-) create mode 100644 api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java create mode 100644 api/src/test/java/com/cloud/network/NetworkTest.java create mode 100644 framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java create mode 100644 framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java create mode 100644 framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md create mode 100644 framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java create mode 100644 test/integration/smoke/test_network_extension_namespace.py create mode 100644 ui/src/views/extension/UpdateRegisteredExtension.vue diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index 42395bf89992..f3c3fabc06fd 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -854,6 +854,7 @@ public class EventTypes { public static final String EVENT_EXTENSION_DELETE = "EXTENSION.DELETE"; public static final String EVENT_EXTENSION_RESOURCE_REGISTER = "EXTENSION.RESOURCE.REGISTER"; public static final String EVENT_EXTENSION_RESOURCE_UNREGISTER = "EXTENSION.RESOURCE.UNREGISTER"; + public static final String EVENT_EXTENSION_RESOURCE_UPDATE = "EXTENSION.RESOURCE.UPDATE"; public static final String EVENT_EXTENSION_CUSTOM_ACTION_ADD = "EXTENSION.CUSTOM.ACTION.ADD"; public static final String EVENT_EXTENSION_CUSTOM_ACTION_UPDATE = "EXTENSION.CUSTOM.ACTION.UPDATE"; public static final String EVENT_EXTENSION_CUSTOM_ACTION_DELETE = "EXTENSION.CUSTOM.ACTION.DELETE"; diff --git a/api/src/main/java/com/cloud/network/Network.java b/api/src/main/java/com/cloud/network/Network.java index 0846306f70f9..69dffb93ec8d 100644 --- a/api/src/main/java/com/cloud/network/Network.java +++ b/api/src/main/java/com/cloud/network/Network.java @@ -207,6 +207,7 @@ public static class Provider { public static final Provider Nsx = new Provider("Nsx", false); public static final Provider Netris = new Provider("Netris", false); + public static final Provider NetworkExtension = new Provider("NetworkExtension", false, true); private final String name; private final boolean isExternal; @@ -250,11 +251,47 @@ public static Provider getProvider(String providerName) { return null; } + /** Private constructor for transient (non-registered) providers. */ + private Provider(String name) { + this.name = name; + this.isExternal = false; + this.needCleanupOnShutdown = true; + // intentionally NOT added to supportedProviders + } + + /** + * Creates a transient (non-registered) {@link Provider} with the given name. + * + *

The new instance is not added to {@code supportedProviders}, so it + * will never be returned by {@link #getProvider(String)} and will not pollute the + * global provider registry. Use this for dynamic / extension-backed providers + * whose names are only known at runtime (e.g. NetworkOrchestrator extensions).

+ * + * @param name the provider name (typically the extension name) + * @return a transient {@link Provider} instance with the given name + */ + public static Provider createTransientProvider(String name) { + return new Provider(name); + } + @Override public String toString() { return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) .append("name", name) .toString(); } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Provider)) return false; + Provider provider = (Provider) obj; + return this.name.equals(provider.name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } } public static class Capability { diff --git a/api/src/main/java/com/cloud/network/NetworkModel.java b/api/src/main/java/com/cloud/network/NetworkModel.java index c212e6319eb4..7e1a07ebeb69 100644 --- a/api/src/main/java/com/cloud/network/NetworkModel.java +++ b/api/src/main/java/com/cloud/network/NetworkModel.java @@ -187,6 +187,8 @@ public interface NetworkModel { boolean canElementEnableIndividualServices(Provider provider); + boolean canElementEnableIndividualServicesByName(String providerName); + boolean areServicesSupportedInNetwork(long networkId, Service... services); boolean isNetworkSystem(Network network); @@ -237,6 +239,18 @@ public interface NetworkModel { String getDefaultGuestTrafficLabel(long dcId, HypervisorType vmware); + /** + * Resolves a provider name to a {@link Provider} instance. + * For known static providers, delegates to {@link Provider#getProvider(String)}. + * For dynamically-registered NetworkOrchestrator extension providers whose names + * are not in the static registry, returns a transient {@link Provider} with the + * given name so callers can still dispatch correctly. + * + * @param providerName the provider name from {@code ntwk_service_map} or similar + * @return a {@link Provider} instance, or {@code null} if not resolvable + */ + Provider resolveProvider(String providerName); + /** * @param providerName * @return diff --git a/api/src/main/java/com/cloud/network/NetworkService.java b/api/src/main/java/com/cloud/network/NetworkService.java index 53692f932a4e..0c684046fd8f 100644 --- a/api/src/main/java/com/cloud/network/NetworkService.java +++ b/api/src/main/java/com/cloud/network/NetworkService.java @@ -155,7 +155,7 @@ PhysicalNetwork createPhysicalNetwork(Long zoneId, String vnetRange, String netw Pair, Integer> searchPhysicalNetworks(Long id, Long zoneId, String keyword, Long startIndex, Long pageSize, String name); - PhysicalNetwork updatePhysicalNetwork(Long id, String networkSpeed, List tags, String newVnetRangeString, String state); + PhysicalNetwork updatePhysicalNetwork(Long id, String networkSpeed, List tags, String newVnetRangeString, String state, Map externalDetails); boolean deletePhysicalNetwork(Long id); diff --git a/api/src/main/java/com/cloud/network/element/NetworkElement.java b/api/src/main/java/com/cloud/network/element/NetworkElement.java index cb0fc2fca981..67be7b9ba2e2 100644 --- a/api/src/main/java/com/cloud/network/element/NetworkElement.java +++ b/api/src/main/java/com/cloud/network/element/NetworkElement.java @@ -146,4 +146,8 @@ boolean shutdownProviderInstances(PhysicalNetworkServiceProvider provider, Reser * @return true/false */ boolean verifyServicesCombination(Set services); + + default boolean rollingRestartSupported() { + return true; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/UpdatePhysicalNetworkCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/UpdatePhysicalNetworkCmd.java index 6a6264e418ce..1cd3202bf9e8 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/UpdatePhysicalNetworkCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/UpdatePhysicalNetworkCmd.java @@ -17,6 +17,7 @@ package org.apache.cloudstack.api.command.admin.network; import java.util.List; +import java.util.Map; import org.apache.cloudstack.api.APICommand; @@ -53,6 +54,12 @@ public class UpdatePhysicalNetworkCmd extends BaseAsyncCmd { @Parameter(name = ApiConstants.VLAN, type = CommandType.STRING, description = "The VLAN for the physical Network") private String vlan; + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs to be added to the extension-resource mapping. Use the format externaldetails[i].=. Example: externaldetails[0].endpoint.url=https://example.com", + since = "4.23.0") + protected Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -77,6 +84,10 @@ public String getVlan() { return vlan; } + public Map getExternalDetails() { + return convertDetailsToMap(externalDetails); + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// @@ -88,7 +99,7 @@ public long getEntityOwnerId() { @Override public void execute() { - PhysicalNetwork result = _networkService.updatePhysicalNetwork(getId(), getNetworkSpeed(), getTags(), getVlan(), getState()); + PhysicalNetwork result = _networkService.updatePhysicalNetwork(getId(), getNetworkSpeed(), getTags(), getVlan(), getState(), getExternalDetails()); if (result != null) { PhysicalNetworkResponse response = _responseGenerator.createPhysicalNetworkResponse(result); response.setResponseName(getCommandName()); diff --git a/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java b/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java index 33ff70fcace5..558340694b8a 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java +++ b/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java @@ -62,4 +62,12 @@ public Boolean getSuccess() { public void setResult(Map result) { this.result = result; } + + public Map getResult() { + return result; + } + + public boolean isSuccess() { + return Boolean.TRUE.equals(success); + } } diff --git a/api/src/main/java/org/apache/cloudstack/extension/Extension.java b/api/src/main/java/org/apache/cloudstack/extension/Extension.java index 3068612ed6fe..c1d905718b24 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/Extension.java +++ b/api/src/main/java/org/apache/cloudstack/extension/Extension.java @@ -24,7 +24,8 @@ public interface Extension extends InternalIdentity, Identity { enum Type { - Orchestrator + Orchestrator, + NetworkOrchestrator } enum State { Enabled, Disabled; diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java index 776b42f671b7..605cc8b6a79c 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java @@ -48,7 +48,8 @@ public interface ExtensionCustomAction extends InternalIdentity, Identity { enum ResourceType { - VirtualMachine(com.cloud.vm.VirtualMachine.class); + VirtualMachine(com.cloud.vm.VirtualMachine.class), + Network(com.cloud.network.Network.class); private final Class clazz; diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java index a01131278a76..2c373988feac 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java @@ -18,10 +18,99 @@ package org.apache.cloudstack.extension; import java.util.List; +import java.util.Map; + +import com.cloud.network.Network.Capability; +import com.cloud.network.Network.Service; public interface ExtensionHelper { Long getExtensionIdForCluster(long clusterId); Extension getExtension(long id); Extension getExtensionForCluster(long clusterId); List getExtensionReservedResourceDetails(long extensionId); + + /** + * Detail key used to store the comma-separated list of network services provided + * by a NetworkOrchestrator extension (e.g. {@code "SourceNat,StaticNat,Firewall"}). + */ + String NETWORK_SERVICES_DETAIL_KEY = "network.services"; + + /** + * Detail key used to store a JSON object mapping each service name to its + * CloudStack {@link com.cloud.network.Network.Capability} key/value pairs. + * Example: {@code {"SourceNat":{"SupportedSourceNatTypes":"peraccount"}}}. + * Used together with {@link #NETWORK_SERVICES_DETAIL_KEY}. + */ + String NETWORK_SERVICE_CAPABILITIES_DETAIL_KEY = "network.service.capabilities"; + + Long getExtensionIdForPhysicalNetwork(long physicalNetworkId); + Extension getExtensionForPhysicalNetwork(long physicalNetworkId); + String getExtensionScriptPath(Extension extension); + Map getExtensionDetails(long extensionId); + + /** + * Finds the extension registered with the given physical network whose name + * matches the given provider name (case-insensitive). Returns {@code null} + * if no matching extension is found. + * + *

This is the preferred lookup when multiple extensions are registered on + * the same physical network: the provider name stored in + * {@code ntwk_service_map} is used to pinpoint the exact extension that + * handles a given network.

+ * + * @param physicalNetworkId the physical network ID + * @param providerName the provider name (must equal the extension name) + * @return the matching {@link Extension}, or {@code null} + */ + Extension getExtensionForPhysicalNetworkAndProvider(long physicalNetworkId, String providerName); + + /** + * Returns ALL {@code extension_resource_map_details} (including hidden) for + * the specific extension registered on the given physical network. Used by + * {@code NetworkExtensionElement} to inject device credentials into the script + * environment for the correct extension when multiple different extensions are + * registered on the same physical network. + * + * @param physicalNetworkId the physical network ID + * @param extensionId the extension ID + * @return all key/value details including non-display ones, or an empty map + */ + Map getAllResourceMapDetailsForExtensionOnPhysicalNetwork(long physicalNetworkId, long extensionId); + + /** + * Returns {@code true} if the given provider name is backed by a + * {@code NetworkOrchestrator} extension registered on any physical network. + * This is used by {@code NetworkModelImpl} to detect extension-backed providers + * that are not in the static {@code s_providerToNetworkElementMap}. + * + * @param providerName the provider / extension name + * @return true if the provider is a NetworkExtension provider + */ + boolean isNetworkExtensionProvider(String providerName); + + /** + * List all registered extensions filtered by extension {@link Extension.Type}. + * Useful for callers that need to discover available providers of a given + * type (e.g. Orchestrator, NetworkOrchestrator). + * + * @param type extension type to filter by + * @return list of matching {@link Extension} instances (empty list if none) + */ + List listExtensionsByType(Extension.Type type); + + /** + * Returns the effective {@link Service} → ({@link Capability} → value) capabilities + * for the given external network provider, looking it up by name on the given + * physical network. + * + *

If {@code physicalNetworkId} is {@code null}, the method searches across all + * physical networks that have extensions registered and returns the capabilities for + * the first matching extension.

+ * + * @param physicalNetworkId physical network ID, or {@code null} for offering-level queries + * @param providerName provider / extension name + * @return capabilities map, or the default capabilities if no matching extension is found + */ + Map> getNetworkCapabilitiesForProvider(Long physicalNetworkId, String providerName); + } diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java index 40ebc19eb5e3..604e64a1f894 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java @@ -24,7 +24,8 @@ public interface ExtensionResourceMap extends InternalIdentity, Identity { enum ResourceType { - Cluster + Cluster, + PhysicalNetwork } long getExtensionId(); diff --git a/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java b/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java new file mode 100644 index 000000000000..61510924ed36 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.extension; + +import java.util.Map; + +import com.cloud.network.Network; + +/** + * Implemented by network elements that support running custom actions on a + * managed network (e.g. NetworkExtensionElement). + * + *

This interface is looked up by {@code ExtensionsManagerImpl} to dispatch + * {@code runCustomAction} requests whose resource type is {@code Network}.

+ */ +public interface NetworkCustomActionProvider { + + /** + * Returns {@code true} if this provider handles networks whose physical + * network has an ExternalNetwork service provider registered. + * + * @param network the target network + * @return {@code true} if this provider can handle the network + */ + boolean canHandleCustomAction(Network network); + + /** + * Runs a named custom action against the external network device that + * manages the given network. + * + * @param network the CloudStack network on which to run the action + * @param actionName the action name (e.g. {@code "reboot-device"}, {@code "dump-config"}) + * @param parameters optional parameters supplied by the caller + * @return output from the action script, or {@code null} on failure + */ + String runCustomAction(Network network, String actionName, Map parameters); +} + diff --git a/api/src/test/java/com/cloud/network/NetworkTest.java b/api/src/test/java/com/cloud/network/NetworkTest.java new file mode 100644 index 000000000000..ec4ae3c2160b --- /dev/null +++ b/api/src/test/java/com/cloud/network/NetworkTest.java @@ -0,0 +1,40 @@ +package com.cloud.network; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + + +public class NetworkTest { + + @Test + public void testProviderContains() { + List providers = new ArrayList<>(); + providers.add(Network.Provider.VirtualRouter); + + // direct instance present + assertTrue("List should contain VirtualRouter provider", providers.contains(Network.Provider.VirtualRouter)); + + // resolved provider by name (registered provider) + Network.Provider resolved = Network.Provider.getProvider("VirtualRouter"); + assertNotNull("Resolved provider should not be null", resolved); + assertTrue("List should contain resolved VirtualRouter provider", providers.contains(resolved)); + + // transient provider with same name should be considered equal (equals by name) + Network.Provider transientProvider = Network.Provider.createTransientProvider("NetworkExtension"); + assertFalse("List should not contain the transient provider", providers.contains(transientProvider)); + + providers.add(transientProvider); + assertTrue("List should contain the transient provider", providers.contains(transientProvider)); + + // another transient provider with same name should be considered equal + Network.Provider transientProviderNew = Network.Provider.createTransientProvider("NetworkExtension"); + assertTrue("List should contain the new transient provider with same name", providers.contains(transientProviderNew)); + } +} + diff --git a/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java b/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java index ae4314aa11a8..a6bd49a2d8be 100644 --- a/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java +++ b/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java @@ -40,6 +40,12 @@ public void testResourceType() { assertEquals(com.cloud.vm.VirtualMachine.class, vmType.getAssociatedClass()); } + @Test + public void testNetworkResourceType() { + ExtensionCustomAction.ResourceType networkType = ExtensionCustomAction.ResourceType.Network; + assertEquals(com.cloud.network.Network.class, networkType.getAssociatedClass()); + } + @Test public void testParameterTypeSupportsOptions() { assertTrue(ExtensionCustomAction.Parameter.Type.STRING.canSupportsOptions()); 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 2ae228adba94..e123b229b106 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 @@ -177,6 +177,10 @@ import com.cloud.network.dao.RemoteAccessVpnDao; import com.cloud.network.dao.RemoteAccessVpnVO; import com.cloud.network.dao.RouterNetworkDao; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.framework.extensions.network.NetworkExtensionElement; + import com.cloud.network.element.AggregatedCommandExecutor; import com.cloud.network.element.ConfigDriveNetworkElement; import com.cloud.network.element.DhcpServiceProvider; @@ -368,6 +372,10 @@ public class NetworkOrchestrator extends ManagerBase implements NetworkOrchestra private BGPService bgpService; @Inject private Ipv6GuestPrefixSubnetNetworkMapDao ipv6GuestPrefixSubnetNetworkMapDao; + @Inject + protected ExtensionHelper extensionHelper; + @Inject + private NetworkExtensionElement networkExtensionElement; @Override public List getNetworkGurus() { @@ -461,6 +469,28 @@ public void setDhcpProviders(final List dhcpProviders) { HashMap _lastNetworkIdsToFree = new HashMap<>(); + /** + * Returns the full list of network elements to iterate when implementing, + * shutting down, or otherwise orchestrating a network. + * + *

The base list ({@link #networkElements}, wired by Spring) is extended + * at runtime with one transient {@link NetworkExtensionElement} per + * registered {@code NetworkOrchestrator} extension. This keeps the + * Spring bean list free from {@code NetworkExtensionElement} and allows + * dynamic discovery of extensions without a restart.

+ */ + private List getNetworkElementsIncludingExtensions() { + List extensions = extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator); + if (extensions == null || extensions.isEmpty()) { + return networkElements; + } + List combined = new ArrayList<>(networkElements); + for (Extension ext : extensions) { + combined.add(networkExtensionElement.withProviderName(ext.getName())); + } + return combined; + } + private void updateRouterDefaultDns(final VirtualMachineProfile vmProfile, final NicProfile nicProfile) { if (!Type.DomainRouter.equals(vmProfile.getType()) || !nicProfile.isDefaultNic()) { return; @@ -1685,7 +1715,7 @@ public void implementNetworkElementsAndResources(final DeployDestination dest, f } } - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (element instanceof AggregatedCommandExecutor && providersToImplement.contains(element.getProvider())) { ((AggregatedCommandExecutor) element).prepareAggregatedExecution(network, dest); } @@ -1702,7 +1732,7 @@ public void implementNetworkElementsAndResources(final DeployDestination dest, f ex.addProxyObject(_entityMgr.findById(DataCenter.class, network.getDataCenterId()).getUuid()); throw ex; } - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (element instanceof AggregatedCommandExecutor && providersToImplement.contains(element.getProvider())) { if (!((AggregatedCommandExecutor) element).completeAggregatedExecution(network, dest)) { logger.warn("Failed to re-program the network as a part of network {} implement due to aggregated commands execution failure!", network); @@ -1716,7 +1746,7 @@ public void implementNetworkElementsAndResources(final DeployDestination dest, f } reconfigureAndApplyStaticRouteForVpcVpn(network); } finally { - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (element instanceof AggregatedCommandExecutor && providersToImplement.contains(element.getProvider())) { ((AggregatedCommandExecutor) element).cleanupAggregatedExecution(network, dest); } @@ -1737,7 +1767,7 @@ private void reconfigureAndApplyStaticRouteForVpcVpn(Network network) { private void implementNetworkElements(final DeployDestination dest, final ReservationContext context, final Network network, final NetworkOffering offering, final List providersToImplement) throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { // The physicalNetworkId will not get translated into a uuid by the response serializer, @@ -2030,7 +2060,7 @@ public void doInTransactionWithoutResult(TransactionStatus status) { @Override public void configureUpdateInSequence(Network network) { List providers = getNetworkProviders(network.getId()); - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providers.contains(element.getProvider())) { if (element instanceof RedundantResource) { ((RedundantResource) element).configureResource(network); @@ -2043,7 +2073,7 @@ public void configureUpdateInSequence(Network network) { public int getResourceCount(Network network) { List providers = getNetworkProviders(network.getId()); int resourceCount = 0; - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providers.contains(element.getProvider())) { //currently only one element implements the redundant resource interface if (element instanceof RedundantResource) { @@ -2074,7 +2104,7 @@ public void configureExtraDhcpOptions(Network network, long nicId) { @Override public void finalizeUpdateInSequence(Network network, boolean success) { List providers = getNetworkProviders(network.getId()); - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providers.contains(element.getProvider())) { //currently only one element implements the redundant resource interface if (element instanceof RedundantResource) { @@ -2101,7 +2131,7 @@ public void setHypervisorHostname(VirtualMachineProfile vm, DeployDestination de } private void setHypervisorHostnameInNetwork(VirtualMachineProfile vm, DeployDestination dest, Network network, NicProfile profile, boolean migrationSuccessful) { - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (_networkModel.areServicesSupportedInNetwork(network.getId(), Service.UserData) && element instanceof UserDataServiceProvider && (element instanceof ConfigDriveNetworkElement && !migrationSuccessful || element instanceof VirtualRouterElement && migrationSuccessful)) { String errorMsg = String.format("Failed to add hypervisor host name while applying the userdata during the migration of VM %s, " + @@ -2229,7 +2259,7 @@ public NicProfile prepareNic(final VirtualMachineProfile vmProfile, final Deploy updateNic(nic, network, 1); final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -2284,7 +2314,7 @@ public void prepareNicForMigration(final VirtualMachineProfile vm, final DeployD } final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -2328,7 +2358,7 @@ public void prepareAllNicsForMigration(final VirtualMachineProfile vm, final Dep } } final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException(String.format("Service provider %s either doesn't exist or is not enabled in physical network: %s", @@ -2410,7 +2440,7 @@ public void commitNicForMigration(final VirtualMachineProfile src, final Virtual } final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -2446,7 +2476,7 @@ public void rollbackNicForMigration(final VirtualMachineProfile src, final Virtu } final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -2533,7 +2563,7 @@ public Pair doInTransaction(final TransactionStatus status) final Network network = networkToRelease.first(); final NicProfile profile = networkToRelease.second(); final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { logger.debug("Asking {} to release {}", element.getName(), profile); //NOTE: Context appear to never be used in release method @@ -2596,7 +2626,7 @@ protected void removeNic(final VirtualMachineProfile vm, final NicVO nic) { */ if (nic.getReservationStrategy() == Nic.ReservationStrategy.Create) { final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { logger.debug("Asking {} to release {}, according to the reservation strategy {}.", element.getName(), nic, nic.getReservationStrategy()); try { @@ -3311,7 +3341,7 @@ public boolean shutdownNetworkElementsAndResources(final ReservationContext cont // 2) Shutdown all the network elements boolean success = true; - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToShutdown.contains(element.getProvider())) { try { logger.debug("Sending network shutdown to {}", element.getName()); @@ -3422,7 +3452,7 @@ public boolean destroyNetwork(final long networkId, final ReservationContext con // get providers to destroy final List providersToDestroy = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToDestroy.contains(element.getProvider())) { try { logger.debug("Sending destroy to {}", element); @@ -3793,7 +3823,7 @@ public boolean areRoutersRunning(final List routers) { public void cleanupNicDhcpDnsEntry(Network network, VirtualMachineProfile vmProfile, NicProfile nicProfile) { final List networkProviders = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (networkProviders.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -3829,7 +3859,7 @@ public void cleanupNicDhcpDnsEntry(Network network, VirtualMachineProfile vmProf * @throws InsufficientCapacityException */ private boolean rollingRestartRouters(final NetworkVO network, final NetworkOffering offering, final DeployDestination dest, final ReservationContext context) throws ResourceUnavailableException, ConcurrentOperationException, InsufficientCapacityException { - if (!NetworkOrchestrationService.RollingRestartEnabled.value()) { + if (!isRollingRestartSupport(network)) { if (shutdownNetworkElementsAndResources(context, true, network)) { implementNetworkElementsAndResources(dest, context, network, offering); return true; @@ -3877,6 +3907,20 @@ private boolean rollingRestartRouters(final NetworkVO network, final NetworkOffe return areRoutersRunning(routerDao.findByNetwork(network.getId())); } + private boolean isRollingRestartSupport(final NetworkVO network) { + if (!NetworkOrchestrator.RollingRestartEnabled.value()) { + return false; + } + List services = _ntwkSrvcDao.getServicesInNetwork(network.getId()); + for (NetworkServiceMapVO service : services) { + NetworkElement element = _networkModel.getElementImplementingProvider(service.getProvider()); + if (element == null || !element.rollingRestartSupported()) { + return false; + } + } + return true; + } + private void setRestartRequired(final NetworkVO network, final boolean restartRequired) { logger.debug("Marking network {} with restartRequired={}", network, restartRequired); network.setRestartRequired(restartRequired); @@ -4438,6 +4482,8 @@ public Map finalizeServicesAndProvidersForNetwork(final NetworkO if (provider == null) { provider = _networkModel.getDefaultUniqueProviderForService(service).getName(); + } else { + provider = _networkModel.resolveProvider(provider).getName(); } // check that provider is supported @@ -4463,7 +4509,7 @@ private List getNetworkProviders(final long networkId) { final List providerNames = _ntwkSrvcDao.getDistinctProviders(networkId); final List providers = new ArrayList<>(); for (final String providerName : providerNames) { - providers.add(Network.Provider.getProvider(providerName)); + providers.add(_networkModel.resolveProvider(providerName)); } return providers; @@ -4629,7 +4675,7 @@ private Map> getServiceProvidersMap(final long networkId) if (providers == null) { providers = new HashSet<>(); } - providers.add(Provider.getProvider(nsm.getProvider())); + providers.add(_networkModel.resolveProvider(nsm.getProvider())); map.put(Service.getService(nsm.getService()), providers); } return map; @@ -4914,10 +4960,10 @@ public void unmanageNics(VirtualMachineProfile vm) { @Override public void expungeLbVmRefs(List vmIds, Long batchSize) { - if (CollectionUtils.isEmpty(networkElements) || CollectionUtils.isEmpty(vmIds)) { + if (CollectionUtils.isEmpty(getNetworkElementsIncludingExtensions()) || CollectionUtils.isEmpty(vmIds)) { return; } - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (element instanceof LoadBalancingServiceProvider) { LoadBalancingServiceProvider lbProvider = (LoadBalancingServiceProvider)element; lbProvider.expungeLbVmRefs(vmIds, batchSize); diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java index e3989737112d..58f3cbe84735 100644 --- a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java @@ -35,6 +35,7 @@ import com.cloud.exception.InsufficientVirtualNetworkCapacityException; import com.cloud.network.IpAddressManager; import com.cloud.utils.Pair; +import org.apache.cloudstack.extension.ExtensionHelper; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -135,6 +136,7 @@ public void setUp() { testOrchestrator.routerJoinDao = mock(DomainRouterJoinDao.class); testOrchestrator._ipAddrMgr = mock(IpAddressManager.class); testOrchestrator._entityMgr = mock(EntityManager.class); + testOrchestrator.extensionHelper = mock(ExtensionHelper.class); DhcpServiceProvider provider = mock(DhcpServiceProvider.class); Map capabilities = new HashMap(); diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java index 9f7ffabac930..e2dddeed13c4 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java @@ -37,7 +37,6 @@ import com.cloud.network.Network; import com.cloud.network.Network.Event; import com.cloud.network.Network.GuestType; -import com.cloud.network.Network.Provider; import com.cloud.network.Network.Service; import com.cloud.network.Network.State; import com.cloud.network.Networks.BroadcastDomainType; @@ -390,7 +389,7 @@ public void persistNetworkServiceProviders(final long networkId, final Map> ser txn.start(); for (String service : serviceProviderMap.keySet()) { for (String provider : serviceProviderMap.get(service)) { - VpcServiceMapVO serviceMap = new VpcServiceMapVO(vpcId, Network.Service.getService(service), Network.Provider.getProvider(provider)); + VpcServiceMapVO serviceMap = new VpcServiceMapVO(vpcId, Network.Service.getService(service).getName(), provider); _vpcSvcMap.persist(serviceMap); } } diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index 4cb9eb7cb2c4..0c033c19316d 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -117,3 +117,7 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tin --- Disable/enable NICs CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.nics','enabled', 'TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''Indicates whether the NIC is enabled or not'' '); + +-- Increase length of value of extension details from 255 to 4096 to support longer details value +CALL `cloud`.`IDEMPOTENT_CHANGE_COLUMN`('cloud.extension_details', 'value', 'value', 'VARCHAR(4096)'); +CALL `cloud`.`IDEMPOTENT_CHANGE_COLUMN`('cloud.extension_resource_map_details', 'value', 'value', 'VARCHAR(4096)'); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java index 4426f259380b..1c9efdaca782 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java @@ -70,6 +70,17 @@ public class ListExtensionsCmd extends BaseListCmd { + " When no parameters are passed, all the details are returned.") private List details; + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, description = "Type of the extension (e.g. Orchestrator, NetworkOrchestrator). Default is Orchestrator if not set") + private String type; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, + description = "ID of the resource to list registered extensions for (e.g. cluster UUID, physical network UUID)") + private String resourceId; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, + description = "Type of the resource (e.g. Cluster, PhysicalNetwork). Default is Cluster if not set") + private String resourceType; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -82,6 +93,18 @@ public Long getExtensionId() { return extensionId; } + public String getType() { + return type; + } + + public String getResourceId() { + return resourceId; + } + + public String getResourceType() { + return resourceType; + } + public EnumSet getDetails() throws InvalidParameterValueException { if (CollectionUtils.isEmpty(details)) { return EnumSet.of(ApiConstants.ExtensionDetails.all); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java new file mode 100644 index 000000000000..02a46b093938 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.api; + +import java.util.EnumSet; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.user.Account; + +@APICommand(name = "updateRegisteredExtension", + description = "Update details for an extension registered with a resource", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + entityType = {Extension.class}, + authorized = {RoleType.Admin}, + since = "4.23.0") +public class UpdateRegisteredExtensionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, required = true, + entityType = ExtensionResponse.class, description = "ID of the extension") + private Long extensionId; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, required = true, + description = "ID of the resource where the extension is registered") + private String resourceId; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, required = true, + description = "Type of the resource") + private String resourceType; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "Details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].endpoint.url=urlvalue") + protected Map details; + + @Parameter(name = ApiConstants.CLEAN_UP_DETAILS, + type = CommandType.BOOLEAN, + description = "Optional boolean field, which indicates if details should be cleaned up or not " + + "(If set to true, details removed for this registration, details field ignored; " + + "if false or not set, details can be updated through details map)") + private Boolean cleanupDetails; + + public Long getExtensionId() { + return extensionId; + } + + public String getResourceId() { + return resourceId; + } + + public String getResourceType() { + return resourceType; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + public Boolean isCleanupDetails() { + return cleanupDetails; + } + + @Override + public void execute() throws ServerApiException { + Extension extension = extensionsManager.updateRegisteredExtensionWithResource(this); + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, + EnumSet.of(ApiConstants.ExtensionDetails.all)); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Extension; + } + + @Override + public Long getApiResourceId() { + return getExtensionId(); + } +} + diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java index 3355457ed25b..9bb47e4869b2 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java @@ -16,6 +16,9 @@ // under the License. package org.apache.cloudstack.framework.extensions.dao; +import java.util.List; + +import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; import com.cloud.utils.db.GenericDao; @@ -23,4 +26,6 @@ public interface ExtensionDao extends GenericDao { ExtensionVO findByName(String name); + + List listByType(Extension.Type type); } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java index 8e17199de6ca..ed215aa53d03 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java @@ -17,6 +17,9 @@ package org.apache.cloudstack.framework.extensions.dao; +import java.util.List; + +import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; import com.cloud.utils.db.GenericDaoBase; @@ -39,7 +42,13 @@ public ExtensionDaoImpl() { public ExtensionVO findByName(String name) { SearchCriteria sc = AllFieldSearch.create(); sc.setParameters("name", name); - return findOneBy(sc); } + + @Override + public List listByType(Extension.Type type) { + SearchCriteria sc = AllFieldSearch.create(); + sc.setParameters("type", type); + return listBy(sc); + } } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java index 930ef8675531..81da9ecb18fa 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java @@ -28,5 +28,9 @@ public interface ExtensionResourceMapDao extends GenericDao listResourceIdsByExtensionIdAndType(long extensionId,ExtensionResourceMap.ResourceType resourceType); + List listByResourceIdAndType(long resourceId, ExtensionResourceMap.ResourceType resourceType); + + List listResourceIdsByExtensionIdAndType(long extensionId, ExtensionResourceMap.ResourceType resourceType); + + List listResourceIdsByType(ExtensionResourceMap.ResourceType resourceType); } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java index 6f19ef8b8b66..f81a10211207 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java @@ -55,6 +55,15 @@ public ExtensionResourceMapVO findByResourceIdAndType(long resourceId, return findOneBy(sc); } + @Override + public List listByResourceIdAndType(long resourceId, + ExtensionResourceMap.ResourceType resourceType) { + SearchCriteria sc = genericSearch.create(); + sc.setParameters("resourceId", resourceId); + sc.setParameters("resourceType", resourceType); + return listBy(sc); + } + @Override public List listResourceIdsByExtensionIdAndType(long extensionId, ExtensionResourceMap.ResourceType resourceType) { GenericSearchBuilder sb = createSearchBuilder(Long.class); @@ -67,4 +76,15 @@ public List listResourceIdsByExtensionIdAndType(long extensionId, Extensio sc.setParameters("resourceType", resourceType); return customSearch(sc, null); } + + @Override + public List listResourceIdsByType(ExtensionResourceMap.ResourceType resourceType) { + GenericSearchBuilder sb = createSearchBuilder(Long.class); + sb.selectFields(sb.entity().getResourceId()); + sb.and("resourceType", sb.entity().getResourceType(), SearchCriteria.Op.EQ); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("resourceType", resourceType); + return customSearch(sc, null); + } } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java index 1b1a175c5975..cd033badff15 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java @@ -42,6 +42,7 @@ import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateRegisteredExtensionCmd; import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; import com.cloud.agent.api.Answer; @@ -65,6 +66,8 @@ public interface ExtensionsManager extends Manager { Extension unregisterExtensionWithResource(UnregisterExtensionCmd cmd); + Extension updateRegisteredExtensionWithResource(UpdateRegisteredExtensionCmd cmd); + Extension updateExtension(UpdateExtensionCmd cmd); Extension registerExtensionWithResource(RegisterExtensionCmd cmd); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java index 1422338ddc99..5ee42ca4334e 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java @@ -26,6 +26,7 @@ import java.nio.file.Paths; import java.security.InvalidParameterException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; @@ -61,6 +62,7 @@ import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.extension.ExtensionCustomAction; import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.extension.NetworkCustomActionProvider; import org.apache.cloudstack.extension.ExtensionResourceMap; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; @@ -75,6 +77,7 @@ import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateRegisteredExtensionCmd; import org.apache.cloudstack.framework.extensions.command.CleanupExtensionFilesCommand; import org.apache.cloudstack.framework.extensions.command.ExtensionRoutingUpdateCommand; import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; @@ -125,6 +128,19 @@ import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.ExternalProvisioner; import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.Network; +import com.cloud.network.Network.Capability; +import com.cloud.network.Network.Service; +import com.cloud.network.NetworkModel; +import com.cloud.network.PhysicalNetworkServiceProvider; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkServiceMapDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.PhysicalNetworkServiceProviderDao; +import com.cloud.network.dao.PhysicalNetworkServiceProviderVO; +import com.cloud.network.element.NetworkElement; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkVO; import com.cloud.org.Cluster; import com.cloud.serializer.GsonHelper; import com.cloud.storage.dao.VMTemplateDao; @@ -141,6 +157,10 @@ import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.Transaction; import com.cloud.utils.db.TransactionCallbackWithException; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineManager; @@ -171,6 +191,12 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana @Inject ClusterDao clusterDao; + @Inject + PhysicalNetworkDao physicalNetworkDao; + + @Inject + PhysicalNetworkServiceProviderDao physicalNetworkServiceProviderDao; + @Inject AgentManager agentMgr; @@ -210,6 +236,15 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana @Inject VMTemplateDao templateDao; + @Inject + NetworkDao networkDao; + + @Inject + NetworkServiceMapDao networkServiceMapDao; + + @Inject + NetworkModel networkModel; + @Inject RoleService roleService; @@ -339,6 +374,39 @@ protected Pair getChecksumForExtensionPathOnMSPeer(Extension ex return getResultFromAnswersString(answersStr, extension, msHost, "get path checksum"); } + protected List buildExtensionResourceDetailsArray(long extensionResourceMapId, + Map details) { + List detailsList = new ArrayList<>(); + if (MapUtils.isEmpty(details)) { + return detailsList; + } + for (Map.Entry entry : details.entrySet()) { + boolean display = !SENSITIVE_DETAIL_KEYS.contains(entry.getKey().toLowerCase()); + detailsList.add(new ExtensionResourceMapDetailsVO(extensionResourceMapId, entry.getKey(), + entry.getValue(), display)); + } + return detailsList; + } + + protected void appendHiddenExtensionResourceDetails(long extensionResourceMapId, + List detailsList) { + if (CollectionUtils.isEmpty(detailsList)) { + return; + } + Map hiddenDetails = extensionResourceMapDetailsDao.listDetailsKeyPairs(extensionResourceMapId, false); + if (MapUtils.isEmpty(hiddenDetails)) { + return; + } + Set requestedKeys = detailsList.stream() + .map(ExtensionResourceMapDetailsVO::getName) + .collect(Collectors.toSet()); + hiddenDetails.forEach((key, value) -> { + if (!requestedKeys.contains(key)) { + detailsList.add(new ExtensionResourceMapDetailsVO(extensionResourceMapId, key, value, false)); + } + }); + } + protected List getParametersListFromMap(String actionName, Map parametersMap) { if (MapUtils.isEmpty(parametersMap)) { return Collections.emptyList(); @@ -370,16 +438,42 @@ protected Extension getExtensionFromResource(ExtensionCustomAction.ResourceType VirtualMachine vm = (VirtualMachine) object; Pair clusterHostId = virtualMachineManager.findClusterAndHostIdForVm(vm, false); clusterId = clusterHostId.first(); + if (clusterId == null) { + return null; + } + ExtensionResourceMapVO mapVO = + extensionResourceMapDao.findByResourceIdAndType(clusterId, ExtensionResourceMap.ResourceType.Cluster); + if (mapVO == null) { + return null; + } + return extensionDao.findById(mapVO.getExtensionId()); + } else if (resourceType == ExtensionCustomAction.ResourceType.Network) { + Network network = (Network) object; + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + return null; + } + // Use provider-based lookup: match the network's service-map providers + // against extension names registered on the physical network. + // This correctly handles multiple different extensions on the same physical network. + List providers = networkServiceMapDao.getDistinctProviders(network.getId()); + if (CollectionUtils.isNotEmpty(providers)) { + for (String providerName : providers) { + Extension ext = getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName); + if (ext != null) { + return ext; + } + } + } + // Fallback: return the first extension registered on the physical network + List maps = extensionResourceMapDao.listByResourceIdAndType( + physicalNetworkId, ExtensionResourceMap.ResourceType.PhysicalNetwork); + if (CollectionUtils.isEmpty(maps)) { + return null; + } + return extensionDao.findById(maps.get(0).getExtensionId()); } - if (clusterId == null) { - return null; - } - ExtensionResourceMapVO mapVO = - extensionResourceMapDao.findByResourceIdAndType(clusterId, ExtensionResourceMap.ResourceType.Cluster); - if (mapVO == null) { - return null; - } - return extensionDao.findById(mapVO.getExtensionId()); + return null; } protected String getActionMessage(boolean success, ExtensionCustomAction action, Extension extension, @@ -694,25 +788,68 @@ public List listExtensions(ListExtensionsCmd cmd) { Long id = cmd.getExtensionId(); String name = cmd.getName(); String keyword = cmd.getKeyword(); + String typeStr = cmd.getType(); + String resourceIdStr = cmd.getResourceId(); + String resourceTypeStr = cmd.getResourceType(); + + // If resourceId + resourceType are specified, return only extensions registered to that resource + if (StringUtils.isNotBlank(resourceIdStr) && StringUtils.isNotBlank(resourceTypeStr)) { + if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceTypeStr)) { + throw new InvalidParameterValueException("Invalid resourcetype: " + resourceTypeStr); + } + ExtensionResourceMap.ResourceType resType = ExtensionResourceMap.ResourceType.valueOf(resourceTypeStr); + // Resolve resourceId to a DB id + long resolvedResourceId; + if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(resType)) { + PhysicalNetworkVO pn = physicalNetworkDao.findByUuid(resourceIdStr); + if (pn == null) { + try { pn = physicalNetworkDao.findById(Long.parseLong(resourceIdStr)); } catch (NumberFormatException ignored) {} + } + if (pn == null) throw new InvalidParameterValueException("Invalid physical network ID: " + resourceIdStr); + resolvedResourceId = pn.getId(); + } else { + try { resolvedResourceId = Long.parseLong(resourceIdStr); } catch (NumberFormatException e) { + throw new InvalidParameterValueException("Invalid resource ID: " + resourceIdStr); + } + } + List maps = extensionResourceMapDao.listByResourceIdAndType(resolvedResourceId, resType); + List responses = new ArrayList<>(); + for (ExtensionResourceMapVO map : maps) { + ExtensionVO ext = extensionDao.findById(map.getExtensionId()); + if (ext == null) continue; + if (typeStr != null && !typeStr.equalsIgnoreCase(ext.getType().name())) continue; + if (name != null && !name.equalsIgnoreCase(ext.getName())) continue; + responses.add(createExtensionResponse(ext, cmd.getDetails())); + } + return responses; + } + final SearchBuilder sb = extensionDao.createSearchBuilder(); final Filter searchFilter = new Filter(ExtensionVO.class, "id", false, cmd.getStartIndex(), cmd.getPageSizeVal()); sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); sb.and("name", sb.entity().getName(), SearchCriteria.Op.EQ); sb.and("keyword", sb.entity().getName(), SearchCriteria.Op.LIKE); + sb.and("type", sb.entity().getType(), SearchCriteria.Op.EQ); + sb.done(); final SearchCriteria sc = sb.create(); if (id != null) { sc.setParameters("id", id); } - if (name != null) { sc.setParameters("name", name); } - if (keyword != null) { sc.setParameters("keyword", "%" + keyword + "%"); } + if (typeStr != null) { + Extension.Type type = EnumUtils.getEnum(Extension.Type.class, typeStr); + if (type == null) { + throw new InvalidParameterValueException("Invalid type: " + typeStr); + } + sc.setParameters("type", type); + } final Pair, Integer> result = extensionDao.searchAndCount(sc, searchFilter); List responses = new ArrayList<>(); @@ -880,19 +1017,108 @@ public Extension registerExtensionWithResource(RegisterExtensionCmd cmd) { String resourceType = cmd.getResourceType(); if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceType)) { throw new InvalidParameterValueException( - String.format("Currently only [%s] can be used to register an extension of type Orchestrator", + String.format("Currently only [%s] can be used to register an extension", EnumSet.allOf(ExtensionResourceMap.ResourceType.class))); } + ExtensionVO extension = extensionDao.findById(extensionId); + if (extension == null) { + throw new InvalidParameterValueException("Invalid extension specified"); + } + ExtensionResourceMap.ResourceType resType = ExtensionResourceMap.ResourceType.valueOf(resourceType); + if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(resType)) { + PhysicalNetworkVO physicalNetwork = physicalNetworkDao.findByUuid(resourceId); + if (physicalNetwork == null) { + physicalNetwork = physicalNetworkDao.findById(Long.parseLong(resourceId)); + } + if (physicalNetwork == null) { + throw new InvalidParameterValueException("Invalid physical network ID specified"); + } + ExtensionResourceMap extensionResourceMap = registerExtensionWithPhysicalNetwork(physicalNetwork, extension, cmd.getDetails()); + return extensionDao.findById(extensionResourceMap.getExtensionId()); + } ClusterVO clusterVO = clusterDao.findByUuid(resourceId); if (clusterVO == null) { throw new InvalidParameterValueException("Invalid cluster ID specified"); } + ExtensionResourceMap extensionResourceMap = registerExtensionWithCluster(clusterVO, extension, cmd.getDetails()); + return extensionDao.findById(extensionResourceMap.getExtensionId()); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_RESOURCE_UPDATE, eventDescription = "updating extension resource") + public Extension updateRegisteredExtensionWithResource(UpdateRegisteredExtensionCmd cmd) { + final String resourceId = cmd.getResourceId(); + final Long extensionId = cmd.getExtensionId(); + final String resourceType = cmd.getResourceType(); + final Map details = cmd.getDetails(); + final Boolean cleanupDetails = cmd.isCleanupDetails(); + + if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceType)) { + throw new InvalidParameterValueException( + String.format("Currently only [%s] can be used to update an extension registration", + EnumSet.allOf(ExtensionResourceMap.ResourceType.class))); + } ExtensionVO extension = extensionDao.findById(extensionId); if (extension == null) { throw new InvalidParameterValueException("Invalid extension specified"); } - ExtensionResourceMap extensionResourceMap = registerExtensionWithCluster(clusterVO, extension, cmd.getDetails()); - return extensionDao.findById(extensionResourceMap.getExtensionId()); + + ExtensionResourceMap.ResourceType resType = ExtensionResourceMap.ResourceType.valueOf(resourceType); + long resolvedResourceId; + if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(resType)) { + PhysicalNetworkVO physicalNetwork = physicalNetworkDao.findByUuid(resourceId); + if (physicalNetwork == null) { + try { + physicalNetwork = physicalNetworkDao.findById(Long.parseLong(resourceId)); + } catch (NumberFormatException ignored) { + } + } + if (physicalNetwork == null) { + throw new InvalidParameterValueException("Invalid physical network ID specified"); + } + resolvedResourceId = physicalNetwork.getId(); + } else { + ClusterVO clusterVO = clusterDao.findByUuid(resourceId); + if (clusterVO == null) { + throw new InvalidParameterValueException("Invalid cluster ID specified"); + } + resolvedResourceId = clusterVO.getId(); + } + + List mappings = extensionResourceMapDao.listByResourceIdAndType(resolvedResourceId, resType); + ExtensionResourceMapVO targetMapping = null; + if (CollectionUtils.isNotEmpty(mappings)) { + for (ExtensionResourceMapVO mapping : mappings) { + if (mapping.getExtensionId() == extensionId) { + targetMapping = mapping; + break; + } + } + } + if (targetMapping == null) { + throw new InvalidParameterValueException(String.format( + "Extension '%s' is not registered with resource %s (%s)", + extension.getName(), resourceId, resourceType)); + } + + if (Boolean.TRUE.equals(cleanupDetails)) { + extensionResourceMapDetailsDao.removeDetails(targetMapping.getId()); + } else { + List detailsList = buildExtensionResourceDetailsArray(targetMapping.getId(), details); + if (CollectionUtils.isNotEmpty(detailsList)) { + appendHiddenExtensionResourceDetails(targetMapping.getId(), detailsList); + } + detailsList = detailsList.stream() + .filter(detail -> detail.getValue() != null) + .collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(detailsList)) { + extensionResourceMapDetailsDao.saveDetails(detailsList); + } else { + extensionResourceMapDetailsDao.removeDetails(targetMapping.getId()); + } + } + + return extensionDao.findById(extensionId); } @Override @@ -923,8 +1149,9 @@ public ExtensionResourceMap registerExtensionWithCluster(Cluster cluster, Extens List detailsVOList = new ArrayList<>(); if (MapUtils.isNotEmpty(details)) { for (Map.Entry entry : details.entrySet()) { + boolean display = !SENSITIVE_DETAIL_KEYS.contains(entry.getKey().toLowerCase()); detailsVOList.add(new ExtensionResourceMapDetailsVO(savedExtensionMap.getId(), - entry.getKey(), entry.getValue())); + entry.getKey(), entry.getValue(), display)); } extensionResourceMapDetailsDao.saveDetails(detailsVOList); } @@ -934,6 +1161,247 @@ public ExtensionResourceMap registerExtensionWithCluster(Cluster cluster, Extens return result; } + protected ExtensionResourceMap registerExtensionWithPhysicalNetwork(PhysicalNetworkVO physicalNetwork, + Extension extension, Map details) { + // Only NetworkOrchestrator extensions can be registered with physical networks + if (!Extension.Type.NetworkOrchestrator.equals(extension.getType())) { + throw new InvalidParameterValueException(String.format( + "Only extensions of type %s can be registered with a physical network. " + + "Extension '%s' is of type %s.", + Extension.Type.NetworkOrchestrator.name(), + extension.getName(), extension.getType().name())); + } + + // Block registering the exact same extension twice on the same physical network + final ExtensionResourceMap.ResourceType resourceType = ExtensionResourceMap.ResourceType.PhysicalNetwork; + List existing = extensionResourceMapDao.listByResourceIdAndType( + physicalNetwork.getId(), resourceType); + if (existing != null) { + for (ExtensionResourceMapVO ex : existing) { + if (ex.getExtensionId() == extension.getId()) { + throw new CloudRuntimeException(String.format( + "Extension '%s' is already registered with physical network %s", + extension.getName(), physicalNetwork.getId())); + } + } + } + + // Resolve which services this extension provides from its network.services detail + Set services = resolveExtensionServices(extension); + + return Transaction.execute((TransactionCallbackWithException) status -> { + // 1. Persist the extension<->physical-network mapping + ExtensionResourceMapVO extensionMap = new ExtensionResourceMapVO(extension.getId(), + physicalNetwork.getId(), resourceType); + ExtensionResourceMapVO savedExtensionMap = extensionResourceMapDao.persist(extensionMap); + + // 2. Persist device credentials / details + List detailsVOList = new ArrayList<>(); + if (MapUtils.isNotEmpty(details)) { + for (Map.Entry entry : details.entrySet()) { + boolean display = !SENSITIVE_DETAIL_KEYS.contains(entry.getKey().toLowerCase()); + detailsVOList.add(new ExtensionResourceMapDetailsVO(savedExtensionMap.getId(), + entry.getKey(), entry.getValue(), display)); + } + extensionResourceMapDetailsDao.saveDetails(detailsVOList); + } + + // 3. Auto-create the NetworkServiceProvider entry for this extension so that + // the services are visible in the UI and in listSupportedNetworkServices. + // The NSP name equals the extension name; state is Enabled by default. + PhysicalNetworkServiceProviderVO existingNsp = + physicalNetworkServiceProviderDao.findByServiceProvider( + physicalNetwork.getId(), extension.getName()); + if (existingNsp == null) { + PhysicalNetworkServiceProviderVO nsp = + new PhysicalNetworkServiceProviderVO(physicalNetwork.getId(), extension.getName()); + applyServicesToNsp(nsp, services); + nsp.setState(PhysicalNetworkServiceProvider.State.Enabled); + physicalNetworkServiceProviderDao.persist(nsp); + logger.info("Auto-created NetworkServiceProvider '{}' (Enabled) for physical network {} " + + "with services {}", extension.getName(), physicalNetwork.getId(), services); + } + + return extensionMap; + }); + } + + /** + * Resolves the set of network service names declared in the extension's + * {@code network.services} detail. Falls back to an empty set if not present + */ + private Set resolveExtensionServices(Extension extension) { + Map extDetails = extensionDetailsDao.listDetailsKeyPairs(extension.getId()); + Set parsed = parseServicesFromDetailKeys(extDetails); + if (!parsed.isEmpty()) { + return parsed; + } + // Default: the full set of services NetworkExtensionElement supports + return new HashSet<>(); + } + + /** + * Resolves the set of service names from the extension detail map. + * From {@code network.services} comma-separated key. + */ + @SuppressWarnings("deprecation") + private Set parseServicesFromDetailKeys(Map extDetails) { + if (extDetails == null) { + return Collections.emptySet(); + } + // New format: "network.services" = "SourceNat,StaticNat,..." + if (extDetails.containsKey(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY)) { + String value = extDetails.get(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY); + if (StringUtils.isNotBlank(value)) { + Set services = new HashSet<>(); + for (String s : value.split(",")) { + String trimmed = s.trim(); + if (!trimmed.isEmpty()) { + services.add(trimmed); + } + } + if (!services.isEmpty()) { + return services; + } + } + } + + return Collections.emptySet(); + } + + /** + * Builds a full {@code Map>} from the + * extension detail map. From the split keys + * {@code network.services} + {@code network.service.capabilities}. + */ + @SuppressWarnings("deprecation") + private Map> buildCapabilitiesFromDetailKeys( + Map extDetails) { + if (extDetails == null) { + return new HashMap<>(); + } + // New split format + if (extDetails.containsKey(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY)) { + Set serviceNames = parseServicesFromDetailKeys(extDetails); + if (!serviceNames.isEmpty()) { + JsonObject capsObj = null; + if (extDetails.containsKey(ExtensionHelper.NETWORK_SERVICE_CAPABILITIES_DETAIL_KEY)) { + try { + capsObj = JsonParser.parseString( + extDetails.get(ExtensionHelper.NETWORK_SERVICE_CAPABILITIES_DETAIL_KEY)) + .getAsJsonObject(); + } catch (Exception e) { + logger.warn("Failed to parse network.service.capabilities JSON: {}", e.getMessage()); + } + } + Map> result = new HashMap<>(); + for (String svcName : serviceNames) { + Service service = Service.getService(svcName); + if (service == null) { + logger.warn("Unknown network service '{}' in network.services — skipping", svcName); + continue; + } + Map capMap = new HashMap<>(); + if (capsObj != null && capsObj.has(svcName)) { + JsonObject svcCaps = capsObj.getAsJsonObject(svcName); + for (Map.Entry entry : svcCaps.entrySet()) { + Capability cap = Capability.getCapability(entry.getKey()); + if (cap != null) { + capMap.put(cap, entry.getValue().getAsString()); + } + } + } + result.put(service, capMap); + } + return result; + } + } + + return new HashMap<>(); + } + + /** + * Sets the boolean service-provided flags on a {@link PhysicalNetworkServiceProviderVO} + * based on a set of service names. + */ + private void applyServicesToNsp(PhysicalNetworkServiceProviderVO nsp, Set services) { + nsp.setSourcenatServiceProvided(services.contains("SourceNat")); + nsp.setStaticnatServiceProvided(services.contains("StaticNat")); + nsp.setPortForwardingServiceProvided(services.contains("PortForwarding")); + nsp.setFirewallServiceProvided(services.contains("Firewall")); + nsp.setGatewayServiceProvided(services.contains("Gateway")); + nsp.setDnsServiceProvided(services.contains("Dns")); + nsp.setDhcpServiceProvided(services.contains("Dhcp")); + nsp.setUserdataServiceProvided(services.contains("UserData")); + nsp.setLbServiceProvided(services.contains("Lb")); + nsp.setVpnServiceProvided(services.contains("Vpn")); + nsp.setSecuritygroupServiceProvided(services.contains("SecurityGroup")); + nsp.setNetworkAclServiceProvided(services.contains("NetworkACL")); + } + + /** Keys that are always stored with display=false (sensitive). */ + private static final Set SENSITIVE_DETAIL_KEYS = + Set.of("password", "sshkey"); + + /** + * Validates that the comma-separated or JSON-array {@code servicesValue} is a + * subset of the services declared in the extension's {@code network.services} + * Throws {@link InvalidParameterValueException} if any service in the request is not + * offered by the extension. + */ + protected void validateNetworkServicesSubset(Extension extension, String servicesValue) { + if (StringUtils.isBlank(servicesValue)) { + return; + } + Map extDetails = extensionDetailsDao.listDetailsKeyPairs(extension.getId()); + Set allowedServices = parseServicesFromDetailKeys(extDetails); + if (allowedServices.isEmpty()) { + // No services declared → accept any + return; + } + + // Parse the requested services: either comma-separated string or JSON array + List requested = parseServicesList(servicesValue); + List invalid = requested.stream() + .filter(s -> !allowedServices.contains(s)) + .collect(Collectors.toList()); + if (!invalid.isEmpty()) { + throw new InvalidParameterValueException(String.format( + "The following services are not supported by extension '%s': %s. " + + "Supported services are: %s", + extension.getName(), invalid, allowedServices)); + } + } + + /** + * Parses a services list from either a comma-separated string (e.g. + * {@code "SourceNat,StaticNat"}) or a JSON array (e.g. + * {@code ["SourceNat","StaticNat"]}). + */ + private List parseServicesList(String value) { + if (StringUtils.isBlank(value)) { + return Collections.emptyList(); + } + value = value.trim(); + if (value.startsWith("[")) { + try { + JsonArray arr = JsonParser.parseString(value).getAsJsonArray(); + List result = new ArrayList<>(); + for (JsonElement el : arr) { + result.add(el.getAsString().trim()); + } + return result; + } catch (Exception e) { + // fall through to comma-split + } + } + // Comma-separated + return Arrays.stream(value.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + @Override @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_RESOURCE_UNREGISTER, eventDescription = "unregistering extension resource") public Extension unregisterExtensionWithResource(UnregisterExtensionCmd cmd) { @@ -942,10 +1410,15 @@ public Extension unregisterExtensionWithResource(UnregisterExtensionCmd cmd) { final String resourceType = cmd.getResourceType(); if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceType)) { throw new InvalidParameterValueException( - String.format("Currently only [%s] can be used to unregister an extension of type Orchestrator", + String.format("Currently only [%s] can be used to unregister an extension", EnumSet.allOf(ExtensionResourceMap.ResourceType.class))); } - unregisterExtensionWithCluster(resourceId, extensionId); + ExtensionResourceMap.ResourceType resType = ExtensionResourceMap.ResourceType.valueOf(resourceType); + if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(resType)) { + unregisterExtensionWithPhysicalNetwork(resourceId, extensionId); + } else { + unregisterExtensionWithCluster(resourceId, extensionId); + } return extensionDao.findById(extensionId); } @@ -965,6 +1438,55 @@ public void unregisterExtensionWithCluster(Cluster cluster, Long extensionId) { } } + protected void unregisterExtensionWithPhysicalNetwork(String resourceId, Long extensionId) { + PhysicalNetworkVO physicalNetwork = physicalNetworkDao.findByUuid(resourceId); + if (physicalNetwork == null) { + try { + physicalNetwork = physicalNetworkDao.findById(Long.parseLong(resourceId)); + } catch (NumberFormatException ignored) { + } + } + if (physicalNetwork == null) { + throw new InvalidParameterValueException("Invalid physical network ID specified"); + } + // Find the specific map entry for this extension+physical-network combination + List existingList = extensionResourceMapDao.listByResourceIdAndType( + physicalNetwork.getId(), ExtensionResourceMap.ResourceType.PhysicalNetwork); + if (existingList == null || existingList.isEmpty()) { + return; + } + final long physNetId = physicalNetwork.getId(); + for (ExtensionResourceMapVO existing : existingList) { + if (extensionId == null || existing.getExtensionId() == extensionId) { + ExtensionVO ext = extensionDao.findById(existing.getExtensionId()); + if (ext != null) { + List networksUsingProvider = networkDao.listByPhysicalNetworkAndProvider( + physNetId, ext.getName()); + if (CollectionUtils.isNotEmpty(networksUsingProvider)) { + throw new CloudRuntimeException(String.format( + "Cannot unregister extension '%s' from physical network %s. " + + "Provider is used by %d existing network(s)", + ext.getName(), physNetId, networksUsingProvider.size())); + } + } + + extensionResourceMapDao.remove(existing.getId()); + extensionResourceMapDetailsDao.removeDetails(existing.getId()); + + // Also remove the auto-created NSP for this extension + if (ext != null) { + PhysicalNetworkServiceProviderVO nsp = + physicalNetworkServiceProviderDao.findByServiceProvider(physNetId, ext.getName()); + if (nsp != null) { + physicalNetworkServiceProviderDao.remove(nsp.getId()); + logger.info("Removed NetworkServiceProvider '{}' from physical network {} " + + "on extension unregister", ext.getName(), physNetId); + } + } + } + } + } + @Override public ExtensionResponse createExtensionResponse(Extension extension, EnumSet viewDetails) { @@ -988,6 +1510,12 @@ public ExtensionResponse createExtensionResponse(Extension extension, Cluster cluster = clusterDao.findById(extensionResourceMapVO.getResourceId()); extensionResourceResponse.setId(cluster.getUuid()); extensionResourceResponse.setName(cluster.getName()); + } else if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(extensionResourceMapVO.getResourceType())) { + PhysicalNetworkVO pn = physicalNetworkDao.findById(extensionResourceMapVO.getResourceId()); + if (pn != null) { + extensionResourceResponse.setId(pn.getUuid()); + extensionResourceResponse.setName(pn.getName()); + } } Map details = extensionResourceMapDetailsDao.listDetailsKeyPairs( extensionResourceMapVO.getId(), true); @@ -1423,6 +1951,10 @@ public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { Pair clusterAndHostId = virtualMachineManager.findClusterAndHostIdForVm(virtualMachine, false); clusterId = clusterAndHostId.first(); hostId = clusterAndHostId.second(); + } else if (entity instanceof Network) { + // Network custom action: dispatched directly to NetworkCustomActionProvider (no agent) + Network network = (Network) entity; + return runNetworkCustomAction(network, customActionVO, extensionVO, actionResourceType, cmdParameters); } if (clusterId == null || hostId == null) { @@ -1499,6 +2031,92 @@ public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { return response; } + /** + * Executes a custom action for a Network resource by delegating to an + * available {@link NetworkCustomActionProvider} (e.g. NetworkExtensionElement). + * This path does NOT go through the agent framework. + */ + protected CustomActionResultResponse runNetworkCustomAction(Network network, + ExtensionCustomActionVO customActionVO, ExtensionVO extensionVO, + ExtensionCustomAction.ResourceType actionResourceType, + Map cmdParameters) { + + final String actionName = customActionVO.getName(); + CustomActionResultResponse response = new CustomActionResultResponse(); + response.setId(customActionVO.getUuid()); + response.setName(actionName); + response.setObjectName("customactionresult"); + Map result = new HashMap<>(); + response.setSuccess(false); + result.put(ApiConstants.MESSAGE, getActionMessage(false, customActionVO, extensionVO, actionResourceType, network)); + + // Resolve action parameters + List actionParameters = null; + Pair, Map> allDetails = + extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(customActionVO.getId()); + if (allDetails.second().containsKey(ApiConstants.PARAMETERS)) { + actionParameters = ExtensionCustomAction.Parameter.toListFromJson( + allDetails.second().get(ApiConstants.PARAMETERS)); + } + Map parameters = null; + if (CollectionUtils.isNotEmpty(actionParameters)) { + parameters = ExtensionCustomAction.Parameter.validateParameterValues(actionParameters, cmdParameters); + } + + // Find the provider name for this network (try each service until we find one) + String providerName = null; + for (Service service : new Service[]{Service.SourceNat, Service.StaticNat, + Service.PortForwarding, Service.Firewall, Service.Gateway}) { + providerName = networkServiceMapDao.getProviderForServiceInNetwork(network.getId(), service); + if (StringUtils.isNotBlank(providerName)) { + break; + } + } + if (StringUtils.isBlank(providerName)) { + logger.error("No network service provider found for network {}", network.getId()); + result.put(ApiConstants.DETAILS, "No network service provider found for this network"); + response.setResult(result); + return response; + } + + // Get the network element implementing that provider + NetworkElement element = networkModel.getElementImplementingProvider(providerName); + if (element == null) { + logger.error("No NetworkElement found implementing provider '{}' for network {}", providerName, network.getId()); + result.put(ApiConstants.DETAILS, "No network element found for provider: " + providerName); + response.setResult(result); + return response; + } + + // The element must implement NetworkCustomActionProvider + if (!(element instanceof NetworkCustomActionProvider)) { + logger.error("Network element '{}' for provider '{}' does not support custom actions", + element.getClass().getSimpleName(), providerName); + result.put(ApiConstants.DETAILS, "Provider '" + providerName + "' does not support custom actions"); + response.setResult(result); + return response; + } + + NetworkCustomActionProvider provider = (NetworkCustomActionProvider) element; + try { + if (!provider.canHandleCustomAction(network)) { + throw new CloudRuntimeException("Provider '" + providerName + "' cannot handle custom action for this network"); + } + logger.info("Running network custom action '{}' on network {} via {} (provider: {})", + actionName, network.getId(), element.getClass().getSimpleName(), providerName); + String output = provider.runCustomAction(network, actionName, parameters); + boolean success = output != null; + response.setSuccess(success); + result.put(ApiConstants.MESSAGE, getActionMessage(success, customActionVO, extensionVO, actionResourceType, network)); + result.put(ApiConstants.DETAILS, success ? output : "Action failed — check management server logs for details"); + } catch (Exception e) { + logger.error("Network custom action '{}' threw exception: {}", actionName, e.getMessage(), e); + result.put(ApiConstants.DETAILS, "Action failed: " + e.getMessage()); + } + response.setResult(result); + return response; + } + @Override public ExtensionCustomActionResponse createCustomActionResponse(ExtensionCustomAction customAction) { ExtensionCustomActionResponse response = new ExtensionCustomActionResponse(customAction.getUuid(), @@ -1608,11 +2226,8 @@ public void updateExtensionResourceMapDetails(long extensionResourceMapId, Map detailsList = new ArrayList<>(); - for (Map.Entry entry : details.entrySet()) { - detailsList.add(new ExtensionResourceMapDetailsVO(extensionResourceMapId, entry.getKey(), - entry.getValue())); - } + List detailsList = + buildExtensionResourceDetailsArray(extensionResourceMapId, details); extensionResourceMapDetailsDao.saveDetails(detailsList); } @@ -1677,6 +2292,26 @@ public List getExtensionReservedResourceDetails(long extensionId) { return reservedDetails; } + @Override + public Long getExtensionIdForPhysicalNetwork(long physicalNetworkId) { + // Returns the first (primary) extension for backward compatibility + List maps = extensionResourceMapDao.listByResourceIdAndType(physicalNetworkId, + ExtensionResourceMap.ResourceType.PhysicalNetwork); + if (maps == null || maps.isEmpty()) { + return null; + } + return maps.get(0).getExtensionId(); + } + + @Override + public Extension getExtensionForPhysicalNetwork(long physicalNetworkId) { + Long extensionId = getExtensionIdForPhysicalNetwork(physicalNetworkId); + if (extensionId == null) { + return null; + } + return extensionDao.findById(extensionId); + } + @Override public boolean start() { long pathStateCheckInterval = PathStateCheckInterval.value(); @@ -1714,6 +2349,7 @@ public List> getCommands() { cmds.add(UpdateExtensionCmd.class); cmds.add(RegisterExtensionCmd.class); cmds.add(UnregisterExtensionCmd.class); + cmds.add(UpdateRegisteredExtensionCmd.class); return cmds; } @@ -1765,4 +2401,106 @@ protected void runInContext() { } } } + + @Override + public String getExtensionScriptPath(Extension extension) { + if (extension == null) { + return null; + } + return externalProvisioner.getExtensionPath(extension.getRelativePath()); + } + + @Override + public Map getExtensionDetails(long extensionId) { + return extensionDetailsDao.listDetailsKeyPairs(extensionId); + } + + @Override + public Extension getExtensionForPhysicalNetworkAndProvider(long physicalNetworkId, String providerName) { + if (StringUtils.isBlank(providerName)) { + return null; + } + List maps = extensionResourceMapDao.listByResourceIdAndType( + physicalNetworkId, ExtensionResourceMap.ResourceType.PhysicalNetwork); + if (maps == null || maps.isEmpty()) { + return null; + } + for (ExtensionResourceMapVO map : maps) { + ExtensionVO ext = extensionDao.findById(map.getExtensionId()); + if (ext != null && providerName.equalsIgnoreCase(ext.getName())) { + return ext; + } + } + return null; + } + + @Override + public Map getAllResourceMapDetailsForExtensionOnPhysicalNetwork(long physicalNetworkId, long extensionId) { + List maps = extensionResourceMapDao.listByResourceIdAndType( + physicalNetworkId, ExtensionResourceMap.ResourceType.PhysicalNetwork); + if (maps == null || maps.isEmpty()) { + return new HashMap<>(); + } + for (ExtensionResourceMapVO map : maps) { + if (map.getExtensionId() == extensionId) { + Map details = extensionResourceMapDetailsDao.listDetailsKeyPairs(map.getId()); + return details != null ? details : new HashMap<>(); + } + } + return new HashMap<>(); + } + + @Override + public boolean isNetworkExtensionProvider(String providerName) { + if (StringUtils.isBlank(providerName)) { + return false; + } + List networkOrchExtensions = extensionDao.listByType(Extension.Type.NetworkOrchestrator); + if (networkOrchExtensions == null || networkOrchExtensions.isEmpty()) { + return false; + } + return networkOrchExtensions.stream() + .anyMatch(ext -> providerName.equalsIgnoreCase(ext.getName())); + } + + @Override + public List listExtensionsByType(Extension.Type type) { + if (type == null) { + return new ArrayList<>(); + } + List extensions = extensionDao.listByType(type); + if (extensions == null || extensions.isEmpty()) { + return new ArrayList<>(); + } + return new ArrayList<>(extensions); + } + + @Override + public Map> getNetworkCapabilitiesForProvider(Long physicalNetworkId, + String providerName) { + if (StringUtils.isBlank(providerName)) { + return new HashMap<>(); + } + Extension extension = null; + if (physicalNetworkId != null) { + extension = getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName); + } + if (extension == null) { + // Search across all physical networks + List networkOrchExtensions = extensionDao.listByType(Extension.Type.NetworkOrchestrator); + if (networkOrchExtensions != null) { + for (ExtensionVO ext : networkOrchExtensions) { + if (providerName.equalsIgnoreCase(ext.getName())) { + extension = ext; + break; + } + } + } + } + if (extension == null) { + return new HashMap<>(); + } + Map extDetails = extensionDetailsDao.listDetailsKeyPairs(extension.getId()); + return buildCapabilitiesFromDetailKeys(extDetails); + } } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java new file mode 100644 index 000000000000..aa7e253dfb1a --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java @@ -0,0 +1,2674 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.framework.extensions.network; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.nio.file.Files; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import com.cloud.agent.api.to.LoadBalancerTO; +import com.cloud.dc.DataCenter; +import com.cloud.dc.Vlan; +import com.cloud.dc.VlanVO; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.dc.dao.VlanDao; +import com.cloud.deploy.DeployDestination; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.IpAddressManager; +import com.cloud.network.IpAddress; +import com.cloud.network.Network; +import com.cloud.network.Network.Capability; +import com.cloud.network.Network.Provider; +import com.cloud.network.Network.Service; +import com.cloud.network.NetworkModel; +import com.cloud.network.Networks; +import com.cloud.network.dao.NetworkDetailVO; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.PhysicalNetworkServiceProvider; +import com.cloud.network.PublicIpAddress; +import com.cloud.network.addr.PublicIp; +import com.cloud.network.dao.FirewallRulesDao; +import com.cloud.network.dao.IPAddressDao; +import com.cloud.network.dao.IPAddressVO; +import com.cloud.network.dao.NetworkDetailsDao; +import com.cloud.network.dao.NetworkServiceMapDao; +import com.cloud.network.vpc.dao.VpcDao; +import com.cloud.network.element.AggregatedCommandExecutor; +import com.cloud.network.element.DhcpServiceProvider; +import com.cloud.network.element.DnsServiceProvider; +import com.cloud.network.element.FirewallServiceProvider; +import com.cloud.network.element.IpDeployer; +import com.cloud.network.element.LoadBalancingServiceProvider; +import com.cloud.network.element.NetworkACLServiceProvider; +import com.cloud.network.element.NetworkElement; +import com.cloud.network.element.PortForwardingServiceProvider; +import com.cloud.network.element.SourceNatServiceProvider; +import com.cloud.network.element.StaticNatServiceProvider; +import com.cloud.network.element.UserDataServiceProvider; +import com.cloud.network.element.VpcProvider; +import com.cloud.network.vpc.NetworkACLItem; +import com.cloud.network.vpc.PrivateGateway; +import com.cloud.network.vpc.StaticRouteProfile; +import com.cloud.network.vpc.Vpc; +import com.cloud.network.lb.LoadBalancingRule; +import com.cloud.network.rules.FirewallRule; +import com.cloud.network.rules.FirewallRuleVO; +import com.cloud.network.rules.PortForwardingRule; +import com.cloud.network.rules.StaticNat; +import com.cloud.offerings.NetworkOfferingVO; +import com.cloud.offerings.dao.NetworkOfferingDao; +import com.cloud.service.ServiceOfferingVO; +import com.cloud.service.dao.ServiceOfferingDao; +import com.cloud.storage.dao.GuestOSCategoryDao; +import com.cloud.storage.dao.GuestOSDao; +import com.cloud.user.AccountService; +import com.cloud.uservm.UserVm; +import com.cloud.offering.NetworkOffering; +import com.cloud.user.Account; +import com.cloud.utils.Pair; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.Nic; +import com.cloud.vm.NicProfile; +import com.cloud.vm.NicVO; +import com.cloud.vm.ReservationContext; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VirtualMachineProfile; +import com.cloud.vm.VMInstanceDetailVO; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.NicDao; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.extension.NetworkCustomActionProvider; +import org.apache.cloudstack.resourcedetail.dao.VpcDetailsDao; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.stream.Collectors; + + +/** + * NetworkExtensionElement is a network plugin that delegates all network + * configuration to an external script via a registered {@link Extension} of + * type {@code NetworkOrchestrator}. + * + *

Script invocation model

+ * The script is called with a command name and optional CLI arguments. + * Two JSON blobs are always forwarded as named CLI arguments: + *
    + *
  • {@value #ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS} {@code } – all + * details stored in {@code extension_resource_map_details} when the + * extension was registered with the physical network (connection info, + * host list, credentials, etc.). The script owns the schema.
  • + *
  • {@value #ARG_NETWORK_EXTENSION_DETAILS} {@code } – the + * per-network JSON blob stored in {@code network_details} under key + * {@value #NETWORK_DETAIL_EXTENSION_DETAILS}. Populated by the + * script's {@code ensure-network-device} response and updated on + * failover (e.g. selected host, namespace, segment ID).
  • + *
+ * + *

Script resolution

+ * The script is resolved from the extension path set when the extension was + * created. Lookup order (first match wins): + *
    + *
  1. {@code /.sh} — preferred convention, + * e.g. for an extension named {@code network-extension} the script is + * {@code network-extension.sh}.
  2. + *
  3. {@code } itself, if it is a file and is executable.
  4. + *
+ * + *

Physical-network extension details

+ * Any key/value pairs stored in {@code extension_resource_map_details} at + * registration time are passed verbatim as a JSON object. There are no + * pre-defined keys — the user and the script agree on the schema. The only + * special treatment is that keys named {@code password} or {@code sshkey} are + * redacted in log output. + * + *

Two well-known optional keys control which host network interfaces the + * wrapper script uses to create bridges and veth pairs:

+ *
    + *
  • {@code guest.network.device} — host NIC for guest (internal) traffic; + * defaults to {@code eth1} when absent.
  • + *
  • {@code public.network.device} — host NIC for public (NAT/external) + * traffic; defaults to {@code eth1} when absent.
  • + *
+ * + *

Example registration for a KVM-namespace backend:

+ *
+ *   cmk registerExtension id=<ext-uuid> resourcetype=PhysicalNetwork \
+ *       resourceid=<phys-uuid> \
+ *       details[0].key=hosts                details[0].value=192.168.1.10,192.168.1.11 \
+ *       details[1].key=port                 details[1].value=22 \
+ *       details[2].key=username             details[2].value=root \
+ *       details[3].key=sshkey               details[3].value="$(cat ~/.ssh/id_rsa)" \
+ *       details[4].key=guest.network.device details[4].value=eth1 \
+ *       details[5].key=public.network.device details[5].value=eth1
+ * 
+ * + *

Per-network extension details

+ * On first {@code implement}, the script is called with + * {@code ensure-network-device}. The script selects a host (e.g. from the + * {@code hosts} list in the physical-network details), checks it is reachable, + * and prints a JSON object to stdout. CloudStack stores this verbatim in + * {@code network_details} under key {@value #NETWORK_DETAIL_EXTENSION_DETAILS} + * and forwards it on every subsequent call via + * {@value #ARG_NETWORK_EXTENSION_DETAILS}. + * + *

Example per-network details (KVM-namespace backend):

+ *
{"host":"192.168.1.10","namespace":"cs-net-42"}
+ * + *

Network capabilities

+ * When creating the extension, set detail {@code network.capabilities} to a + * JSON object describing the services and their capabilities: + *
+ * {
+ *   "services": ["SourceNat", "StaticNat", "PortForwarding", "Firewall"],
+ *   "capabilities": {
+ *     "SourceNat": { "SupportedSourceNatTypes": "peraccount", "RedundantRouter": "false" }
+ *   }
+ * }
+ * 
+ */ +public class NetworkExtensionElement extends AdapterBase implements + NetworkElement, SourceNatServiceProvider, StaticNatServiceProvider, + PortForwardingServiceProvider, IpDeployer, NetworkCustomActionProvider, + DhcpServiceProvider, DnsServiceProvider, FirewallServiceProvider, + UserDataServiceProvider, LoadBalancingServiceProvider, + VpcProvider, NetworkACLServiceProvider, AggregatedCommandExecutor { + + private static final Map> DEFAULT_CAPABILITIES = new HashMap<>(); + + + /** + * When non-null, restricts all operations to the extension whose name + * matches this provider name. + */ + private String providerName; + + @Inject + private NetworkModel networkModel; + @Inject + private NetworkServiceMapDao ntwkSrvcDao; + @Inject + private ExtensionHelper extensionHelper; + @Inject + private NetworkDetailsDao networkDetailsDao; + @Inject + private IpAddressManager ipAddressManager; + @Inject + private NetworkOrchestrationService networkManager; + @Inject + private AccountService accountService; + @Inject + private PhysicalNetworkDao physicalNetworkDao; + @Inject + private DataCenterDao dataCenterDao; + @Inject + private VlanDao vlanDao; + @Inject + private GuestOSCategoryDao guestOSCategoryDao; + @Inject + private GuestOSDao guestOSDao; + @Inject + private HostDao hostDao; + @Inject + private VMInstanceDetailsDao vmInstanceDetailsDao; + @Inject + private UserVmDao userVmDao; + @Inject + private NicDao nicDao; + @Inject + private NetworkOfferingDao networkOfferingDao; + @Inject + private ServiceOfferingDao serviceOfferingDao; + @Inject + private FirewallRulesDao firewallRulesDao; + @Inject + private IPAddressDao ipAddressDao; + @Inject + private VpcDao vpcDao; + @Inject + private VpcDetailsDao vpcDetailsDao; + + // ---- Script argument names ---- + + /** CLI argument carrying physical-network extension details as a JSON object. */ + public static final String ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS = "--physical-network-extension-details"; + + /** CLI argument carrying per-network opaque JSON blob. */ + public static final String ARG_NETWORK_EXTENSION_DETAILS = "--network-extension-details"; + + /** CLI argument carrying per-action parameters as a JSON object. */ + public static final String ARG_ACTION_PARAMS = "--action-params"; + + // ---- Network detail key ---- + + /** + * Key used to persist the per-network JSON blob in {@code network_details}. + * The blob is produced by the network-extension.sh's {@code ensure-network-device} + * command and may contain any fields the script needs (e.g. selected host, + * namespace name, VRF ID, …). + */ + public static final String NETWORK_DETAIL_EXTENSION_DETAILS = "extension.details"; + + public String getProviderName() { + return providerName; + } + + /** + * Returns a new {@link NetworkExtensionElement} scoped to {@code providerName}, + * sharing all injected dependencies with this instance. + */ + public NetworkExtensionElement withProviderName(String providerName) { + NetworkExtensionElement copy = new NetworkExtensionElement(); + copy.networkModel = this.networkModel; + copy.ntwkSrvcDao = this.ntwkSrvcDao; + copy.extensionHelper = this.extensionHelper; + copy.networkDetailsDao = this.networkDetailsDao; + copy.ipAddressManager = this.ipAddressManager; + copy.physicalNetworkDao = this.physicalNetworkDao; + copy.dataCenterDao = this.dataCenterDao; + copy.vlanDao = this.vlanDao; + copy.guestOSCategoryDao = this.guestOSCategoryDao; + copy.guestOSDao = this.guestOSDao; + copy.hostDao = this.hostDao; + copy.vmInstanceDetailsDao = this.vmInstanceDetailsDao; + copy.userVmDao = this.userVmDao; + copy.nicDao = this.nicDao; + copy.networkManager = this.networkManager; + copy.networkOfferingDao = this.networkOfferingDao; + copy.serviceOfferingDao = this.serviceOfferingDao; + copy.accountService = this.accountService; + copy.firewallRulesDao = this.firewallRulesDao; + copy.ipAddressDao = this.ipAddressDao; + copy.vpcDao = this.vpcDao; + copy.vpcDetailsDao = this.vpcDetailsDao; + copy.providerName = providerName; + + logger.debug("NetworkExtensionElement initialised with provider name '{}'", providerName); + return copy; + } + + // ---- Capabilities ---- + + @Override + public Map> getCapabilities() { + try { + // If this element is scoped to a provider name, prefer capabilities stored + // in the extension's "network.capabilities" detail. The ExtensionHelper + // exposes a helper that loads the Service→Capability map from the DB. + if (providerName != null && !providerName.isBlank()) { + Map> caps = extensionHelper.getNetworkCapabilitiesForProvider(null, providerName); + if (caps != null && !caps.isEmpty()) { + return caps; + } + } + } catch (Exception e) { + logger.warn("Failed to load network capabilities from extension details for provider '{}': {}", providerName, e.getMessage()); + } + + return DEFAULT_CAPABILITIES; + } + + @Override + public Provider getProvider() { + if (providerName != null) { + return Provider.createTransientProvider(providerName); + } + return Provider.NetworkExtension; + } + + // ---- Extension / provider resolution ---- + + protected Extension resolveExtension(Network network) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + logger.warn("Network {} has no physical network — cannot resolve extension", network.getId()); + return null; + } + if (providerName != null && !providerName.isBlank()) { + Extension ext = extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName); + if (ext != null) { + return ext; + } + logger.warn("No extension found for scoped provider '{}' on physical network {}", providerName, physicalNetworkId); + } + List providers = ntwkSrvcDao.getDistinctProviders(network.getId()); + if (providers != null) { + for (String p : providers) { + Extension ext = extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p); + if (ext != null) { + return ext; + } + } + } + return extensionHelper.getExtensionForPhysicalNetwork(physicalNetworkId); + } + + protected boolean canHandle(Network network, Service service) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + return false; + } + if (providerName != null && !providerName.isBlank()) { + boolean hasExt = extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName) != null; + if (!hasExt) { + return false; + } + if (service == null) { + return true; + } + List sp = ntwkSrvcDao.getProvidersForServiceInNetwork(network.getId(), service); + return sp != null && sp.stream() + .anyMatch(p -> extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p) != null); + } + List providers = ntwkSrvcDao.getDistinctProviders(network.getId()); + if (providers == null || providers.isEmpty()) { + return false; + } + boolean hasExtProv = providers.stream().anyMatch( + p -> extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p) != null); + if (!hasExtProv) { + return false; + } + if (service == null) { + return true; + } + List sp = ntwkSrvcDao.getProvidersForServiceInNetwork(network.getId(), service); + return sp != null && sp.stream() + .anyMatch(p -> extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p) != null); + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + return true; + } + + // ---- NetworkElement lifecycle ---- + + @Override + public boolean implement(Network network, NetworkOffering offering, DeployDestination dest, + ReservationContext context) throws ConcurrentOperationException, + ResourceUnavailableException, InsufficientCapacityException { + if (!canHandle(network, null)) { + return false; + } + logger.info("Implementing network extension for network {} (VLAN {})", network.getId(), network.getBroadcastUri()); + + // Step 1: Ensure a network device is selected and its details stored. + ensureExtensionDetails(network); + + // Step 2: Allocate the IPs for DHCP/DNS/UserData service if needed + String extensionIp = ensureExtensionIp(network); + + String vlanId = getVlanId(network); + + // Build common vpc/network args + List vpcArgs = getVpcIdArgs(network); + + // Step 2: Create the network on the device. + List implArgs = new ArrayList<>(); + implArgs.add("--network-id"); implArgs.add(String.valueOf(network.getId())); + implArgs.add("--vlan"); implArgs.add(safeStr(vlanId)); + implArgs.add("--gateway"); implArgs.add(safeStr(network.getGateway())); + implArgs.add("--cidr"); implArgs.add(safeStr(network.getCidr())); + implArgs.add("--extension-ip"); implArgs.add(safeStr(extensionIp)); + implArgs.addAll(vpcArgs); + + boolean result = executeScript(network, "implement-network", implArgs.toArray(new String[0])); + + if (!result) { + return false; + } + + // Step 3: Configure source NAT for both VPC and non-VPC networks for + // compatibility (other network-element providers may also implement VPC tiers). + // When this is a VPC tier, the script's assign-ip does nothing for source-nat + // because VPC source NAT is managed at the VPC level by implementVpc(). + if (canHandle(network, Service.SourceNat)) { + try { + if (network.getVpcId() == null) { + // Isolated network: apply the network's own source NAT IP. + Account owner = accountService.getAccount(network.getAccountId()); + PublicIpAddress existingIp = networkModel.getSourceNatIpAddressForGuestNetwork(owner, network); + if (existingIp != null) { + applyIps(network, List.of(existingIp), Set.of(Service.SourceNat)); + } + } else { + // VPC tier: apply the VPC-level source NAT IP (script is a no-op for SNAT). + final PublicIpAddress vpcSourceNatIp = getVpcSourceNatIp(network.getVpcId()); + if (vpcSourceNatIp != null) { + applyIps(network, List.of(vpcSourceNatIp), Set.of(Service.SourceNat)); + } + } + } catch (Exception e) { + logger.warn("Failed to configure source NAT IP for network {}: {}", network.getId(), e.getMessage(), e); + } + } + + return true; + } + + @Override + public boolean prepare(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { + // Copy from VirtualRouterElement.java + if (vm.getType() != VirtualMachine.Type.User || vm.getHypervisorType() == Hypervisor.HypervisorType.BareMetal) { + return false; + } + + if (!canHandle(network, null)) { + return false; + } + + if (!networkModel.isProviderEnabledInPhysicalNetwork(networkModel.getPhysicalNetworkId(network), getProvider().getName())) { + return false; + } + + final NetworkOfferingVO offering = networkOfferingDao.findById(network.getNetworkOfferingId()); + implement(network, offering, dest, context); + + return true; + } + + @Override + public boolean release(Network network, NicProfile nic, VirtualMachineProfile vm, + ReservationContext context) throws ConcurrentOperationException, ResourceUnavailableException { + return true; + } + + @Override + public boolean shutdown(Network network, ReservationContext context, boolean cleanup) + throws ConcurrentOperationException, ResourceUnavailableException { + logger.info("Shutting down network extension for network {}", network.getId()); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--vlan"); args.add(safeStr(getVlanId(network))); + args.addAll(getVpcIdArgs(network)); + boolean result = executeScript(network, "shutdown-network", args.toArray(new String[0])); + if (result) { + // Remove stored per-network extension details (e.g. namespace). For VPC-backed networks + // the namespace is named cs-vpc-, stored in the extension details. Removing the + // stored details ensures the namespace is deleted/forgotten on shutdown. + try { + networkDetailsDao.removeDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS); + } catch (Exception e) { + logger.warn("Failed to remove network extension details for network {}: {}", network.getId(), e.getMessage()); + } + } + return result; + } + + @Override + public boolean destroy(Network network, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + logger.info("Destroying network extension for network {}", network.getId()); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--vlan"); args.add(safeStr(getVlanId(network))); + args.addAll(getVpcIdArgs(network)); + // For both isolated and VPC tier networks, use destroy-network. + // For VPC tiers, the script preserves the shared namespace; + // the VPC namespace is removed only when shutdownVpc() calls shutdown-vpc. + boolean result = executeScript(network, "destroy-network", args.toArray(new String[0])); + if (result) { + cleanupPlaceholderNicIp(network, context); + networkDetailsDao.removeDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS); + } + return result; + } + + /** + * Releases placeholder NIC IPs allocated for DHCP/DNS/UserData extension traffic, + * then removes the placeholder NIC record(s) for this network. + */ + protected void cleanupPlaceholderNicIp(Network network, ReservationContext context) { + List placeholderNics = nicDao.listPlaceholderNicsByNetworkIdAndVmType( + network.getId(), VirtualMachine.Type.DomainRouter); + if (placeholderNics == null || placeholderNics.isEmpty()) { + return; + } + + long userId = accountService.getSystemUser().getId(); + Account caller = accountService.getSystemAccount(); + if (context != null && context.getAccount() != null) { + caller = context.getAccount(); + } + + for (NicVO placeholderNic : placeholderNics) { + try { + String ip = placeholderNic.getIPv4Address(); + if (ip != null && !ip.isBlank()) { + logger.debug("Cleaning up PlaceHolder IP {} on network {}", ip, network.getId()); + IPAddressVO ipAddress = ipAddressDao.findByIpAndSourceNetworkId(network.getId(), ip); + if (ipAddress != null) { + if (Network.GuestType.Shared.equals(network.getGuestType())) { + ipAddressManager.disassociatePublicIpAddress(ipAddress, userId, caller); + } else { + ipAddressManager.markIpAsUnavailable(ipAddress.getId()); + ipAddressDao.unassignIpAddress(ipAddress.getId()); + } + } + } + } catch (Exception e) { + logger.warn("Failed to release placeholder IP for network {} and nic {}: {}", + network.getId(), placeholderNic.getId(), e.getMessage()); + } + + try { + nicDao.remove(placeholderNic.getId()); + } catch (Exception e) { + logger.warn("Failed to remove placeholder nic {} for network {}: {}", + placeholderNic.getId(), network.getId(), e.getMessage()); + } + } + } + + @Override + public boolean isReady(PhysicalNetworkServiceProvider provider) { + return true; + } + + @Override + public boolean shutdownProviderInstances(PhysicalNetworkServiceProvider provider, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + return true; + } + + @Override + public boolean canEnableIndividualServices() { + return true; + } + + @Override + public boolean verifyServicesCombination(Set services) { + return true; + } + + // ---- ensure-network-device ---- + + /** + * Calls the network-extension.sh script with {@code ensure-network-device} before + * the first network operation. The script verifies the previously selected + * device is reachable (using the {@code hosts} list in the physical-network + * extension details) and performs failover if needed. The returned JSON is + * persisted in {@code network_details} and forwarded to all subsequent calls + * via {@value #ARG_NETWORK_EXTENSION_DETAILS}. + * + *

For VPC tier networks the extension details are inherited from the VPC-level + * details (stored in {@code vpc_details}) so all tiers in the same VPC share + * the same host/namespace binding. The script's {@code ensure-network-device} + * is only called at the VPC level (see {@link #ensureExtensionDetails(Vpc)}).

+ */ + protected void ensureExtensionDetails(Network network) { + if (network.getVpcId() != null) { + Vpc vpc = vpcDao.findById(network.getVpcId()); + ensureExtensionDetails(vpc); + return; + } + + // Isolated network: run ensure-network-device to select / validate the host. + Map stored = networkDetailsDao.listDetailsKeyPairs(network.getId()); + String currentDetails = stored != null + ? stored.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + + logger.info("Ensuring network device for network {} (current={})", network.getId(), currentDetails); + + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + + String physicalNetworkDetailsJson = buildPhysicalNetworkDetailsJson(network.getPhysicalNetworkId(), extension); + + List cmdLine = new ArrayList<>(); + cmdLine.add(scriptFile.getAbsolutePath()); + cmdLine.add("ensure-network-device"); + cmdLine.add("--network-id"); + cmdLine.add(String.valueOf(network.getId())); + cmdLine.add("--vlan"); + cmdLine.add(safeStr(getVlanId(network))); + cmdLine.add("--zone-id"); + cmdLine.add(String.valueOf(network.getDataCenterId())); + // Pass VPC ID so the script can derive the correct namespace (cs-net-) + if (network.getVpcId() != null) { + cmdLine.add("--vpc-id"); + cmdLine.add(String.valueOf(network.getVpcId())); + } + cmdLine.add("--current-details"); + cmdLine.add(currentDetails); + cmdLine.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS); + cmdLine.add(physicalNetworkDetailsJson); + cmdLine.add(ARG_NETWORK_EXTENSION_DETAILS); + cmdLine.add(currentDetails); + + try { + ProcessBuilder pb = new ProcessBuilder(cmdLine); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = new String(process.getInputStream().readAllBytes()).trim(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + logger.warn("ensure-network-device exited {} for network {} — keeping current details", + exitCode, network.getId()); + if ("{}".equals(currentDetails)) { + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + return; + } + if (output.isEmpty()) { + output = "{}".equals(currentDetails) ? "{}" : currentDetails; + } + if (!output.equals(currentDetails)) { + logger.info("Network device updated for network {}: {}", network.getId(), output); + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, output, false); + } else { + logger.debug("Network device unchanged for network {}: {}", network.getId(), output); + } + } catch (Exception e) { + logger.warn("Failed ensure-network-device for network {}: {}", network.getId(), e.getMessage()); + if ("{}".equals(currentDetails)) { + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + } + } + + /* + * If the network supports DHCP/DNS/UserData but not SourceNat/Gateway, + * an additional IP is needed on the external network to host these services. + * This method ensures that IP is allocated and configured on the external network and returns its address. + */ + protected String ensureExtensionIp(Network network) { + if (networkModel.isAnyServiceSupportedInNetwork(network.getId(), this.getProvider(), + Service.SourceNat, Service.Gateway)) { + // Gateway or Source NAT will be configured on the external network + return network.getGateway(); + } + + if (networkModel.isAnyServiceSupportedInNetwork(network.getId(), this.getProvider(), + Service.Dhcp, Service.Dns, Service.UserData)) { + try { + // An extra IP will be allocated and configured on the external network + Nic placeholderNic = networkModel.getPlaceholderNicForRouter(network, null); + if (placeholderNic == null) { + NetworkDetailVO routerIpDetail = networkDetailsDao.findDetail(network.getId(), ApiConstants.ROUTER_IP); + String routerIp = routerIpDetail != null ? routerIpDetail.getValue() : null; + Account account = accountService.getAccount(network.getAccountId()); + String extensionIp = Network.GuestType.Shared.equals(network.getGuestType()) ? + ipAddressManager.assignPublicIpAddress(network.getDataCenterId(), null, account, Vlan.VlanType.DirectAttached, network.getId(), routerIp, false, false).getAddress().toString(): + ipAddressManager.acquireGuestIpAddress(network, routerIp); + logger.debug("Saving placeholder nic with ip4 address {} for the network", extensionIp, network); + networkManager.savePlaceholderNic(network, extensionIp, null, VirtualMachine.Type.DomainRouter); + return extensionIp; + } + return placeholderNic.getIPv4Address(); + } catch (Exception e) { + logger.warn("Failed to acquire extension IP for network {}: {}", network.getId(), e.getMessage()); + } + } + return null; + } + + // ---- IpDeployer ---- + + @Override + public boolean applyIps(Network network, List ipAddress, Set services) + throws ResourceUnavailableException { + if (ipAddress == null || ipAddress.isEmpty()) { + return true; + } + logger.info("Applying {} IPs for network {}", ipAddress.size(), network.getId()); + String vlanId = getVlanId(network); + + for (PublicIpAddress ip : ipAddress) { + boolean isSourceNat = ip.isSourceNat(); + boolean isRevoke = ip.getState() == IpAddress.State.Releasing; + String action = isRevoke ? "release-ip" : "assign-ip"; + + // Public VLAN tag (e.g. "101") from the IP's VLAN record. + String publicVlanTag = safeStr(ip.getVlanTag()); + + // Compute public IP gateway and CIDR (from the PublicIpAddress if available) + String publicGateway; + String publicCidr; + try { + publicGateway = ip.getGateway(); + String publicIpStr = ip.getAddress() != null ? ip.getAddress().addr() : null; + String publicNetmask = ip.getNetmask(); + publicCidr = buildCidrFromIpAndNetmask(publicIpStr, publicNetmask); + } catch (Exception e) { + publicGateway = null; + publicCidr = null; + } + + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--vlan"); args.add(safeStr(vlanId)); + args.add("--public-ip"); args.add(ip.getAddress().addr()); + args.add("--source-nat"); args.add(String.valueOf(isSourceNat)); + args.add("--gateway"); args.add(safeStr(network.getGateway())); + args.add("--cidr"); args.add(safeStr(network.getCidr())); + args.add("--public-gateway"); args.add(safeStr(publicGateway)); + args.add("--public-cidr"); args.add(safeStr(publicCidr)); + args.add("--public-vlan"); args.add(publicVlanTag); + args.addAll(getVpcIdArgs(network)); + + boolean result = executeScript(network, action, args.toArray(new String[0])); + if (!result) { + throw new ResourceUnavailableException( + "Failed to " + action + " for IP " + ip.getAddress().addr(), + Network.class, network.getId()); + } + } + return true; + } + + /** + * Build a CIDR string from IP address and dotted netmask (or prefix). + * Returns "" if either value is null or parsing fails. + */ + private String buildCidrFromIpAndNetmask(String ipStr, String netmaskStr) { + if (ipStr == null || ipStr.isEmpty() || netmaskStr == null || netmaskStr.isEmpty()) { + return ""; + } + // If netmask is already CIDR (contains '/'), try to return network/prefix + if (netmaskStr.contains("/")) { + return netmaskStr; + } + try { + InetAddress ip = InetAddress.getByName(ipStr); + InetAddress mask = InetAddress.getByName(netmaskStr); + int maskInt = ByteBuffer.wrap(mask.getAddress()).getInt(); + int prefix = Integer.bitCount(maskInt); + // Return the provided IP with the calculated prefix so the address retains its host value + return ip.getHostAddress() + "/" + prefix; + } catch (Exception e) { + logger.debug("Failed to compute CIDR from ip/netmask {} {}: {}", ipStr, netmaskStr, e.getMessage()); + return ""; + } + } + + // ---- StaticNatServiceProvider ---- + + @Override + public boolean applyStaticNats(Network config, List rules) + throws ResourceUnavailableException { + if (rules == null || rules.isEmpty()) { + return true; + } + if (!canHandle(config, Service.StaticNat)) { + return false; + } + logger.info("Applying {} static NAT rules for network {}", rules.size(), config.getId()); + String vlanId = getVlanId(config); + List vpcArgs = getVpcIdArgs(config); + + for (StaticNat rule : rules) { + String action = rule.isForRevoke() ? "delete-static-nat" : "add-static-nat"; + String publicCidr = getPublicCidr(rule.getSourceIpAddressId()); + String publicVlanTag = getPublicVlanTag(rule.getSourceIpAddressId()); + + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(config.getId())); + args.add("--vlan"); args.add(safeStr(vlanId)); + args.add("--public-ip"); args.add(getIpAddress(rule.getSourceIpAddressId())); + args.add("--public-cidr"); args.add(safeStr(publicCidr)); + args.add("--public-vlan"); args.add(publicVlanTag); + args.add("--private-ip"); args.add(safeStr(rule.getDestIpAddress())); + args.addAll(vpcArgs); + boolean result = executeScript(config, action, args.toArray(new String[0])); + if (!result) { + throw new ResourceUnavailableException("Failed to " + action + " for static NAT rule", + Network.class, config.getId()); + } + } + return true; + } + + // ---- PortForwardingServiceProvider ---- + + @Override + public boolean applyPFRules(Network network, List rules) + throws ResourceUnavailableException { + if (rules == null || rules.isEmpty()) { + return true; + } + if (!canHandle(network, Service.PortForwarding)) { + return false; + } + logger.info("Applying {} port forwarding rules for network {}", rules.size(), network.getId()); + String vlanId = getVlanId(network); + List vpcArgs = getVpcIdArgs(network); + + for (PortForwardingRule rule : rules) { + boolean isRevoke = rule.getState() == FirewallRule.State.Revoke; + String action = isRevoke ? "delete-port-forward" : "add-port-forward"; + String publicPort = PortForwardingServiceProvider.getPublicPortRange(rule); + String privatePort = PortForwardingServiceProvider.getPrivatePFPortRange(rule); + String publicCidr = getPublicCidr(rule.getSourceIpAddressId()); + String publicVlanTag = getPublicVlanTag(rule.getSourceIpAddressId()); + + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--vlan"); args.add(safeStr(vlanId)); + args.add("--public-ip"); args.add(getIpAddress(rule.getSourceIpAddressId())); + args.add("--public-cidr"); args.add(safeStr(publicCidr)); + args.add("--public-vlan"); args.add(publicVlanTag); + args.add("--public-port"); args.add(safeStr(publicPort)); + args.add("--private-ip"); args.add(safeStr(rule.getDestinationIpAddress() != null + ? rule.getDestinationIpAddress().addr() : null)); + args.add("--private-port"); args.add(safeStr(privatePort)); + args.add("--protocol"); args.add(safeStr(rule.getProtocol())); + args.addAll(vpcArgs); + boolean result = executeScript(network, action, args.toArray(new String[0])); + if (!result) { + throw new ResourceUnavailableException("Failed to " + action + " for port forwarding rule", + Network.class, network.getId()); + } + } + return true; + } + + // ---- Script execution ---- + + /** + * Executes the network-extension.sh script with the given command and arguments. + * + *

Two JSON blobs are always appended as named CLI arguments:

+ *
    + *
  • {@value #ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS} {@code } – all + * {@code extension_resource_map_details} for this extension on the physical + * network. Sensitive keys (password, sshkey) are included but redacted in + * log output.
  • + *
  • {@value #ARG_NETWORK_EXTENSION_DETAILS} {@code } – the per-network + * JSON blob from {@code network_details} ({@code {}} if not yet set).
  • + *
+ */ + protected boolean executeScript(Network network, String command, String... args) { + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + + ensureExtensionDetails(network); + + String physicalNetworkDetailsJson = buildPhysicalNetworkDetailsJson(network.getPhysicalNetworkId(), extension); + String networkExtensionDetailsJson = getNetworkExtensionDetailsJson(network); + + // Log the JSON blobs so we can diagnose missing-argument issues in runtime logs + logger.debug("Physical network details JSON: {}", physicalNetworkDetailsJson); + logger.debug("Network extension details JSON: {}", networkExtensionDetailsJson); + + List cmdLine = new ArrayList<>(); + cmdLine.add(scriptFile.getAbsolutePath()); + cmdLine.add(command); + cmdLine.addAll(Arrays.asList(args)); + cmdLine.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS); + cmdLine.add(physicalNetworkDetailsJson); + cmdLine.add(ARG_NETWORK_EXTENSION_DETAILS); + cmdLine.add(networkExtensionDetailsJson); + + logger.debug("Executing network extension script: {}", String.join(" ", cmdLine)); + + try { + ProcessBuilder pb = new ProcessBuilder(cmdLine); + pb.redirectErrorStream(true); + Process process = pb.start(); + byte[] output = process.getInputStream().readAllBytes(); + int exitCode = process.waitFor(); + + String outputStr = new String(output).trim(); + if (!outputStr.isEmpty()) { + logger.debug("Script output: {}", outputStr); + } + if (exitCode != 0) { + logger.error("Network extension script failed with exit code {}: {}", exitCode, outputStr); + return false; + } + return true; + } catch (Exception e) { + logger.error("Failed to execute network extension script: {}", e.getMessage(), e); + throw new CloudRuntimeException("Failed to execute network extension script", e); + } + } + + /** + * Writes a potentially large payload to a temporary file and passes the file path + * to the extension script via {@code payloadArgName}. This avoids argv size limits + * for multi-MB payloads. + */ + protected boolean executeScriptWithFilePayload(Network network, String command, + String payloadArgName, String payload, String... args) { + File payloadFile = null; + try { + payloadFile = File.createTempFile("cs-extnet-" + command + "-", ".payload"); + Files.writeString(payloadFile.toPath(), payload != null ? payload : "", StandardCharsets.UTF_8); + + List cmdArgs = new ArrayList<>(); + cmdArgs.addAll(Arrays.asList(args)); + cmdArgs.add(payloadArgName); + cmdArgs.add(payloadFile.getAbsolutePath()); + + return executeScript(network, command, cmdArgs.toArray(new String[0])); + } catch (Exception e) { + throw new CloudRuntimeException( + String.format("Failed preparing payload file for command %s", command), e); + } finally { + if (payloadFile != null && payloadFile.exists() && !payloadFile.delete()) { + payloadFile.deleteOnExit(); + } + } + } + + // ---- Detail helpers ---- + + /** + * Returns all {@code extension_resource_map_details} for the given extension + * on the physical network as a plain map, enriched with physical-network + * metadata (name, kvmnetworklabel, vmwarenetworklabel, xennetworklabel, + * public_kvmnetworklabel) so the wrapper script can derive bridge names and + * interface names without extra lookups. + */ + private Map buildPhysicalNetworkDetailsMap(Long physicalNetworkId, Extension extension) { + Map details = new HashMap<>(); + if (physicalNetworkId == null || extension == null) { + return details; + } + // Start with registered extension_resource_map_details + Map mapDetails = extensionHelper.getAllResourceMapDetailsForExtensionOnPhysicalNetwork( + physicalNetworkId, extension.getId()); + if (mapDetails != null) { + details.putAll(mapDetails); + } + + // Enrich with physical-network record fields + PhysicalNetworkVO pn = physicalNetworkDao.findById(physicalNetworkId); + if (pn != null && pn.getName() != null) { + details.put("physicalnetworkname", pn.getName()); + } + + return details; + } + + + /** + * Returns {@code ["--vpc-id", ""]} when the network belongs to a VPC, + * or an empty list otherwise. Appended to every script invocation so the + * wrapper script can derive the correct namespace (cs-net-<vpcId>). + */ + private List getVpcIdArgs(Network network) { + if (network.getVpcId() != null) { + return List.of("--vpc-id", String.valueOf(network.getVpcId())); + } + return List.of(); + } + + /** + * Serialises the physical-network extension details to a compact JSON object string. + */ + private String buildPhysicalNetworkDetailsJson(Long physicalNetworkId, Extension extension) { + return mapToJson(buildPhysicalNetworkDetailsMap(physicalNetworkId, extension)); + } + + /** + * Reads the per-network JSON blob from {@code network_details} + * (returns {@code {}} if not yet set). + */ + private String getNetworkExtensionDetailsJson(Network network) { + if (network.getVpcId() != null) { + return getVpcExtensionDetailsJson(network.getVpcId()); + } else { + Map networkDetails = networkDetailsDao.listDetailsKeyPairs(network.getId()); + return networkDetails != null + ? networkDetails.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + } + } + + + /** + * Serialises a {@code Map} to a compact JSON object string. + * Returns {@code {}} for null or empty maps. + */ + private String mapToJson(Map map) { + if (map == null || map.isEmpty()) { + return "{}"; + } + JsonObject obj = new JsonObject(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() != null) { + obj.addProperty(entry.getKey(), entry.getValue()); + } + } + return new Gson().toJson(obj); + } + + // ---- Custom action ---- + + @Override + public boolean canHandleCustomAction(Network network) { + return canHandle(network, null); + } + + /** + * Runs a custom action on the external network device. + * Per-action parameters are passed as a JSON object via + * {@value #ARG_ACTION_PARAMS}, e.g.: + *
--action-params '{"key1":"value1","key2":"value2"}'
+ * The wrapper script receives the `--action-params` JSON string and forwards + * it unchanged to hook scripts as the `--action-params` CLI argument; hook + * scripts should parse the JSON themselves (for example using `jq` or a + * small shell/awk parser). + */ + public String runCustomAction(Network network, String actionName, Map parameters) { + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + + String physicalNetworkDetailsJson = buildPhysicalNetworkDetailsJson(network.getPhysicalNetworkId(), extension); + String networkExtensionDetailsJson = getNetworkExtensionDetailsJson(network); + String actionParamsJson = buildActionParamsJson(parameters); + + List cmdLine = new ArrayList<>(); + cmdLine.add(scriptFile.getAbsolutePath()); + cmdLine.add("custom-action"); + cmdLine.add("--network-id"); + cmdLine.add(String.valueOf(network.getId())); + cmdLine.add("--action"); + cmdLine.add(actionName); + cmdLine.add(ARG_ACTION_PARAMS); + cmdLine.add(actionParamsJson); + cmdLine.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS); + cmdLine.add(physicalNetworkDetailsJson); + cmdLine.add(ARG_NETWORK_EXTENSION_DETAILS); + cmdLine.add(networkExtensionDetailsJson); + + logger.info("Running custom action '{}' on network {} (extension: {}, params: {} key(s))", + actionName, network.getId(), extension != null ? extension.getName() : "unknown", + parameters != null ? parameters.size() : 0); + + try { + ProcessBuilder pb = new ProcessBuilder(cmdLine); + pb.redirectErrorStream(true); + Process process = pb.start(); + byte[] output = process.getInputStream().readAllBytes(); + int exitCode = process.waitFor(); + String outputStr = new String(output).trim(); + + logger.debug("Running custom action script: {}", String.join(" ", cmdLine)); + + if (exitCode != 0) { + logger.error("Custom action '{}' failed (exit {}): {}", actionName, exitCode, outputStr); + return null; + } + logger.info("Custom action '{}' completed successfully", actionName); + return outputStr.isEmpty() ? "OK" : outputStr; + } catch (Exception e) { + logger.error("Failed to execute custom action '{}': {}", actionName, e.getMessage(), e); + throw new CloudRuntimeException("Failed to execute custom action: " + actionName, e); + } + } + + /** + * Serialises custom-action parameters to a compact JSON object string. + * Returns {@code {}} for null or empty maps. + */ + private String buildActionParamsJson(Map parameters) { + if (parameters == null || parameters.isEmpty()) { + return "{}"; + } + JsonObject obj = new JsonObject(); + for (Map.Entry entry : parameters.entrySet()) { + obj.addProperty(entry.getKey(), + entry.getValue() != null ? entry.getValue().toString() : ""); + } + return new Gson().toJson(obj); + } + + // ---- Script file resolution ---- + + /** + * Resolves the executable script file from the given extension. + * + *

Lookup order (first match wins):

+ *
    + *
  1. {@code /.sh} — preferred convention, + * e.g. for an extension named {@code network-extension} the script is + * {@code network-extension.sh}.
  2. + *
  3. {@code } itself, if it is a file and is executable.
  4. + *
+ */ + protected File resolveScriptFile(Network network, Extension extension) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + throw new CloudRuntimeException("Network " + network.getId() + " has no physical network"); + } + if (extension == null) { + throw new CloudRuntimeException( + "No NetworkOrchestrator extension found for network " + network.getId() + + " on physical network " + physicalNetworkId); + } + if (!Extension.Type.NetworkOrchestrator.equals(extension.getType())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not of type NetworkOrchestrator"); + } + if (!Extension.State.Enabled.equals(extension.getState())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not enabled"); + } + if (!extension.isPathReady()) { + throw new CloudRuntimeException("Extension " + extension.getName() + " path is not ready"); + } + + String extensionPath = extensionHelper.getExtensionScriptPath(extension); + if (extensionPath == null) { + throw new CloudRuntimeException("Could not resolve path for extension " + extension.getName()); + } + + File extensionDir = new File(extensionPath); + + // /.sh (preferred convention) + File namedScript = new File(extensionDir, extension.getName() + ".sh"); + if (namedScript.exists() && namedScript.canExecute()) { + return namedScript; + } + // itself is the script file + if (extensionDir.isFile() && extensionDir.canExecute()) { + return extensionDir; + } + + throw new CloudRuntimeException( + "No executable script found in extension path " + extensionPath + + ". Expected '" + extension.getName() + ".sh' inside the extension directory."); + } + + // ---- Helpers ---- + + private String getVlanId(Network network) { + return network.getBroadcastUri() != null + ? Networks.BroadcastDomainType.getValue(network.getBroadcastUri()) : null; + } + + private String getIpAddress(Long ipAddressId) { + if (ipAddressId == null) { + return ""; + } + IpAddress ip = networkModel.getIp(ipAddressId); + return ip != null ? ip.getAddress().addr() : ""; + } + + private String getPublicCidr(Long ipAddressId) { + if (ipAddressId == null) { + return ""; + } + IpAddress ip = networkModel.getIp(ipAddressId); + if (ip.getAddress() == null) { + return ""; + } + VlanVO vlan = vlanDao.findById(ip.getVlanId()); + return buildCidrFromIpAndNetmask(ip.getAddress().addr(), vlan.getVlanNetmask()); + } + + private String getPublicVlanTag(Long ipAddressId) { + if (ipAddressId == null) { + return ""; + } + IpAddress ip = networkModel.getIp(ipAddressId); + if (ip == null) { + return ""; + } + VlanVO vlan = vlanDao.findById(ip.getVlanId()); + return vlan != null ? safeStr(vlan.getVlanTag()) : ""; + } + + private String safeStr(String value) { + return value != null ? value : ""; + } + + // ---- DhcpServiceProvider ---- + + private String getNetworkDns(final Network network) { + final DataCenter dc = dataCenterDao.findById(network.getDataCenterId()); + Pair dnsList = networkModel.getNetworkIp4Dns(network, dc); + return dnsList.first() + (dnsList.second() != null ? "," + dnsList.second() : ""); + } + + @Override + public boolean addDhcpEntry(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + String extensionIp = ensureExtensionIp(network); + logger.debug("addDhcpEntry: network={} mac={} ip={}", network.getId(), + nic.getMacAddress(), nic.getIPv4Address()); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--mac"); args.add(safeStr(nic.getMacAddress())); + args.add("--ip"); args.add(safeStr(nic.getIPv4Address())); + args.add("--hostname"); args.add(safeStr(vm.getHostName())); + args.add("--gateway"); args.add(safeStr(network.getGateway())); + args.add("--cidr"); args.add(safeStr(network.getCidr())); + args.add("--dns"); args.add(safeStr(getNetworkDns(network))); + args.add("--default-nic"); args.add(String.valueOf(nic.isDefaultNic())); + args.add("--domain"); args.add(safeStr(network.getNetworkDomain())); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "add-dhcp-entry", args.toArray(new String[0])); + } + + @Override + public boolean configDhcpSupportForSubnet(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("configDhcpSupportForSubnet: network={}", network.getId()); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--gateway"); args.add(safeStr(network.getGateway())); + args.add("--cidr"); args.add(safeStr(network.getCidr())); + args.add("--dns"); args.add(safeStr(getNetworkDns(network))); + args.add("--vlan"); args.add(safeStr(getVlanId(network))); + args.add("--domain"); args.add(safeStr(network.getNetworkDomain())); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "config-dhcp-subnet", args.toArray(new String[0])); + } + + @Override + public boolean removeDhcpSupportForSubnet(Network network) throws ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("removeDhcpSupportForSubnet: network={}", network.getId()); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "remove-dhcp-subnet", args.toArray(new String[0])); + } + + @Override + public boolean setExtraDhcpOptions(Network network, long nicId, Map dhcpOptions) { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + if (dhcpOptions == null || dhcpOptions.isEmpty()) { + return true; + } + logger.debug("setExtraDhcpOptions: network={} nicId={} options={}", network.getId(), nicId, dhcpOptions.size()); + // Serialise options as a compact JSON object: {"":"", ...} + StringBuilder json = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : dhcpOptions.entrySet()) { + if (!first) json.append(","); + json.append("\"").append(e.getKey()).append("\":\"") + .append(e.getValue() != null ? e.getValue().replace("\"", "\\\"") : "") + .append("\""); + first = false; + } + json.append("}"); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--nic-id"); args.add(String.valueOf(nicId)); + args.add("--options"); args.add(json.toString()); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + try { + return executeScript(network, "set-dhcp-options", args.toArray(new String[0])); + } catch (Exception e) { + logger.warn("setExtraDhcpOptions failed for network {}: {}", network.getId(), e.getMessage()); + return false; + } + } + + @Override + public boolean removeDhcpEntry(Network network, NicProfile nic, VirtualMachineProfile vmProfile) + throws ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("removeDhcpEntry: network={} mac={} ip={}", network.getId(), + nic.getMacAddress(), nic.getIPv4Address()); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--mac"); args.add(safeStr(nic.getMacAddress())); + args.add("--ip"); args.add(safeStr(nic.getIPv4Address())); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "remove-dhcp-entry", args.toArray(new String[0])); + } + + // ---- DnsServiceProvider ---- + + @Override + public boolean addDnsEntry(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + String hostname = vm.getHostName(); + logger.debug("addDnsEntry: network={} hostname={} ip={}", network.getId(), + hostname, nic.getIPv4Address()); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--ip"); args.add(safeStr(nic.getIPv4Address())); + args.add("--hostname"); args.add(safeStr(hostname)); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "add-dns-entry", args.toArray(new String[0])); + } + + @Override + public boolean configDnsSupportForSubnet(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + logger.debug("configDnsSupportForSubnet: network={}", network.getId()); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--gateway"); args.add(safeStr(network.getGateway())); + args.add("--cidr"); args.add(safeStr(network.getCidr())); + args.add("--dns"); args.add(safeStr(getNetworkDns(network))); + args.add("--vlan"); args.add(safeStr(getVlanId(network))); + args.add("--domain"); args.add(safeStr(network.getNetworkDomain())); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "config-dns-subnet", args.toArray(new String[0])); + } + + @Override + public boolean removeDnsSupportForSubnet(Network network) throws ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + logger.debug("removeDnsSupportForSubnet: network={}", network.getId()); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "remove-dns-subnet", args.toArray(new String[0])); + } + + // ---- UserDataServiceProvider ---- + + @Override + public boolean addPasswordAndUserdata(Network network, NicProfile nic, VirtualMachineProfile profile, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + + VirtualMachine vm = profile.getVirtualMachine(); + + // SSH public key from VM instance details + String sshPublicKey = null; + try { + VMInstanceDetailVO sshKeyDetail = vmInstanceDetailsDao.findDetail(profile.getId(), VmDetailConstants.SSH_PUBLIC_KEY); + if (sshKeyDetail != null) { + sshPublicKey = sshKeyDetail.getValue(); + } + } catch (Exception e) { + logger.debug("Could not fetch SSH public key for VM {}: {}", profile.getId(), e.getMessage()); + } + + // Service offering display name + String serviceOfferingName = ""; + try { + serviceOfferingName = profile.getServiceOffering().getDisplayText(); + } catch (Exception e) { + logger.debug("Could not fetch service offering for VM {}: {}", profile.getId(), e.getMessage()); + } + + // Is Windows guest? + boolean isWindows = false; + try { + isWindows = guestOSCategoryDao + .findById(guestOSDao.findById(vm.getGuestOSId()).getCategoryId()) + .getName().equalsIgnoreCase("Windows"); + } catch (Exception e) { + logger.debug("Could not determine OS type for VM {}: {}", profile.getId(), e.getMessage()); + } + + // Hypervisor hostname – prefer dest host, fall back to current host + String destHostname = null; + try { + if (dest != null && dest.getHost() != null) { + destHostname = VirtualMachineManager.getHypervisorHostname(dest.getHost().getName()); + } else if (vm.getHostId() != null) { + destHostname = VirtualMachineManager.getHypervisorHostname( + hostDao.findById(vm.getHostId()).getName()); + } + } catch (Exception e) { + logger.debug("Could not resolve hypervisor hostname for VM {}: {}", profile.getId(), e.getMessage()); + } + + // Password from the VM profile parameter (set by UserVmManager before deployment) + String password = (String) profile.getParameter(VirtualMachineProfile.Param.VmPassword); + + // Use this NIC's IP — the metadata server in each namespace identifies requesters + // by REMOTE_ADDR, which will be the VM's IP on THIS network (not necessarily the + // default NIC IP), so we always key metadata by the NIC's IP on this network. + String nicIpAddress = nic.getIPv4Address(); + + logger.debug("addPasswordAndUserdata: network={} ip={} hasPassword={} hasSshKey={}", + network.getId(), nicIpAddress, + password != null && !password.isEmpty(), + sshPublicKey != null && !sshPublicKey.isEmpty()); + + final UserVmVO userVm = userVmDao.findById(vm.getId()); + if (userVm == null) { + throw new CloudRuntimeException("Could not find UserVmVO for VM " + vm.getId()); + } + + // Generate the full metadata set (userdata, meta-data/*, password) in one go + List vmData = networkModel.generateVmData( + userVm.getUserData(), + userVm.getUserDataDetails(), + serviceOfferingName, + vm.getDataCenterId(), + profile.getInstanceName(), + profile.getHostName(), + profile.getId(), + profile.getUuid(), + nicIpAddress, + sshPublicKey, + password, + isWindows, + destHostname); + + if (vmData == null || vmData.isEmpty()) { + logger.debug("addPasswordAndUserdata: no VM data generated for network={} ip={}", network.getId(), nicIpAddress); + return true; + } + + // Serialise vmData as JSON array. + // For the userdata entry CloudStack stores user-data base64-encoded; decode it so the + // wrapper writes the actual bytes. All other fields are plain strings. In both cases we + // then re-encode with Base64 so the single --vm-data argument is shell-safe. + StringBuilder json = new StringBuilder("["); + boolean first = true; + for (String[] entry : vmData) { + String dir = entry[NetworkModel.CONFIGDATA_DIR]; + String file = entry[NetworkModel.CONFIGDATA_FILE]; + String content = entry.length > NetworkModel.CONFIGDATA_CONTENT + ? entry[NetworkModel.CONFIGDATA_CONTENT] : null; + if (content == null) content = ""; + + byte[] contentBytes; + if (NetworkModel.USERDATA_DIR.equals(dir) && NetworkModel.USERDATA_FILE.equals(file)) { + // user-data is stored as base64 in CloudStack DB; decode it for the wrapper + try { + contentBytes = Base64.getDecoder().decode(content); + } catch (Exception e) { + contentBytes = content.getBytes(StandardCharsets.UTF_8); + } + } else { + contentBytes = content.getBytes(StandardCharsets.UTF_8); + } + + if (!first) json.append(","); + first = false; + json.append("{\"dir\":\"").append(jsonEscape(dir)) + .append("\",\"file\":\"").append(jsonEscape(file)) + .append("\",\"content\":\"") + .append(Base64.getEncoder().encodeToString(contentBytes)) + .append("\"}"); + } + json.append("]"); + + // Wrap the entire JSON as base64 to avoid any shell quoting / escaping issues + String vmDataArg = Base64.getEncoder().encodeToString( + json.toString().getBytes(StandardCharsets.UTF_8)); + + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--ip"); args.add(safeStr(nicIpAddress)); + args.add("--gateway"); args.add(safeStr(nic.getIPv4Gateway())); + args.add("--extension-ip"); args.add(safeStr(ensureExtensionIp(network))); + args.addAll(getVpcIdArgs(network)); + return executeScriptWithFilePayload(network, "save-vm-data", "--vm-data-file", + vmDataArg, args.toArray(new String[0])); + } + + @Override + public boolean savePassword(Network network, NicProfile nic, VirtualMachineProfile vm) + throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String password = (String) vm.getParameter(VirtualMachineProfile.Param.VmPassword); + if (password == null || password.isEmpty()) { + return true; + } + logger.debug("savePassword: network={} ip={}", network.getId(), nic.getIPv4Address()); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--ip"); args.add(safeStr(nic.getIPv4Address())); + args.add("--gateway"); args.add(safeStr(nic.getIPv4Gateway())); + args.add("--password"); args.add(password); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "save-password", args.toArray(new String[0])); + } + + @Override + public boolean saveUserData(Network network, NicProfile nic, VirtualMachineProfile vm) + throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String userData = null; + if (vm.getVirtualMachine() instanceof UserVm) { + userData = ((UserVm) vm.getVirtualMachine()).getUserData(); + } + if (userData == null || userData.isEmpty()) { + return true; + } + logger.debug("saveUserData: network={} ip={}", network.getId(), nic.getIPv4Address()); + // userData is stored as base64; pass it directly so the script can decode it + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--ip"); args.add(safeStr(nic.getIPv4Address())); + args.add("--gateway"); args.add(safeStr(nic.getIPv4Gateway())); + args.add("--userdata"); args.add(userData); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "save-userdata", args.toArray(new String[0])); + } + + @Override + public boolean saveSSHKey(Network network, NicProfile nic, VirtualMachineProfile vm, + String sshPublicKey) throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + if (sshPublicKey == null || sshPublicKey.isEmpty()) { + return true; + } + logger.debug("saveSSHKey: network={} ip={}", network.getId(), nic.getIPv4Address()); + // Encode SSH key as base64 to safely pass via CLI + String sshKeyBase64 = Base64.getEncoder().encodeToString(sshPublicKey.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--ip"); args.add(safeStr(nic.getIPv4Address())); + args.add("--gateway"); args.add(safeStr(nic.getIPv4Gateway())); + args.add("--sshkey"); args.add(sshKeyBase64); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "save-sshkey", args.toArray(new String[0])); + } + + @Override + public boolean saveHypervisorHostname(NicProfile nic, Network network, VirtualMachineProfile vm, + DeployDestination dest) throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String hostname = dest != null && dest.getHost() != null ? dest.getHost().getName() : null; + if (hostname == null || hostname.isEmpty()) { + return true; + } + logger.debug("saveHypervisorHostname: network={} ip={} host={}", network.getId(), + nic.getIPv4Address(), hostname); + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--ip"); args.add(safeStr(nic.getIPv4Address())); + args.add("--gateway"); args.add(safeStr(nic.getIPv4Gateway())); + args.add("--hypervisor-hostname"); args.add(hostname); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.addAll(getVpcIdArgs(network)); + return executeScript(network, "save-hypervisor-hostname", args.toArray(new String[0])); + } + + // ---- LoadBalancingServiceProvider ---- + + @Override + public boolean applyLBRules(Network network, List rules) + throws ResourceUnavailableException { + if (rules == null || rules.isEmpty()) { + return true; + } + if (!canHandle(network, Service.Lb)) { + return false; + } + logger.info("Applying {} LB rules for network {}", rules.size(), network.getId()); + String vlanId = getVlanId(network); + List vpcArgs = getVpcIdArgs(network); + + // Serialise all rules as a JSON array and pass as a single --lb-rules argument + StringBuilder json = new StringBuilder("["); + boolean firstRule = true; + for (LoadBalancingRule rule : rules) { + if (!firstRule) json.append(","); + firstRule = false; + boolean revoke = rule.getState() == FirewallRule.State.Revoke; + json.append("{"); + json.append("\"id\":").append(rule.getId()).append(","); + json.append("\"name\":\"").append(jsonEscape(rule.getName())).append("\","); + json.append("\"publicIp\":\"").append(jsonEscape(rule.getSourceIp() != null ? rule.getSourceIp().addr() : "")).append("\","); + json.append("\"publicPort\":").append(rule.getSourcePortStart()).append(","); + json.append("\"privatePort\":").append(rule.getDefaultPortStart()).append(","); + json.append("\"protocol\":\"").append(jsonEscape(safeStr(rule.getProtocol()))).append("\","); + json.append("\"algorithm\":\"").append(jsonEscape(safeStr(rule.getAlgorithm()))).append("\","); + json.append("\"revoke\":").append(revoke).append(","); + json.append("\"backends\":["); + if (rule.getDestinations() != null) { + boolean firstDest = true; + for (LoadBalancingRule.LbDestination dest : rule.getDestinations()) { + if (!firstDest) json.append(","); + firstDest = false; + json.append("{"); + json.append("\"ip\":\"").append(jsonEscape(dest.getIpAddress())).append("\","); + json.append("\"port\":").append(dest.getDestinationPortStart()).append(","); + json.append("\"revoked\":").append(dest.isRevoked()); + json.append("}"); + } + } + json.append("]"); + json.append("}"); + } + json.append("]"); + + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--vlan"); args.add(safeStr(vlanId)); + args.add("--lb-rules"); args.add(json.toString()); + args.addAll(vpcArgs); + boolean result = executeScript(network, "apply-lb-rules", args.toArray(new String[0])); + if (!result) { + throw new ResourceUnavailableException("Failed to apply LB rules for network " + network.getId(), + Network.class, network.getId()); + } + return true; + } + + @Override + public boolean validateLBRule(Network network, LoadBalancingRule rule) { + // Delegate validation to the external script; accept by default + return true; + } + + @Override + public List updateHealthChecks(Network network, + List lbrules) { + // Health-check state updates are not implemented via this path + return new ArrayList<>(); + } + + @Override + public boolean handlesOnlyRulesInTransitionState() { + return false; + } + + /** Escapes a string for embedding in a JSON string literal. */ + private static String jsonEscape(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + @Override + public IpDeployer getIpDeployer(Network network) { + // This element itself implements IpDeployer; return this instance. + return this; + } + + @Override + public boolean rollingRestartSupported() { + return false; + } + + /** + * Applies all active firewall rules for a network to the external network device. + * + *

Three categories of rules are handled:

+ *
    + *
  1. Egress rules ({@link FirewallRule.TrafficType#Egress}) — control outbound + * traffic from guest VMs. The network offering's {@code egressDefaultPolicy} flag + * is consulted: + *
      + *
    • {@code true} (ALLOW by default) — each egress rule becomes a DROP rule; + * a catch-all ACCEPT is appended at the end.
    • + *
    • {@code false} (DENY by default) — each egress rule becomes an ACCEPT rule; + * a catch-all DROP is appended at the end.
    • + *
    + *
  2. + *
  3. Ingress rules ({@link FirewallRule.TrafficType#Ingress}) on public IPs + * (static NAT, port-forwarding, LB, …) — control inbound access to a specific + * public IP. The wrapper script uses {@code conntrack --ctorigdst} to match the + * original pre-DNAT destination, so no private-IP lookup is required and all + * DNAT-based services (static-NAT, port-forwarding, LB) are handled uniformly.
  4. + *
  5. Default egress policy — always conveyed via the JSON payload so the + * script can enforce it even when the explicit rule list is empty.
  6. + *
+ * + *

Full-state rebuild semantics: + * {@code applyFWRules} is called with a narrow scope — the firewall manager + * passes only the rules for one public IP ({@code applyIngressFirewallRules}) or only + * the egress rules ({@code applyEgressFirewallRules}) per call. The script, however, + * rebuilds the entire firewall chain from scratch each time it runs. To avoid wiping + * the rules for other IPs on every call, this method ignores the {@code rules} parameter + * and instead queries the database for all active (non-revoked, non-System) + * {@link FirewallRule.Purpose#Firewall} rules for the network.

+ * + *

Script command: {@code apply-fw-rules}

+ */ + @Override + public boolean applyFWRules(Network network, List rules) + throws ResourceUnavailableException { + if (!canHandle(network, Service.Firewall)) { + return false; + } + + // Determine default egress policy from the network offering. + // true = ALLOW (default permissive; explicit rules are deny-rules) + // false = DENY (default restrictive; explicit rules are allow-rules) + NetworkOfferingVO offering = networkOfferingDao.findById(network.getNetworkOfferingId()); + boolean defaultEgressAllow = offering == null || offering.isEgressDefaultPolicy(); + + // Load ALL active (non-revoked) firewall rules for this network from the DB. + // applyFWRules is called in a narrow scope (only one public IP's ingress rules, or + // only egress rules per call), but the script does a full rebuild of the firewall + // chain. Querying the DB ensures every call produces a complete, correct chain. + List allRules = firewallRulesDao.listByNetworkAndPurposeAndNotRevoked( + network.getId(), FirewallRule.Purpose.Firewall); + // Skip System-type rules — the default egress policy is already conveyed by + // "default_egress_allow". System rules are transient (not stored in DB), but + // guard here anyway in case of future changes. + allRules = allRules.stream() + .filter(r -> !FirewallRule.FirewallRuleType.System.equals(r.getType())) + .collect(Collectors.toList()); + + for (FirewallRuleVO r : allRules) { + firewallRulesDao.loadSourceCidrs(r); + firewallRulesDao.loadDestinationCidrs(r); + } + + logger.info("applyFWRules: network={} activeRules={} defaultEgressAllow={}", + network.getId(), allRules.size(), defaultEgressAllow); + + // Build JSON payload: { "default_egress_allow": , "cidr": "...", "rules": [...] } + StringBuilder json = new StringBuilder(); + json.append("{\"default_egress_allow\":").append(defaultEgressAllow).append(","); + json.append("\"cidr\":\"").append(jsonEscape(safeStr(network.getCidr()))).append("\","); + json.append("\"rules\":["); + + boolean first = true; + for (FirewallRuleVO rule : allRules) { + if (!first) json.append(","); + first = false; + + boolean isEgress = FirewallRule.TrafficType.Egress.equals(rule.getTrafficType()); + + json.append("{"); + json.append("\"id\":").append(rule.getId()).append(","); + json.append("\"type\":\"").append(isEgress ? "egress" : "ingress").append("\","); + json.append("\"protocol\":\"").append(jsonEscape(safeStr(rule.getProtocol()))).append("\","); + if (rule.getSourcePortStart() != null) { + json.append("\"portStart\":").append(rule.getSourcePortStart()).append(","); + } + if (rule.getSourcePortEnd() != null) { + json.append("\"portEnd\":").append(rule.getSourcePortEnd()).append(","); + } + if (rule.getIcmpType() != null) { + json.append("\"icmpType\":").append(rule.getIcmpType()).append(","); + } + if (rule.getIcmpCode() != null) { + json.append("\"icmpCode\":").append(rule.getIcmpCode()).append(","); + } + // For ingress rules include the public IP the rule is associated with. + if (!isEgress) { + json.append("\"publicIp\":\"") + .append(jsonEscape(getIpAddress(rule.getSourceIpAddressId()))) + .append("\","); + } + // sourceCidrs: for ingress = allowed external source IPs; + // for egress = allowed VM source IP ranges + json.append("\"sourceCidrs\":["); + List sourceCidrs = rule.getSourceCidrList(); + if (sourceCidrs != null && !sourceCidrs.isEmpty()) { + boolean firstCidr = true; + for (String cidr : sourceCidrs) { + if (!firstCidr) json.append(","); + firstCidr = false; + json.append("\"").append(jsonEscape(cidr)).append("\""); + } + } + json.append("]"); + // destCidrs: optional destination CIDR filter (meaningful for egress rules) + List destCidrs = rule.getDestinationCidrList(); + json.append(",\"destCidrs\":["); + if (destCidrs != null && !destCidrs.isEmpty()) { + boolean firstCidr = true; + for (String cidr : destCidrs) { + if (!firstCidr) json.append(","); + firstCidr = false; + json.append("\"").append(jsonEscape(cidr)).append("\""); + } + } + json.append("]"); + json.append("}"); + } + json.append("]}"); + + String rulesBase64 = Base64.getEncoder().encodeToString( + json.toString().getBytes(StandardCharsets.UTF_8)); + + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--vlan"); args.add(safeStr(getVlanId(network))); + args.add("--gateway"); args.add(safeStr(network.getGateway())); + args.add("--cidr"); args.add(safeStr(network.getCidr())); + args.addAll(getVpcIdArgs(network)); + + boolean result = executeScriptWithFilePayload(network, "apply-fw-rules", "--fw-rules-file", + rulesBase64, args.toArray(new String[0])); + if (!result) { + throw new ResourceUnavailableException( + "Failed to apply firewall rules for network " + network.getId(), + Network.class, network.getId()); + } + return true; + } + + // ---- AggregatedCommandExecutor ---- + + /** + * Called at the start of a network-restart cycle (before rules are re-programmed). + * We have nothing to "start" here — the batch restore is driven by + * {@link #completeAggregatedExecution}. + */ + @Override + public boolean prepareAggregatedExecution(Network network, DeployDestination dest) + throws ResourceUnavailableException { + if (!canHandle(network, null)) { + return true; + } + logger.debug("prepareAggregatedExecution: network={}", network.getId()); + return true; + } + + /** + * Called after all firewall/NAT/LB rules have been re-applied during a network restart. + * + *

Queries all active User-VM NICs on this network from the database, builds a single + * batch JSON payload containing DHCP/DNS/metadata entries for every VM, and sends it to + * the wrapper script as a single {@code restore-network} call. This avoids N script + * invocations (one per VM) and instead performs the full restore in one shot.

+ */ + @Override + public boolean completeAggregatedExecution(Network network, DeployDestination dest) + throws ResourceUnavailableException { + if (!canHandle(network, null)) { + return true; + } + + logger.info("completeAggregatedExecution: restoring all VM network data for network={}", network.getId()); + + boolean dhcpEnabled = networkModel.areServicesSupportedInNetwork(network.getId(), Service.Dhcp) + && networkModel.isProviderSupportServiceInNetwork(network.getId(), Service.Dhcp, getProvider()); + boolean dnsEnabled = networkModel.areServicesSupportedInNetwork(network.getId(), Service.Dns) + && networkModel.isProviderSupportServiceInNetwork(network.getId(), Service.Dns, getProvider()); + boolean userdataEnabled = networkModel.areServicesSupportedInNetwork(network.getId(), Service.UserData) + && networkModel.isProviderSupportServiceInNetwork(network.getId(), Service.UserData, getProvider()); + + if (!dhcpEnabled && !dnsEnabled && !userdataEnabled) { + logger.debug("completeAggregatedExecution: no DHCP/DNS/UserData service for network={}, skipping", network.getId()); + return true; + } + + // Query all active User-VM NICs on this network + List nics = nicDao.listByNetworkIdAndType(network.getId(), VirtualMachine.Type.User); + if (nics == null || nics.isEmpty()) { + logger.debug("completeAggregatedExecution: no user VM NICs on network={}, skipping", network.getId()); + return true; + } + + logger.info("completeAggregatedExecution: building batch restore for {} VMs on network={}", + nics.size(), network.getId()); + + String restoreDataBase64 = buildRestoreNetworkData(network, nics, dhcpEnabled, dnsEnabled, userdataEnabled); + + String extensionIp = ensureExtensionIp(network); + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--gateway"); args.add(safeStr(network.getGateway())); + args.add("--cidr"); args.add(safeStr(network.getCidr())); + args.add("--vlan"); args.add(safeStr(getVlanId(network))); + args.add("--extension-ip"); args.add(safeStr(extensionIp)); + args.add("--dns"); args.add(safeStr(getNetworkDns(network))); + args.add("--domain"); args.add(safeStr(network.getNetworkDomain())); + args.addAll(getVpcIdArgs(network)); + + return executeScriptWithFilePayload(network, "restore-network", "--restore-data-file", + restoreDataBase64, args.toArray(new String[0])); + } + + /** + * Called in the {@code finally} block of the network-restart cycle to clean up any + * temporary state created by {@link #prepareAggregatedExecution}. + * Nothing to clean up here. + */ + @Override + public boolean cleanupAggregatedExecution(Network network, DeployDestination dest) + throws ResourceUnavailableException { + return true; + } + + /** + * Builds the base64-encoded JSON payload for {@code restore-network}. + * + *

The JSON structure is:

+ *
+     * {
+     *   "dhcp_enabled": true,
+     *   "dns_enabled":  true,
+     *   "userdata_enabled": true,
+     *   "vms": [
+     *     {
+     *       "ip":          "10.0.0.10",
+     *       "mac":         "02:00:00:00:00:01",
+     *       "hostname":    "vm-1",
+     *       "default_nic": true,
+     *       "vm_data": [
+     *         { "dir": "userdata", "file": "user-data", "content": "" },
+     *         { "dir": "meta-data", "file": "instance-id", "content": "" },
+     *         ...
+     *       ]
+     *     },
+     *     ...
+     *   ]
+     * }
+     * 
+ * + *

Each {@code vm_data} entry has its {@code content} base64-encoded (the same + * encoding used by the per-VM {@code save-vm-data} command), so the wrapper script + * can handle both paths with the same decoder.

+ */ + private String buildRestoreNetworkData(Network network, List nics, + boolean dhcpEnabled, boolean dnsEnabled, boolean userdataEnabled) { + + // Precompute service-offering display text keyed by offering ID to avoid repeated DB hits + Map offeringNameCache = new HashMap<>(); + + StringBuilder json = new StringBuilder("{"); + json.append("\"dhcp_enabled\":").append(dhcpEnabled).append(","); + json.append("\"dns_enabled\":").append(dnsEnabled).append(","); + json.append("\"userdata_enabled\":").append(userdataEnabled).append(","); + json.append("\"vms\":["); + + boolean firstVm = true; + for (NicVO nic : nics) { + if (nic.getState() != Nic.State.Reserved && nic.getState() != Nic.State.Allocated) { + continue; + } + if (nic.getIPv4Address() == null || nic.getMacAddress() == null) { + continue; + } + + Long instanceId = nic.getInstanceId(); + if (instanceId == null) { + continue; + } + + UserVmVO userVm = userVmDao.findById(instanceId); + if (userVm == null) { + continue; + } + + // Per-VM data array (only if UserData service is enabled) + List vmData = null; + if (userdataEnabled) { + try { + // Service offering display text + String offeringName = offeringNameCache.computeIfAbsent(userVm.getServiceOfferingId(), id -> { + try { + ServiceOfferingVO so = serviceOfferingDao.findById(id); + return so != null ? so.getDisplayText() : ""; + } catch (Exception e) { + return ""; + } + }); + + // SSH public key + String sshPublicKey = null; + try { + VMInstanceDetailVO sshKeyDetail = vmInstanceDetailsDao.findDetail(instanceId, VmDetailConstants.SSH_PUBLIC_KEY); + if (sshKeyDetail != null) { + sshPublicKey = sshKeyDetail.getValue(); + } + } catch (Exception e) { + logger.debug("Could not fetch SSH key for VM {}: {}", instanceId, e.getMessage()); + } + + // Is Windows? + boolean isWindows = false; + try { + isWindows = guestOSCategoryDao + .findById(guestOSDao.findById(userVm.getGuestOSId()).getCategoryId()) + .getName().equalsIgnoreCase("Windows"); + } catch (Exception ignored) { } + + // Hypervisor hostname from current host + String destHostname = null; + try { + if (userVm.getHostId() != null) { + destHostname = VirtualMachineManager.getHypervisorHostname( + hostDao.findById(userVm.getHostId()).getName()); + } + } catch (Exception ignored) { } + + vmData = networkModel.generateVmData( + userVm.getUserData(), + userVm.getUserDataDetails(), + offeringName, + userVm.getDataCenterId(), + userVm.getInstanceName(), + userVm.getHostName(), + userVm.getId(), + userVm.getUuid(), + nic.getIPv4Address(), + sshPublicKey, + null, // password — not re-issued on restore + isWindows, + destHostname); + } catch (Exception e) { + logger.warn("Could not generate vmData for VM {} on network {}: {}", instanceId, network.getId(), e.getMessage()); + } + } + + // Build VM JSON entry + if (!firstVm) json.append(","); + firstVm = false; + + json.append("{"); + json.append("\"ip\":\"").append(jsonEscape(nic.getIPv4Address())).append("\","); + json.append("\"mac\":\"").append(jsonEscape(nic.getMacAddress())).append("\","); + json.append("\"hostname\":\"").append(jsonEscape(safeStr(userVm.getHostName()))).append("\","); + json.append("\"default_nic\":").append(nic.isDefaultNic()).append(","); + json.append("\"vm_data\":["); + + if (vmData != null && !vmData.isEmpty()) { + boolean firstEntry = true; + for (String[] entry : vmData) { + String dir = entry[NetworkModel.CONFIGDATA_DIR]; + String file = entry[NetworkModel.CONFIGDATA_FILE]; + String content = entry.length > NetworkModel.CONFIGDATA_CONTENT + ? entry[NetworkModel.CONFIGDATA_CONTENT] : null; + if (content == null) content = ""; + + byte[] contentBytes; + if (NetworkModel.USERDATA_DIR.equals(dir) && NetworkModel.USERDATA_FILE.equals(file)) { + try { + contentBytes = Base64.getDecoder().decode(content); + } catch (Exception e) { + contentBytes = content.getBytes(StandardCharsets.UTF_8); + } + } else { + contentBytes = content.getBytes(StandardCharsets.UTF_8); + } + + if (!firstEntry) json.append(","); + firstEntry = false; + json.append("{\"dir\":\"").append(jsonEscape(dir)) + .append("\",\"file\":\"").append(jsonEscape(file)) + .append("\",\"content\":\"") + .append(Base64.getEncoder().encodeToString(contentBytes)) + .append("\"}"); + } + } + + json.append("]"); // vm_data + json.append("}"); // vm object + } + + json.append("]"); // vms + json.append("}"); // root + + return Base64.getEncoder().encodeToString(json.toString().getBytes(StandardCharsets.UTF_8)); + } + + // ---- VpcProvider ---- + + /** + * Finds the extension + physical-network pair for the given VPC by scanning the + * physical networks in the VPC's zone for a registered NetworkOrchestrator extension. + * Returns {@code null} when no suitable extension is found. + */ + protected Pair resolveExtensionForVpc(Vpc vpc) { + List physNetworks = physicalNetworkDao.listByZone(vpc.getZoneId()); + if (physNetworks == null || physNetworks.isEmpty()) { + return null; + } + for (PhysicalNetworkVO pn : physNetworks) { + Extension ext; + if (providerName != null && !providerName.isBlank()) { + ext = extensionHelper.getExtensionForPhysicalNetworkAndProvider(pn.getId(), providerName); + } else { + ext = extensionHelper.getExtensionForPhysicalNetwork(pn.getId()); + } + if (ext != null) { + return new Pair<>(pn.getId(), ext); + } + } + return null; + } + + /** + * Resolves the script file for a VPC-level operation (no network object required). + */ + protected File resolveScriptFileForVpc(Long physicalNetworkId, Extension extension) { + if (physicalNetworkId == null) { + throw new CloudRuntimeException("No physical network ID for VPC extension"); + } + if (extension == null) { + throw new CloudRuntimeException("No extension found for physical network " + physicalNetworkId); + } + if (!Extension.Type.NetworkOrchestrator.equals(extension.getType())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not of type NetworkOrchestrator"); + } + if (!Extension.State.Enabled.equals(extension.getState())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not enabled"); + } + if (!extension.isPathReady()) { + throw new CloudRuntimeException("Extension " + extension.getName() + " path is not ready"); + } + String extensionPath = extensionHelper.getExtensionScriptPath(extension); + if (extensionPath == null) { + throw new CloudRuntimeException("Could not resolve path for extension " + extension.getName()); + } + File extensionDir = new File(extensionPath); + File namedScript = new File(extensionDir, extension.getName() + ".sh"); + if (namedScript.exists() && namedScript.canExecute()) { + return namedScript; + } + if (extensionDir.isFile() && extensionDir.canExecute()) { + return extensionDir; + } + throw new CloudRuntimeException( + "No executable script found in extension path " + extensionPath + + ". Expected '" + extension.getName() + ".sh'."); + } + + /** + * Calls {@code ensure-network-device} with VPC-level args (no {@code --network-id}). + * The returned JSON is persisted in {@code vpc_details} under key + * {@value #NETWORK_DETAIL_EXTENSION_DETAILS}. VPC tier networks then inherit + * these details via {@link #ensureExtensionDetails(Network)}. + */ + protected void ensureExtensionDetails(Vpc vpc) { + Map stored = vpcDetailsDao.listDetailsKeyPairs(vpc.getId()); + String currentDetails = stored != null + ? stored.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + + logger.info("Ensuring extension device for VPC {} (current={})", vpc.getId(), currentDetails); + + Pair physNetAndExt = resolveExtensionForVpc(vpc); + if (physNetAndExt == null) { + logger.warn("ensureExtensionDetails(vpc): no extension found for VPC {} zone {}", + vpc.getId(), vpc.getZoneId()); + return; + } + Long physicalNetworkId = physNetAndExt.first(); + Extension extension = physNetAndExt.second(); + File scriptFile = resolveScriptFileForVpc(physicalNetworkId, extension); + String physicalNetworkDetailsJson = buildPhysicalNetworkDetailsJson(physicalNetworkId, extension); + + List cmdLine = new ArrayList<>(); + cmdLine.add(scriptFile.getAbsolutePath()); + cmdLine.add("ensure-network-device"); + cmdLine.add("--vpc-id"); + cmdLine.add(String.valueOf(vpc.getId())); + cmdLine.add("--zone-id"); + cmdLine.add(String.valueOf(vpc.getZoneId())); + cmdLine.add("--current-details"); + cmdLine.add(currentDetails); + cmdLine.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS); + cmdLine.add(physicalNetworkDetailsJson); + cmdLine.add(ARG_NETWORK_EXTENSION_DETAILS); + cmdLine.add(currentDetails); + + try { + ProcessBuilder pb = new ProcessBuilder(cmdLine); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = new String(process.getInputStream().readAllBytes()).trim(); + int exitCode = process.waitFor(); + + logger.debug("Ensuring VPC network device script: {}", String.join(" ", cmdLine)); + + if (exitCode != 0) { + logger.warn("ensure-network-device exited {} for VPC {} — keeping current details", + exitCode, vpc.getId()); + if ("{}".equals(currentDetails)) { + vpcDetailsDao.addDetail(vpc.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + return; + } + if (output.isEmpty()) { + output = "{}".equals(currentDetails) ? "{}" : currentDetails; + } + if (!output.equals(currentDetails)) { + logger.info("VPC extension device updated for VPC {}: {}", vpc.getId(), output); + vpcDetailsDao.addDetail(vpc.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, output, false); + } else { + logger.debug("VPC extension device unchanged for VPC {}: {}", vpc.getId(), output); + } + } catch (Exception e) { + logger.warn("Failed ensure-network-device for VPC {}: {}", vpc.getId(), e.getMessage()); + if ("{}".equals(currentDetails)) { + vpcDetailsDao.addDetail(vpc.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + } + } + + /** + * Returns the per-VPC extension-details JSON from {@code vpc_details} + * (returns {@code {}} if not yet set). + */ + private String getVpcExtensionDetailsJson(long vpcId) { + Map vpcDetails = vpcDetailsDao.listDetailsKeyPairs(vpcId); + return vpcDetails != null + ? vpcDetails.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + } + + /** + * Executes the extension script for a VPC-level command (no tier network required). + * Uses VPC-level details from {@code vpc_details}. + */ + protected boolean executeVpcScript(Vpc vpc, String command, String... args) { + Pair physNetAndExt = resolveExtensionForVpc(vpc); + if (physNetAndExt == null) { + logger.warn("executeVpcScript: no extension found for VPC {} zone {}", vpc.getId(), vpc.getZoneId()); + return false; + } + Long physicalNetworkId = physNetAndExt.first(); + Extension extension = physNetAndExt.second(); + File scriptFile = resolveScriptFileForVpc(physicalNetworkId, extension); + + String physicalNetworkDetailsJson = buildPhysicalNetworkDetailsJson(physicalNetworkId, extension); + String vpcExtDetailsJson = getVpcExtensionDetailsJson(vpc.getId()); + + logger.debug("Physical network details JSON: {}", physicalNetworkDetailsJson); + logger.debug("VPC extension details JSON: {}", vpcExtDetailsJson); + + List cmdLine = new ArrayList<>(); + cmdLine.add(scriptFile.getAbsolutePath()); + cmdLine.add(command); + cmdLine.addAll(Arrays.asList(args)); + cmdLine.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS); + cmdLine.add(physicalNetworkDetailsJson); + cmdLine.add(ARG_NETWORK_EXTENSION_DETAILS); + cmdLine.add(vpcExtDetailsJson); + + logger.debug("Executing VPC extension script: {}", String.join(" ", cmdLine)); + + try { + ProcessBuilder pb = new ProcessBuilder(cmdLine); + pb.redirectErrorStream(true); + Process process = pb.start(); + byte[] output = process.getInputStream().readAllBytes(); + int exitCode = process.waitFor(); + + String outputStr = new String(output).trim(); + if (!outputStr.isEmpty()) { + logger.debug("Script output: {}", outputStr); + } + if (exitCode != 0) { + logger.error("VPC extension script {} failed with exit code {}: {}", command, exitCode, outputStr); + return false; + } + return true; + } catch (Exception e) { + logger.error("Failed to execute VPC extension script {}: {}", command, e.getMessage(), e); + throw new CloudRuntimeException("Failed to execute VPC extension script: " + command, e); + } + } + + protected PublicIpAddress getVpcSourceNatIp(long vpcId) { + final List ips = ipAddressDao.listByAssociatedVpc(vpcId, true); + if (ips == null || ips.isEmpty()) { + return null; + } + IPAddressVO selected = null; + for (final IPAddressVO ip : ips) { + if (ip.getState() != IpAddress.State.Releasing) { + selected = ip; + break; + } + } + if (selected == null) { + selected = ips.get(0); + } + + final VlanVO vlan = vlanDao.findById(selected.getVlanId()); + if (vlan == null) { + logger.warn("No VLAN found for VPC source NAT IP {} (vpc={})", selected.getAddress(), vpcId); + return null; + } + return PublicIp.createFromAddrAndVlan(selected, vlan); + } + + /** + * Implements the VPC by: + *
    + *
  1. Calling {@link #ensureExtensionDetails(Vpc)} to select a host and + * save the VPC-level details (does not use any anchor tier network).
  2. + *
  3. Calling the script's {@code implement-vpc} command to create the VPC + * namespace and VPC-level networking state.
  4. + *
  5. Applying VPC source NAT if a source-NAT IP already exists (the script's + * {@code assign-ip} sets up the public veth + SNAT rule for the VPC CIDR).
  6. + *
+ */ + @Override + public boolean implementVpc(Vpc vpc, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { + + // Step 1: Ensure a VPC extension device is selected and details saved at VPC level. + ensureExtensionDetails(vpc); + + // Step 2: Create the VPC namespace (no anchor tier network needed). + List implArgs = new ArrayList<>(); + implArgs.add("--vpc-id"); implArgs.add(String.valueOf(vpc.getId())); + implArgs.add("--cidr"); implArgs.add(safeStr(vpc.getCidr())); + + // Include source NAT IP if already allocated, so the script can set up the + // VPC-level SNAT rule for the entire VPC CIDR. + final PublicIpAddress sourceNatIp = getVpcSourceNatIp(vpc.getId()); + if (sourceNatIp != null) { + implArgs.add("--public-ip"); implArgs.add(safeStr(sourceNatIp.getAddress().addr())); + implArgs.add("--public-vlan"); implArgs.add(safeStr(getPublicVlanTag(sourceNatIp.getId()))); + implArgs.add("--public-gateway"); implArgs.add(safeStr(sourceNatIp.getGateway())); + implArgs.add("--public-cidr"); implArgs.add(safeStr(getPublicCidr(sourceNatIp.getId()))); + implArgs.add("--source-nat"); implArgs.add("true"); + } + + if (!executeVpcScript(vpc, "implement-vpc", implArgs.toArray(new String[0]))) { + return false; + } + + return true; + } + + /** + * Shuts down the VPC by: + *
    + *
  1. Calling {@code destroy-network} for each extension-backed VPC tier (removes + * tier resources but preserves the shared VPC namespace).
  2. + *
  3. Calling {@code shutdown-vpc} to remove the VPC namespace and state after + * all tiers have been cleaned up.
  4. + *
+ */ + @Override + public boolean shutdownVpc(Vpc vpc, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + final List networks = networkModel.listNetworksByVpc(vpc.getId()); + + boolean result = true; + if (networks != null) { + for (final Network network : networks) { + if (!canHandle(network, null)) { + continue; + } + + final List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--vlan"); args.add(safeStr(getVlanId(network))); + args.addAll(getVpcIdArgs(network)); + + final boolean tierResult = executeScript(network, "destroy-network", args.toArray(new String[0])); + result = result && tierResult; + } + } + + // Remove the VPC namespace and VPC-level details regardless of tier result. + List vpcArgs = new ArrayList<>(); + vpcArgs.add("--vpc-id"); vpcArgs.add(String.valueOf(vpc.getId())); + boolean vpcResult = executeVpcScript(vpc, "shutdown-vpc", vpcArgs.toArray(new String[0])); + if (vpcResult) { + try { + vpcDetailsDao.removeDetail(vpc.getId(), NETWORK_DETAIL_EXTENSION_DETAILS); + } catch (Exception e) { + logger.warn("Failed to remove VPC extension details for VPC {}: {}", vpc.getId(), e.getMessage()); + } + } + result = result && vpcResult; + + return result; + } + + @Override + public boolean createPrivateGateway(PrivateGateway gateway) + throws ConcurrentOperationException, ResourceUnavailableException { + throw new UnsupportedOperationException("Private gateways are not supported by the network extension element."); + } + + /** Private gateways are not supported by the network extension element. */ + @Override + public boolean deletePrivateGateway(PrivateGateway gateway) + throws ConcurrentOperationException, ResourceUnavailableException { + throw new UnsupportedOperationException("Private gateways are not supported by the network extension element."); + } + + /** Static routes are not supported by the network extension element. */ + @Override + public boolean applyStaticRoutes(Vpc vpc, List routes) + throws ResourceUnavailableException { + throw new UnsupportedOperationException("Static routes are not supported by the network extension element."); + } + + /** ACL items on private gateways are not supported by the network extension element. */ + @Override + public boolean applyACLItemsToPrivateGw(PrivateGateway gateway, List rules) + throws ResourceUnavailableException { + throw new UnsupportedOperationException("ACL items on private gateways are not supported by the network extension element."); + } + + @Override + public boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address) { + if (vpc == null || address == null || address.getAddress() == null) { + logger.warn("updateVpcSourceNatIp: invalid input (vpc={}, address={})", vpc, address); + return false; + } + + final List args = new ArrayList<>(); + final VlanVO vlan = vlanDao.findById(address.getVlanId()); + args.add("--vpc-id"); args.add(String.valueOf(vpc.getId())); + args.add("--cidr"); args.add(safeStr(vpc.getCidr())); + args.add("--public-ip"); args.add(safeStr(address.getAddress().addr())); + args.add("--public-vlan"); args.add(safeStr(getPublicVlanTag(address.getId()))); + args.add("--public-gateway"); args.add(vlan != null ? safeStr(vlan.getVlanGateway()) : ""); + args.add("--public-cidr"); args.add(safeStr(getPublicCidr(address.getId()))); + args.add("--source-nat"); args.add("true"); + + final boolean result = executeVpcScript(vpc, "update-vpc-source-nat-ip", args.toArray(new String[0])); + if (!result) { + logger.warn("updateVpcSourceNatIp: failed to update source NAT IP for VPC {} to {}", + vpc.getId(), address.getAddress().addr()); + } + return result; + } + + /** + * Applies VPC network ACL rules for a VPC tier network via the script's + * {@code apply-network-acl} command. Rules are serialised as a Base64-encoded + * JSON array and passed via a temporary payload file. + * + *

Script command: {@code apply-network-acl}

+ */ + @Override + public boolean applyNetworkACLs(Network config, List rules) + throws ResourceUnavailableException { + if (!canHandle(config, Service.NetworkACL)) { + return true; + } + + // Rebuild the ACL chain from all non-revoked rules. + List activeRules = rules == null ? List.of() : + rules.stream() + .filter(r -> r.getState() != NetworkACLItem.State.Revoke) + .collect(Collectors.toList()); + + logger.info("applyNetworkACLs: network={} activeRules={}", config.getId(), activeRules.size()); + + String aclRulesBase64 = buildAclRulesBase64(activeRules); + + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(config.getId())); + args.add("--vlan"); args.add(safeStr(getVlanId(config))); + args.add("--gateway"); args.add(safeStr(config.getGateway())); + args.add("--cidr"); args.add(safeStr(config.getCidr())); + args.addAll(getVpcIdArgs(config)); + + boolean result = executeScriptWithFilePayload(config, "apply-network-acl", + "--acl-rules-file", aclRulesBase64, args.toArray(new String[0])); + if (!result) { + throw new ResourceUnavailableException( + "Failed to apply network ACL rules for network " + config.getId(), + Network.class, config.getId()); + } + return true; + } + + /** + * Re-applies ACL rules for all extension-backed networks in a VPC after a rule reorder. + * Calls {@code apply-network-acl} for each affected network with the full ACL item list. + */ + @Override + public boolean reorderAclRules(Vpc vpc, List networks, + List networkACLItems) { + if (networks == null || networks.isEmpty()) { + return true; + } + + List activeRules = networkACLItems == null ? List.of() : + networkACLItems.stream() + .filter(r -> r.getState() != NetworkACLItem.State.Revoke) + .collect(Collectors.toList()); + + boolean result = true; + for (Network network : networks) { + if (!canHandle(network, Service.NetworkACL)) { + continue; + } + try { + String aclRulesBase64 = buildAclRulesBase64(activeRules); + + List args = new ArrayList<>(); + args.add("--network-id"); args.add(String.valueOf(network.getId())); + args.add("--vlan"); args.add(safeStr(getVlanId(network))); + args.add("--gateway"); args.add(safeStr(network.getGateway())); + args.add("--cidr"); args.add(safeStr(network.getCidr())); + args.addAll(getVpcIdArgs(network)); + + boolean r = executeScriptWithFilePayload(network, "apply-network-acl", + "--acl-rules-file", aclRulesBase64, args.toArray(new String[0])); + result = result && r; + } catch (Exception e) { + logger.warn("reorderAclRules: failed for network {}: {}", network.getId(), e.getMessage()); + result = false; + } + } + return result; + } + + /** + * Serialises a list of {@link NetworkACLItem}s to a Base64-encoded JSON array + * suitable for passing to the {@code apply-network-acl} script command. + * Rules are sorted by their number (priority order). + */ + private String buildAclRulesBase64(List rules) { + StringBuilder json = new StringBuilder("["); + boolean first = true; + List sorted = rules.stream() + .sorted(java.util.Comparator.comparingInt(NetworkACLItem::getNumber)) + .collect(Collectors.toList()); + for (NetworkACLItem rule : sorted) { + if (!first) json.append(","); + first = false; + json.append("{"); + json.append("\"number\":").append(rule.getNumber()).append(","); + json.append("\"action\":\"").append(rule.getAction().name().toLowerCase()).append("\","); + json.append("\"trafficType\":\"").append(rule.getTrafficType().name().toLowerCase()).append("\","); + json.append("\"protocol\":\"").append(jsonEscape(safeStr(rule.getProtocol()))).append("\""); + if (rule.getSourcePortStart() != null) { + json.append(",\"portStart\":").append(rule.getSourcePortStart()); + } + if (rule.getSourcePortEnd() != null) { + json.append(",\"portEnd\":").append(rule.getSourcePortEnd()); + } + if (rule.getIcmpType() != null) { + json.append(",\"icmpType\":").append(rule.getIcmpType()); + } + if (rule.getIcmpCode() != null) { + json.append(",\"icmpCode\":").append(rule.getIcmpCode()); + } + json.append(",\"sourceCidrs\":["); + List sourceCidrs = rule.getSourceCidrList(); + if (sourceCidrs != null && !sourceCidrs.isEmpty()) { + boolean firstCidr = true; + for (String cidr : sourceCidrs) { + if (!firstCidr) json.append(","); + firstCidr = false; + json.append("\"").append(jsonEscape(cidr)).append("\""); + } + } + json.append("]"); + json.append("}"); + } + json.append("]"); + return Base64.getEncoder().encodeToString( + json.toString().getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md new file mode 100644 index 000000000000..ec885d7610b3 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md @@ -0,0 +1,1076 @@ +# Network Extension Script Protocol + +This document describes the complete interface between Apache CloudStack's +`NetworkExtensionElement` and the external script (Bash, Python, or any +executable) that implements network services for a custom device. + +Any executable that handles the commands listed below can be registered as a +**NetworkOrchestrator extension** and used as the provider for one or more +CloudStack network services (DHCP, DNS, UserData, SourceNat, StaticNat, +PortForwarding, Firewall, Lb, NetworkACL, Gateway). + +The reference implementation is the `network-namespace` extension at +`extensions/network-namespace/`, which uses Linux network namespaces on KVM +hosts. Use it as a working example. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Script Placement Convention](#script-placement-convention) +3. [CloudStack Setup Steps](#cloudstack-setup-steps) +4. [Always-present CLI Arguments](#always-present-cli-arguments) +5. [Shared Arguments Reference](#shared-arguments-reference) +6. [Command Reference](#command-reference) + - [ensure-network-device](#ensure-network-device) + - [implement](#implement) + - [shutdown](#shutdown) + - [destroy](#destroy) + - [assign-ip / release-ip](#assign-ip--release-ip) + - [add-static-nat / delete-static-nat](#add-static-nat--delete-static-nat) + - [add-port-forward / delete-port-forward](#add-port-forward--delete-port-forward) + - [apply-fw-rules](#apply-fw-rules) + - [add-dhcp-entry / remove-dhcp-entry](#add-dhcp-entry--remove-dhcp-entry) + - [config-dhcp-subnet / remove-dhcp-subnet](#config-dhcp-subnet--remove-dhcp-subnet) + - [set-dhcp-options](#set-dhcp-options) + - [add-dns-entry](#add-dns-entry) + - [config-dns-subnet / remove-dns-subnet](#config-dns-subnet--remove-dns-subnet) + - [save-vm-data](#save-vm-data) + - [save-password](#save-password) + - [save-userdata](#save-userdata) + - [save-sshkey](#save-sshkey) + - [save-hypervisor-hostname](#save-hypervisor-hostname) + - [apply-lb-rules](#apply-lb-rules) + - [restore-network](#restore-network) + - [custom-action](#custom-action) +7. [Service-to-Command Mapping](#service-to-command-mapping) +8. [Capabilities Configuration](#capabilities-configuration) +9. [VPC Networks](#vpc-networks) +10. [Extension IP](#extension-ip) +11. [Exit Codes](#exit-codes) +12. [Minimal Script Skeleton](#minimal-script-skeleton) + +--- + +## Architecture Overview + +``` +CloudStack Management Server + │ + │ exec /.sh [args...] + │ --physical-network-extension-details '{...}' + │ --network-extension-details '{...}' + ▼ + Your Script (Bash / Python / Go / …) + │ + │ configures / queries your device: + │ • KVM host over SSH + │ • SDN controller REST API + │ • Hardware appliance CLI + │ • Cloud provider API + ▼ + External Network Device +``` + +CloudStack calls the script synchronously (blocking process execution) on the +**management server** for every network event. The script is responsible for +translating those events into configuration changes on the actual device. + +The script must: + +- **Exit 0** on success. +- **Exit non-zero** on failure (CloudStack will log the error and may retry). +- For `ensure-network-device` only, **print a single-line JSON object** to + stdout (see [ensure-network-device](#ensure-network-device)). + +All other commands must produce no output on stdout (any output is logged at +DEBUG level and ignored). + +--- + +## Script Placement Convention + +CloudStack resolves the executable in this order (first match wins): + +1. **`/.sh`** — preferred convention. + Example: extension named `my-sdn` → script at + `.../my-sdn/my-sdn.sh`. +2. **`` itself**, if it is a regular file and is executable. + +The `` is the `path` field returned by `listExtensions` after +the extension is created. CloudStack sets it to: + +``` +/usr/share/cloudstack-management/extensions// +``` + +> **Tip:** Your script does not have to live on the management server — it can +> be a thin proxy that SSHes into a remote appliance. The +> `network-namespace.sh` entry-point is exactly that: it SSHes into the target +> KVM host and calls the wrapper script there. + +--- + +## CloudStack Setup Steps + +### Step 1 – Create the Extension + +```bash +cmk createExtension \ + name=my-sdn \ + type=NetworkOrchestrator \ + "details[0].key=network.services" \ + "details[0].value=SourceNat,StaticNat,PortForwarding,Firewall,Lb,Dhcp,Dns,UserData" \ + "details[1].key=network.service.capabilities" \ + "details[1].value=$(cat my-sdn-capabilities.json)" +``` + +`network.service.capabilities` is a JSON object — see +[Capabilities Configuration](#capabilities-configuration). + +### Step 2 – Deploy the Script + +Copy your executable to the path reported by `listExtensions`: + +```bash +SCRIPT_PATH=$(cmk listExtensions name=my-sdn | jq -r '.[0].path') +# e.g. /usr/share/cloudstack-management/extensions/my-sdn/ +mkdir -p "${SCRIPT_PATH}" +cp my-sdn.sh "${SCRIPT_PATH}/my-sdn.sh" +chmod 755 "${SCRIPT_PATH}/my-sdn.sh" +``` + +If you have multiple management servers, deploy the script to **every** one. + +### Step 3 – Register the Extension to a Physical Network + +```bash +PHYS_ID=$(cmk listPhysicalNetworks | jq -r '.[0].id') +EXT_ID=$(cmk listExtensions name=my-sdn | jq -r '.[0].id') + +cmk registerExtension \ + id=${EXT_ID} \ + resourcetype=PhysicalNetwork \ + resourceid=${PHYS_ID} \ + "details[0].key=hosts" "details[0].value=192.168.1.10,192.168.1.11" \ + "details[1].key=username" "details[1].value=admin" \ + "details[2].key=password" "details[2].value=s3cr3t" +``` + +Any key/value pairs you pass here will be forwarded to every script +invocation as `--physical-network-extension-details`. The schema is entirely +yours — CloudStack treats it as opaque. + +### Step 4 – Enable the Network Service Provider + +```bash +NSP_ID=$(cmk listNetworkServiceProviders physicalnetworkid=${PHYS_ID} \ + name=my-sdn | jq -r '.[0].id') +cmk updateNetworkServiceProvider id=${NSP_ID} state=Enabled +``` + +### Step 5 – Create a Network Offering + +```bash +cmk createNetworkOffering \ + name="My-SDN-Offering" \ + displaytext="My SDN network offering" \ + guestiptype=Isolated \ + traffictype=GUEST \ + supportedservices=Dhcp,Dns,UserData,SourceNat,StaticNat,PortForwarding,Firewall,Lb \ + "serviceProviderList[Dhcp]=my-sdn" \ + "serviceProviderList[Dns]=my-sdn" \ + "serviceProviderList[UserData]=my-sdn" \ + "serviceProviderList[SourceNat]=my-sdn" \ + "serviceProviderList[StaticNat]=my-sdn" \ + "serviceProviderList[PortForwarding]=my-sdn" \ + "serviceProviderList[Firewall]=my-sdn" \ + "serviceProviderList[Lb]=my-sdn" \ + "serviceCapabilityList[SourceNat][SupportedSourceNatTypes]=peraccount" +cmk updateNetworkOffering id= state=Enabled +``` + +--- + +## Always-present CLI Arguments + +Every command invocation appends these two named arguments **after** all +command-specific arguments: + +| Argument | Value | +|---|---| +| `--physical-network-extension-details` | JSON object — all key/value pairs registered via `registerExtension` **plus** `physicalnetworkname` (auto-enriched by CloudStack). | +| `--network-extension-details` | JSON object — the per-network opaque blob last written by `ensure-network-device`; `{}` until the first successful call. | + +Example call line built by CloudStack: + +``` +/usr/share/cloudstack-management/extensions/my-sdn/my-sdn.sh \ + implement \ + --network-id 42 \ + --vlan 100 \ + --gateway 10.0.0.1 \ + --cidr 10.0.0.0/24 \ + --extension-ip 10.0.0.1 \ + --physical-network-extension-details '{"hosts":"192.168.1.10","username":"admin","password":"s3cr3t","physicalnetworkname":"net1"}' \ + --network-extension-details '{"host":"192.168.1.10","device_id":"vrf-42"}' +``` + +> **Security note:** `password` and `sshkey` values are present verbatim in +> `--physical-network-extension-details` but are **redacted** in CloudStack +> log output. Treat them as secrets; do not log them in your script either. + +--- + +## Shared Arguments Reference + +The following arguments appear in multiple commands. Descriptions apply +everywhere they are used. + +| Argument | Description | +|---|---| +| `--network-id ` | CloudStack numeric network ID. | +| `--vpc-id ` | CloudStack numeric VPC ID. Present only for VPC-tier networks. | +| `--vlan ` | Guest VLAN tag (e.g. `100`). Extracted from the broadcast URI. May be empty for flat networks. | +| `--gateway ` | Guest network gateway (e.g. `10.0.0.1`). | +| `--cidr ` | Guest network CIDR (e.g. `10.0.0.0/24`). | +| `--extension-ip ` | The IP the extension device uses on the guest side. Equals `--gateway` when SourceNat/Gateway service is provided; otherwise a dedicated allocated IP from the guest subnet (see [Extension IP](#extension-ip)). | +| `--public-ip ` | A public (floating) IP address. | +| `--public-cidr ` | CIDR of the public IP (e.g. `203.0.113.5/24`). | +| `--public-vlan ` | VLAN tag of the public IP's network segment. | +| `--public-gateway ` | Gateway of the public IP's network segment. | +| `--private-ip ` | A VM's private IP address inside the guest network. | + +--- + +## Command Reference + +### `ensure-network-device` + +**Called:** Before every network operation, automatically by +`NetworkExtensionElement`. + +**Purpose:** Select (or re-validate) the device/host that will handle this +network. Perform failover to another host if the current one is unreachable. +The returned JSON is stored in `network_details` under key `extension.details` and +forwarded back as `--network-extension-details` on every future call. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | Network ID. | +| `--vlan ` | Guest VLAN. | +| `--zone-id ` | CloudStack zone ID. | +| `--vpc-id ` | VPC ID (optional). | +| `--current-details ` | Previously stored per-network blob (`{}` on first call). | +| `--physical-network-extension-details ` | Physical network details. | +| `--network-extension-details ` | Same as `--current-details`. | + +**Stdout:** A single-line JSON object. CloudStack stores this verbatim. +You can put any fields your script needs (host selection, device ID, segment +ID, namespace name, etc.). + +```json +{"host":"192.168.1.10","device_id":"vrf-42","namespace":"cs-net-42"} +``` + +**Exit 0:** JSON written to stdout is persisted. +**Exit non-zero:** Existing details are kept unchanged; a warning is logged. + +--- + +### `implement` + +**Called:** When a network is implemented (first VM deployed, or network +restart). + +**Purpose:** Create / bring up the network segment on the device: create the +virtual segment (VRF, namespace, VLAN, …), attach the guest interface, and +configure the gateway. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--vlan ` | | +| `--gateway ` | | +| `--cidr ` | | +| `--extension-ip ` | Device's IP on the guest network. | +| `--vpc-id ` | (optional) | + +--- + +### `shutdown` + +**Called:** When a network is shut down (e.g., all VMs removed, before +deletion). + +**Purpose:** Tear down the network segment; release resources. The +per-network `extension.details` blob is removed from CloudStack after a successful +return. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--vlan ` | | +| `--vpc-id ` | (optional) | + +--- + +### `destroy` + +**Called:** When a network is permanently deleted. + +**Purpose:** Same as `shutdown`, but called at hard-delete time. The +placeholder NIC IP (if any) and the `extension.details` blob are cleaned up +automatically by CloudStack after a successful return. + +**Arguments:** Identical to `shutdown`. + +--- + +### `assign-ip` / `release-ip` + +**Called:** When a public IP is associated with or disassociated from a +network (source NAT, static NAT, PF, LB allocation). + +**Purpose:** +- `assign-ip` — attach the public IP to the device; add the necessary routing + entry so the device can receive traffic for this IP. +- `release-ip` — detach the public IP; remove routing. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--vlan ` | Guest VLAN. | +| `--public-ip ` | The public IP being assigned/released. | +| `--source-nat ` | `true` if this is the source NAT IP. | +| `--gateway ` | Guest network gateway. | +| `--cidr ` | Guest network CIDR. | +| `--public-gateway ` | Gateway of the public IP's segment. | +| `--public-cidr ` | CIDR of the public IP (e.g. `203.0.113.5/24`). | +| `--public-vlan ` | Public VLAN tag. | +| `--vpc-id ` | (optional) | + +--- + +### `add-static-nat` / `delete-static-nat` + +**Called:** When a static NAT rule is created or deleted +(`enableStaticNat` / `disableStaticNat` API). + +**Purpose:** Configure a 1:1 bidirectional NAT mapping between a public IP +and a VM private IP. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--vlan ` | | +| `--public-ip ` | | +| `--public-cidr ` | | +| `--public-vlan ` | | +| `--private-ip ` | VM's private IP (DNAT destination). | +| `--vpc-id ` | (optional) | + +--- + +### `add-port-forward` / `delete-port-forward` + +**Called:** When a port forwarding rule is created or deleted +(`createPortForwardingRule` / `deletePortForwardingRule` API). + +**Purpose:** Configure a DNAT rule from `public-ip:public-port` to +`private-ip:private-port`. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--vlan ` | | +| `--public-ip ` | | +| `--public-cidr ` | | +| `--public-vlan ` | | +| `--public-port ` | Port range on the public IP, e.g. `22` or `8080-8090`. | +| `--private-ip ` | VM's private IP. | +| `--private-port ` | Destination port range on the VM, e.g. `22`. | +| `--protocol ` | | +| `--vpc-id ` | (optional) | + +--- + +### `apply-fw-rules` + +**Called:** When any firewall rule is created, deleted, or updated for the +network (`createFirewallRule`, `deleteFirewallRule`, `updateEgressFirewallRule` +APIs, and during network restart). + +**Purpose:** Rebuild the entire firewall policy for the network from scratch. +CloudStack calls this with a *narrow* scope (one IP's ingress rules or egress +rules per call), but the `--fw-rules` payload always contains **all** active +rules for the network, so a full rebuild is always safe. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--vlan ` | | +| `--gateway ` | | +| `--cidr ` | | +| `--fw-rules ` | Base64-encoded JSON firewall payload (see below). | +| `--vpc-id ` | (optional) | + +**`--fw-rules` payload** (decode base64, then parse JSON): + +```json +{ + "default_egress_allow": true, + "cidr": "10.0.0.0/24", + "rules": [ + { + "id": 1, + "type": "ingress", + "protocol": "tcp", + "portStart": 22, + "portEnd": 22, + "publicIp": "203.0.113.5", + "sourceCidrs": ["0.0.0.0/0"], + "destCidrs": [] + }, + { + "id": 2, + "type": "egress", + "protocol": "icmp", + "icmpType": -1, + "icmpCode": -1, + "sourceCidrs": [], + "destCidrs": [] + } + ] +} +``` + +| Field | Description | +|---|---| +| `default_egress_allow` | `true` = permissive egress by default (explicit rules are deny rules); `false` = restrictive (explicit rules are allow rules). | +| `cidr` | Guest network CIDR. | +| `rules[].type` | `"ingress"` or `"egress"`. | +| `rules[].protocol` | `"tcp"`, `"udp"`, `"icmp"`, `"all"`. | +| `rules[].portStart` / `portEnd` | TCP/UDP port range (absent for ICMP/all). | +| `rules[].icmpType` / `icmpCode` | ICMP type/code (`-1` = any; absent for TCP/UDP). | +| `rules[].publicIp` | For ingress: the public IP the rule applies to. | +| `rules[].sourceCidrs` | Allowed source IP ranges (ingress: external; egress: VM). | +| `rules[].destCidrs` | Allowed destination IP ranges (egress only). | + +--- + +### `add-dhcp-entry` / `remove-dhcp-entry` + +**Called:** When a VM NIC is reserved (`add`) or released (`remove`) on a +network whose DHCP service is provided by this extension. + +**Purpose:** Add or remove a static DHCP lease for the VM. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--mac ` | VM NIC MAC address, e.g. `02:00:00:00:00:01`. | +| `--ip ` | VM's assigned IP. | +| `--hostname ` | VM hostname. | +| `--gateway ` | | +| `--cidr ` | | +| `--dns ` | Comma-separated DNS server IPs, e.g. `8.8.8.8,8.8.4.4`. | +| `--default-nic ` | `true` if this is the VM's default NIC. | +| `--domain ` | Network domain suffix (e.g. `cs.example.com`). | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `config-dhcp-subnet` / `remove-dhcp-subnet` + +**Called:** When a shared-network subnet is configured or removed. + +**Purpose:** Configure the DHCP scope (pool, gateway, DNS) for a subnet +without tying it to a specific VM. + +**`config-dhcp-subnet` arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--gateway ` | | +| `--cidr ` | | +| `--dns ` | | +| `--vlan ` | | +| `--domain ` | | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +**`remove-dhcp-subnet` arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `set-dhcp-options` + +**Called:** When extra DHCP options are set on a NIC +(`updateNicExtraDhcpOption` API). + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--nic-id ` | CloudStack NIC ID. | +| `--options ` | JSON object `{"":"", …}`, e.g. `{"15":"example.com","119":"search.example.com"}`. | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `add-dns-entry` + +**Called:** When a VM NIC is reserved on a network whose DNS service is +provided by this extension. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--ip ` | VM's IP. | +| `--hostname ` | VM hostname. | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `config-dns-subnet` / `remove-dns-subnet` + +**Called:** When a DNS scope is configured or removed for a subnet. + +**`config-dns-subnet` arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--gateway ` | | +| `--cidr ` | | +| `--dns ` | | +| `--vlan ` | | +| `--domain ` | | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +**`remove-dns-subnet` arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `save-vm-data` + +**Called:** When a VM is deployed or updated with user data, SSH keys, or +password on a network whose UserData service is provided by this extension. + +**Purpose:** Store the complete cloud-init metadata set (user-data, +meta-data/*, password) for the VM so the metadata HTTP server can serve it. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--ip ` | VM's IP. | +| `--gateway ` | | +| `--vm-data ` | Base64-encoded JSON array of metadata entries (see below). | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +**`--vm-data` payload** (decode base64, then parse JSON): + +```json +[ + {"dir":"userdata", "file":"user-data", "content":""}, + {"dir":"meta-data", "file":"instance-id", "content":""}, + {"dir":"meta-data", "file":"local-hostname", "content":""}, + {"dir":"meta-data", "file":"public-keys/0/openssh-key", "content":""}, + {"dir":"password", "file":"vm_password", "content":""} +] +``` + +Each `content` field is **base64-encoded** binary (raw bytes for user-data; +UTF-8 text for all others). Decode `content` before writing to disk. + +Your metadata HTTP server should serve each entry at: +`http:///latest//` + +--- + +### `save-password` + +**Called:** When a password reset is requested for a VM +(`resetPasswordForVirtualMachine` API). + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--ip ` | VM's IP. | +| `--gateway ` | | +| `--password ` | Plain-text new password. | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `save-userdata` + +**Called:** When a VM's user data is updated +(`updateVirtualMachine` with `userdata`). + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--ip ` | VM's IP. | +| `--gateway ` | | +| `--userdata ` | Base64-encoded raw user-data bytes. | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `save-sshkey` + +**Called:** When an SSH public key is reset for a VM +(`resetSSHKeyForVirtualMachine` API). + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--ip ` | VM's IP. | +| `--gateway ` | | +| `--sshkey ` | Base64-encoded SSH public key (UTF-8 text). Decode to get the key string. | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `save-hypervisor-hostname` + +**Called:** When a VM is deployed and UserData service is active. + +**Purpose:** Store the hypervisor hostname in the metadata so VMs can identify +which host they run on (cloud-init `availability-zone` / host detection). + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--ip ` | VM's IP. | +| `--gateway ` | | +| `--hypervisor-hostname ` | Hypervisor node hostname. | +| `--extension-ip ` | | +| `--vpc-id ` | (optional) | + +--- + +### `apply-lb-rules` + +**Called:** When a load balancer rule is created, deleted, or its members +change (`createLoadBalancerRule`, `deleteLoadBalancerRule`, +`assignToLoadBalancerRule`, `removeFromLoadBalancerRule` APIs). + +**Purpose:** Configure the load balancer on the device: create/update/delete +virtual server → backend pool mappings. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--vlan ` | | +| `--lb-rules ` | JSON array of LB rules (see below). **Not** base64 encoded. | +| `--vpc-id ` | (optional) | + +**`--lb-rules` format:** + +```json +[ + { + "id": 1, + "name": "lb-web", + "publicIp": "203.0.113.5", + "publicPort": 80, + "privatePort": 8080, + "protocol": "tcp", + "algorithm": "roundrobin", + "revoke": false, + "backends": [ + {"ip": "10.0.0.10", "port": 8080, "revoked": false}, + {"ip": "10.0.0.11", "port": 8080, "revoked": true} + ] + } +] +``` + +| Field | Description | +|---|---| +| `revoke` | `true` → delete this rule; `false` → create/update. | +| `backends[].revoked` | `true` → this backend has been removed from the rule. | +| `algorithm` | `roundrobin`, `leastconn`, or `source`. | +| `protocol` | `tcp`, `udp`, or `tcp-proxy`. | + +--- + +### `restore-network` + +**Called:** During a `restartNetwork(cleanup=true)` or `restartVPC(cleanup=true)` +operation, after all rules (firewall/NAT/LB) have been re-applied. + +**Purpose:** Batch-restore all DHCP leases, DNS entries, and metadata for +every VM currently on the network in a single call (instead of N per-VM +calls). + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--gateway ` | | +| `--cidr ` | | +| `--vlan ` | | +| `--extension-ip ` | | +| `--dns ` | | +| `--domain ` | | +| `--restore-data ` | Base64-encoded JSON restore payload (see below). | +| `--vpc-id ` | (optional) | + +**`--restore-data` payload** (decode base64, then parse JSON): + +```json +{ + "dhcp_enabled": true, + "dns_enabled": true, + "userdata_enabled": true, + "vms": [ + { + "ip": "10.0.0.10", + "mac": "02:00:00:00:00:01", + "hostname": "vm-1", + "default_nic": true, + "vm_data": [ + {"dir": "userdata", "file": "user-data", "content": ""}, + {"dir": "meta-data", "file": "instance-id", "content": ""}, + {"dir": "meta-data", "file": "local-hostname","content": ""} + ] + } + ] +} +``` + +Each `vm_data[].content` is **base64-encoded** (same as in `save-vm-data`). + +--- + +### `custom-action` + +**Called:** Via the `runNetworkCustomAction` API. + +**Purpose:** Allows operators to trigger ad-hoc operations on the device +without defining new CloudStack API calls. + +**Arguments:** + +| Argument | Description | +|---|---| +| `--network-id ` | | +| `--action ` | The action name passed by the operator. | +| `--action-params ` | JSON object with arbitrary key/value parameters. | +| `--physical-network-extension-details ` | | +| `--network-extension-details ` | | + +**Stdout:** Returned verbatim to the API caller. + +--- + +## Service-to-Command Mapping + +| CloudStack Network Service | Commands triggered | +|---|---| +| **SourceNat / Gateway** | `assign-ip`, `release-ip` | +| **StaticNat** | `add-static-nat`, `delete-static-nat` | +| **PortForwarding** | `add-port-forward`, `delete-port-forward` | +| **Firewall** | `apply-fw-rules` | +| **Lb** | `apply-lb-rules` | +| **Dhcp** | `add-dhcp-entry`, `remove-dhcp-entry`, `config-dhcp-subnet`, `remove-dhcp-subnet`, `set-dhcp-options` | +| **Dns** | `add-dns-entry`, `config-dns-subnet`, `remove-dns-subnet` | +| **UserData** | `save-vm-data`, `save-password`, `save-userdata`, `save-sshkey`, `save-hypervisor-hostname` | +| *(lifecycle — all)* | `ensure-network-device`, `implement`, `shutdown`, `destroy`, `restore-network` | +| *(operator)* | `custom-action` | + +Your script only needs to implement the commands for the services it declares +in `network.services`. All other commands can safely be no-ops (exit 0). + +--- + +## Capabilities Configuration + +When creating the extension, set `network.service.capabilities` to a JSON +object keyed by service name. The values are capability name → value string +maps. + +```json +{ + "SourceNat": { + "SupportedSourceNatTypes": "peraccount", + "RedundantRouter": "false" + }, + "StaticNat": { + "Supported": "true" + }, + "PortForwarding": { + "SupportedProtocols": "tcp,udp" + }, + "Firewall": { + "TrafficStatistics": "per public ip", + "SupportedProtocols": "tcp,udp,icmp", + "SupportedEgressProtocols":"tcp,udp,icmp,all", + "SupportedTrafficDirection":"ingress,egress", + "MultipleIps": "true" + }, + "Lb": { + "SupportedLBAlgorithms": "roundrobin,leastconn,source", + "SupportedLBIsolation": "dedicated", + "SupportedProtocols": "tcp,udp,tcp-proxy", + "SupportedStickinessMethods": "lbcookie,appsession", + "LbSchemes": "Public", + "SslTermination": "false", + "VmAutoScaling": "false" + }, + "Dhcp": { + "DhcpAccrossMultipleSubnets": "true" + }, + "Dns": { + "AllowDnsSuffixModification": "true", + "ExternalDns": "true" + }, + "Gateway": { + "RedundantRouter": "false" + }, + "NetworkACL": { + "SupportedProtocols": "tcp,udp,icmp" + }, + "UserData": { + "Supported": "true" + } +} +``` + +Only declare services and capabilities your implementation actually supports. + +--- + +## VPC Networks + +For networks that belong to a VPC, `--vpc-id ` is appended to every +command. Use it to share state across all tiers of the same VPC (e.g., a +single VRF or namespace per VPC instead of per tier). + +In `ensure-network-device`, use `--vpc-id` (when present) as the hash key for +host selection so all tiers of a VPC always land on the same device. + +To use this extension as a VPC provider: + +1. Create the NetworkOffering with `useVpc=on`. +2. Create a VpcOffering with the extension as provider. + +--- + +## Extension IP + +`--extension-ip` is the IP the device presents on the guest network side: + +- **With SourceNat or Gateway service:** equals `--gateway` (the device is the + gateway; no separate IP needed). +- **Without SourceNat/Gateway** (Dhcp/Dns/UserData only, e.g. a shared network + helper): CloudStack allocates a dedicated IP from the guest subnet and passes + it. The device must listen on this IP for DHCP, DNS, and metadata (port 80) + requests. + +--- + +## Exit Codes + +| Exit code | Meaning | +|---|---| +| `0` | Success. | +| Any non-zero | Failure. CloudStack logs the exit code and script output, and treats the operation as failed. | + +For SSH-proxy scripts you may use sub-codes for diagnostics (they are logged +but not interpreted differently by CloudStack): + +| Suggested code | Suggested meaning | +|---|---| +| `1` | Usage / configuration error. | +| `2` | SSH connection / authentication failure. | +| `3` | Remote script returned non-zero. | + +--- + +## Minimal Script Skeleton + +The following Bash skeleton handles all commands and can be used as a starting +point. Replace each `TODO` block with your device's API calls. + +```bash +#!/bin/bash +# my-sdn.sh — CloudStack NetworkOrchestrator extension entry-point +set -euo pipefail + +COMMAND="${1:-}"; shift || true + +# Parse arguments into an associative array +declare -A ARGS +while [[ $# -gt 0 ]]; do + case "$1" in + --*) ARGS["${1#--}"]="${2:-}"; shift 2 ;; + *) shift ;; + esac +done + +# Helpers +phys() { echo "${ARGS[physical-network-extension-details]:-{}}"; } +netdetail(){ echo "${ARGS[network-extension-details]:-{}}"; } +arg() { echo "${ARGS[$1]:-}"; } + +case "${COMMAND}" in + + ensure-network-device) + # TODO: check that the device is reachable; select/validate host + # Print per-network JSON to stdout (stored by CloudStack) + printf '{"device":"%s"}\n' "$(arg hosts | cut -d, -f1)" + ;; + + implement) + # TODO: create virtual segment (VRF / VLAN / namespace / …) + # TODO: configure gateway IP $(arg extension-ip) on $(arg cidr) + ;; + + shutdown|destroy) + # TODO: tear down the virtual segment + ;; + + assign-ip) + # TODO: attach public IP $(arg public-ip) to the device + ;; + + release-ip) + # TODO: remove public IP $(arg public-ip) from the device + ;; + + add-static-nat) + # TODO: DNAT $(arg public-ip) → $(arg private-ip) + ;; + + delete-static-nat) + # TODO: remove DNAT for $(arg public-ip) + ;; + + add-port-forward) + # TODO: DNAT $(arg public-ip):$(arg public-port) → $(arg private-ip):$(arg private-port) + ;; + + delete-port-forward) + # TODO: remove the port-forwarding DNAT rule + ;; + + apply-fw-rules) + # TODO: decode base64 --fw-rules, rebuild firewall policy + FW_JSON=$(printf '%s' "$(arg fw-rules)" | base64 -d) + # parse $FW_JSON and apply to device + ;; + + add-dhcp-entry) + # TODO: add static lease mac=$(arg mac) ip=$(arg ip) + ;; + + remove-dhcp-entry) + # TODO: remove static lease for mac=$(arg mac) + ;; + + config-dhcp-subnet|remove-dhcp-subnet) ;; + + set-dhcp-options) ;; + + add-dns-entry) + # TODO: add A record hostname=$(arg hostname) ip=$(arg ip) + ;; + + config-dns-subnet|remove-dns-subnet) ;; + + save-vm-data) + # TODO: decode base64 --vm-data; store metadata for ip=$(arg ip) + VM_DATA_JSON=$(printf '%s' "$(arg vm-data)" | base64 -d) + ;; + + save-password|save-userdata|save-sshkey|save-hypervisor-hostname) ;; + + apply-lb-rules) + # TODO: parse --lb-rules JSON; configure load balancer + ;; + + restore-network) + # TODO: decode base64 --restore-data; rebuild DHCP/DNS/metadata + RESTORE_JSON=$(printf '%s' "$(arg restore-data)" | base64 -d) + ;; + + custom-action) + # TODO: handle $(arg action) with params $(arg action-params) + echo "custom action $(arg action) not implemented" + exit 1 + ;; + + *) + echo "Unknown command: ${COMMAND}" >&2 + exit 1 + ;; +esac + +exit 0 +``` + +For a full production implementation see: +- `extensions/network-namespace/network-namespace.sh` — management-server + entry-point (SSH proxy). +- `extensions/network-namespace/network-namespace-wrapper.sh` — KVM-host + wrapper that implements all commands using Linux network namespaces. + diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java index 535a0f703958..546f33b2a1d4 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java @@ -40,7 +40,7 @@ public class ExtensionDetailsVO implements ResourceDetail { @Column(name = "name", nullable = false, length = 255) private String name; - @Column(name = "value", nullable = false, length = 255) + @Column(name = "value", nullable = false, length = 4096) private String value; @Column(name = "display") diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java index 5cb6f7b85114..c2b23f1eee04 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java @@ -40,7 +40,7 @@ public class ExtensionResourceMapDetailsVO implements ResourceDetail { @Column(name = "name", nullable = false, length = 255) private String name; - @Column(name = "value", nullable = false, length = 255) + @Column(name = "value", nullable = false, length = 4096) private String value; @Column(name = "display") diff --git a/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml b/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml index 9d44d8ff7f3d..ee5e98630140 100644 --- a/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml +++ b/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml @@ -33,4 +33,7 @@ + + + diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java new file mode 100644 index 000000000000..7ca5d92a9fd9 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java @@ -0,0 +1,115 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class UpdateRegisteredExtensionCmdTest { + + private UpdateRegisteredExtensionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = Mockito.spy(new UpdateRegisteredExtensionCmd()); + extensionsManager = mock(ExtensionsManager.class); + ReflectionTestUtils.setField(cmd, "extensionsManager", extensionsManager); + } + + @Test + public void extensionIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "extensionId", null); + assertNull(cmd.getExtensionId()); + } + + @Test + public void resourceIdReturnsValueWhenSet() { + String resourceId = "resource-123"; + ReflectionTestUtils.setField(cmd, "resourceId", resourceId); + assertEquals(resourceId, cmd.getResourceId()); + } + + @Test + public void resourceTypeReturnsValueWhenSet() { + String resourceType = "PhysicalNetwork"; + ReflectionTestUtils.setField(cmd, "resourceType", resourceType); + assertEquals(resourceType, cmd.getResourceType()); + } + + @Test + public void detailsReturnsEmptyMapWhenUnset() { + ReflectionTestUtils.setField(cmd, "details", null); + Map details = cmd.getDetails(); + assertNotNull(details); + assertTrue(details.isEmpty()); + } + + @Test + public void executeSetsExtensionResponseWhenManagerSucceeds() { + Extension extension = mock(Extension.class); + ExtensionResponse response = mock(ExtensionResponse.class); + when(extensionsManager.updateRegisteredExtensionWithResource(cmd)).thenReturn(extension); + when(extensionsManager.createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all))) + .thenReturn(response); + + doNothing().when(cmd).setResponseObject(any()); + + cmd.execute(); + + verify(extensionsManager).updateRegisteredExtensionWithResource(cmd); + verify(extensionsManager).createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all)); + verify(cmd).setResponseObject(response); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerFails() { + when(extensionsManager.updateRegisteredExtensionWithResource(cmd)) + .thenThrow(new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update registered extension")); + + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals("Failed to update registered extension", e.getDescription()); + } + } +} + diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java index ff3fce06b006..05290ea9511a 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java @@ -37,6 +37,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -74,6 +75,7 @@ import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateRegisteredExtensionCmd; import org.apache.cloudstack.framework.extensions.command.CleanupExtensionFilesCommand; import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; import org.apache.cloudstack.framework.extensions.command.GetExtensionPathChecksumCommand; @@ -87,6 +89,7 @@ import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionDetailsVO; import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionVO; import org.apache.cloudstack.framework.extensions.vo.ExtensionDetailsVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapDetailsVO; import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; import org.apache.cloudstack.utils.identity.ManagementServerNode; @@ -94,6 +97,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; @@ -122,6 +126,15 @@ import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.ExternalProvisioner; import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.Network; +import com.cloud.network.NetworkModel; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkServiceMapDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.element.NetworkElement; +import org.apache.cloudstack.extension.NetworkCustomActionProvider; import com.cloud.org.Cluster; import com.cloud.serializer.GsonHelper; import com.cloud.storage.dao.VMTemplateDao; @@ -138,7 +151,7 @@ import com.cloud.vm.VmDetailConstants; import com.cloud.vm.dao.VMInstanceDao; -@RunWith(MockitoJUnitRunner.class) +@RunWith(MockitoJUnitRunner.Silent.class) public class ExtensionsManagerImplTest { @Spy @@ -185,6 +198,14 @@ public class ExtensionsManagerImplTest { private RoleService roleService; @Mock private AccountService accountService; + @Mock + private PhysicalNetworkDao physicalNetworkDao; + @Mock + private NetworkDao networkDao; + @Mock + private NetworkServiceMapDao networkServiceMapDao; + @Mock + private NetworkModel networkModel; @Before public void setUp() { @@ -290,14 +311,6 @@ public void getExtensionFromResourceReturnsNullIfEntityNotFound() { assertNull(extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "uuid")); } - @Test - public void getActionMessageReturnsDefaultOnBlank() { - ExtensionCustomAction action = mock(ExtensionCustomAction.class); - Extension ext = mock(Extension.class); - when(action.getSuccessMessage()).thenReturn(null); - String msg = extensionsManager.getActionMessage(true, action, ext, ExtensionCustomAction.ResourceType.VirtualMachine, null); - assertTrue(msg.contains("Successfully completed")); - } @Test public void getActionMessageReturnsDefaultMessageForSuccessWithoutCustomMessage() { @@ -343,14 +356,6 @@ public void getActionMessageReturnsCustomFailureMessage() { assertEquals("Custom failure message", result); } - @Test - public void getActionMessageHandlesNullActionMessage() { - ExtensionCustomAction action = mock(ExtensionCustomAction.class); - when(action.getSuccessMessage()).thenReturn(null); - Extension extension = mock(Extension.class); - String result = extensionsManager.getActionMessage(true, action, extension, ExtensionCustomAction.ResourceType.VirtualMachine, null); - assertTrue(result.contains("Successfully completed")); - } @Test public void getFilteredExternalDetailsReturnsFilteredMap() { @@ -381,26 +386,6 @@ public void sendExtensionPathNotReadyAlertDoesNotCallsAlertManager() { anyLong(), anyLong(), anyString(), anyString()); } - @Test - public void updateExtensionPathReadyUpdatesWhenStateDiffers() { - Extension ext = mock(Extension.class); - when(ext.getId()).thenReturn(1L); - when(ext.isPathReady()).thenReturn(false); - ExtensionVO vo = mock(ExtensionVO.class); - when(extensionDao.createForUpdate(1L)).thenReturn(vo); - when(extensionDao.update(1L, vo)).thenReturn(true); - extensionsManager.updateExtensionPathReady(ext, true); - verify(extensionDao).update(1L, vo); - } - - @Test - public void disableExtensionUpdatesState() { - ExtensionVO vo = mock(ExtensionVO.class); - when(extensionDao.createForUpdate(1L)).thenReturn(vo); - when(extensionDao.update(1L, vo)).thenReturn(true); - extensionsManager.disableExtension(1L); - verify(extensionDao).update(1L, vo); - } @Test public void getExtensionFromResourceReturnsExtensionForValidResource() { @@ -558,41 +543,29 @@ public void getExtensionsPathReturnsProvisionerPath() { assertEquals("/tmp/extensions", extensionsManager.getExtensionsPath()); } - @Test - public void getExtensionIdForClusterReturnsNullIfNoMap() { - when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(null); - assertNull(extensionsManager.getExtensionIdForCluster(1L)); - } @Test - public void getExtensionIdForClusterReturnsIdIfMapExists() { - ExtensionResourceMapVO map = mock(ExtensionResourceMapVO.class); - when(map.getExtensionId()).thenReturn(5L); - when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(map); - assertEquals(Long.valueOf(5L), extensionsManager.getExtensionIdForCluster(1L)); - } - - @Test - public void getExtensionReturnsExtension() { - ExtensionVO ext = mock(ExtensionVO.class); - when(extensionDao.findById(1L)).thenReturn(ext); - assertEquals(ext, extensionsManager.getExtension(1L)); - } - - @Test - public void getExtensionForClusterReturnsNullIfNoId() { - when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(null); - assertNull(extensionsManager.getExtensionForCluster(1L)); + public void checkExtensionPathSyncUpdatesReadyWhenStateDiffers() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(ext.isPathReady()).thenReturn(false); + ExtensionVO vo = mock(ExtensionVO.class); + when(extensionDao.createForUpdate(1L)).thenReturn(vo); + when(extensionDao.update(1L, vo)).thenReturn(true); + extensionsManager.checkExtensionPathState(ext, Collections.emptyList()); + verify(extensionsManager).updateExtensionPathReady(ext, false); } @Test - public void getExtensionForClusterReturnsExtensionIfIdExists() { - ExtensionResourceMapVO map = mock(ExtensionResourceMapVO.class); - when(map.getExtensionId()).thenReturn(5L); - when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(map); - ExtensionVO ext = mock(ExtensionVO.class); - when(extensionDao.findById(5L)).thenReturn(ext); - assertEquals(ext, extensionsManager.getExtensionForCluster(1L)); + public void checkExtensionPathSyncUpdatesReadyWhenStateUnchanged() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(ext.isPathReady()).thenReturn(true); + when(externalProvisioner.getChecksumForExtensionPath("ext", "entry.sh")).thenReturn("checksum123"); + extensionsManager.checkExtensionPathState(ext, Collections.emptyList()); + verify(extensionsManager, times(1)).updateExtensionPathReady(any(), anyBoolean()); } @Test @@ -1024,13 +997,6 @@ public void testDeleteExtension_Success() { verify(extensionDao).remove(1L); } - @Test - public void testRegisterExtensionWithResource_InvalidResourceType() { - RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); - when(cmd.getResourceType()).thenReturn("InvalidType"); - - assertThrows(InvalidParameterValueException.class, () -> extensionsManager.registerExtensionWithResource(cmd)); - } @Test public void registerExtensionWithResourceRegistersSuccessfullyForValidResourceType() { @@ -1063,8 +1029,6 @@ public void registerExtensionWithResourceThrowsForMissingExtension() { RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.Cluster.name()); when(cmd.getResourceId()).thenReturn(UUID.randomUUID().toString()); - ClusterVO clusterVO = mock(ClusterVO.class); - when(clusterDao.findByUuid(anyString())).thenReturn(clusterVO); extensionsManager.registerExtensionWithResource(cmd); } @@ -1139,6 +1103,159 @@ public void unregisterExtensionWithClusterHandlesMissingMappingGracefully() { verify(extensionResourceMapDao, never()).remove(anyLong()); } + @Test + public void unregisterExtensionWithResourceThrowsWhenProviderUsedByExistingNetworks() { + UnregisterExtensionCmd cmd = mock(UnregisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("physnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + + PhysicalNetworkVO physicalNetwork = mock(PhysicalNetworkVO.class); + when(physicalNetwork.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("physnet-uuid")).thenReturn(physicalNetwork); + + ExtensionResourceMapVO existing = mock(ExtensionResourceMapVO.class); + when(existing.getExtensionId()).thenReturn(1L); + when(extensionResourceMapDao.listByResourceIdAndType(42L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(existing)); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extension.getName()).thenReturn("extnet-provider"); + when(extensionDao.findById(1L)).thenReturn(extension); + + NetworkVO network = mock(NetworkVO.class); + when(networkDao.listByPhysicalNetworkAndProvider(42L, "extnet-provider")).thenReturn(List.of(network)); + + assertThrows(CloudRuntimeException.class, () -> extensionsManager.unregisterExtensionWithResource(cmd)); + verify(extensionResourceMapDao, never()).remove(anyLong()); + } + + @Test + public void updateRegisteredExtensionWithResourceUpdatesDetailsForExistingMapping() { + UpdateRegisteredExtensionCmd cmd = mock(UpdateRegisteredExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("physnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getDetails()).thenReturn(Map.of("username", "root", "hosts", "10.10.10.10")); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extension.getName()).thenReturn("extnet-provider"); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physicalNetwork = mock(PhysicalNetworkVO.class); + when(physicalNetwork.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("physnet-uuid")).thenReturn(physicalNetwork); + + ExtensionResourceMapVO existing = mock(ExtensionResourceMapVO.class); + when(existing.getExtensionId()).thenReturn(1L); + when(existing.getId()).thenReturn(100L); + when(extensionResourceMapDao.listByResourceIdAndType(42L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(existing)); + + Extension result = extensionsManager.updateRegisteredExtensionWithResource(cmd); + + assertEquals(extension, result); + verify(extensionResourceMapDetailsDao, never()).removeDetails(anyLong()); + verify(extensionResourceMapDetailsDao).saveDetails(any()); + } + + @Test + public void updateRegisteredExtensionWithResourceCleanupDetailsFirstThenSaveRequested() { + UpdateRegisteredExtensionCmd cmd = mock(UpdateRegisteredExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("physnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getDetails()).thenReturn(Map.of("username", "root", "password", "secret")); + when(cmd.isCleanupDetails()).thenReturn(true); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physicalNetwork = mock(PhysicalNetworkVO.class); + when(physicalNetwork.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("physnet-uuid")).thenReturn(physicalNetwork); + + ExtensionResourceMapVO existing = mock(ExtensionResourceMapVO.class); + when(existing.getExtensionId()).thenReturn(1L); + when(existing.getId()).thenReturn(100L); + when(extensionResourceMapDao.listByResourceIdAndType(42L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(existing)); + + extensionsManager.updateRegisteredExtensionWithResource(cmd); + + verify(extensionResourceMapDetailsDao).removeDetails(100L); + verify(extensionResourceMapDetailsDao, never()).saveDetails(any()); + } + + @Test + @SuppressWarnings("unchecked") + public void updateRegisteredExtensionWithResourceStoresSensitiveDetailsWithDisplayFalse() { + UpdateRegisteredExtensionCmd cmd = mock(UpdateRegisteredExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("physnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getDetails()).thenReturn(Map.of("username", "root", "password", "newSecret")); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physicalNetwork = mock(PhysicalNetworkVO.class); + when(physicalNetwork.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("physnet-uuid")).thenReturn(physicalNetwork); + + ExtensionResourceMapVO existing = mock(ExtensionResourceMapVO.class); + when(existing.getExtensionId()).thenReturn(1L); + when(existing.getId()).thenReturn(100L); + when(extensionResourceMapDao.listByResourceIdAndType(42L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(existing)); + extensionsManager.updateRegisteredExtensionWithResource(cmd); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(extensionResourceMapDetailsDao).saveDetails(captor.capture()); + verify(extensionResourceMapDetailsDao, never()).removeDetails(anyLong()); + List savedDetails = captor.getValue(); + + ExtensionResourceMapDetailsVO passwordDetail = savedDetails.stream() + .filter(detail -> "password".equals(detail.getName())) + .findFirst() + .orElse(null); + assertNotNull(passwordDetail); + assertFalse(passwordDetail.isDisplay()); + assertEquals("newSecret", passwordDetail.getValue()); + } + + @Test + @SuppressWarnings("unchecked") + public void registerExtensionWithClusterStoresSensitiveDetailsWithDisplayFalse() { + Cluster cluster = mock(Cluster.class); + when(cluster.getId()).thenReturn(12L); + when(cluster.getName()).thenReturn("cluster-12"); + when(cluster.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + + Extension extension = mock(Extension.class); + when(extension.getId()).thenReturn(5L); + + ExtensionResourceMapVO persistedMap = mock(ExtensionResourceMapVO.class); + when(persistedMap.getId()).thenReturn(120L); + when(extensionResourceMapDao.persist(any())).thenReturn(persistedMap); + + extensionsManager.registerExtensionWithCluster(cluster, extension, + Map.of("username", "admin", "password", "s3cr3t")); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(extensionResourceMapDetailsDao).saveDetails(captor.capture()); + List savedDetails = captor.getValue(); + + ExtensionResourceMapDetailsVO passwordDetail = savedDetails.stream() + .filter(detail -> "password".equals(detail.getName())) + .findFirst() + .orElse(null); + assertNotNull(passwordDetail); + assertFalse(passwordDetail.isDisplay()); + } + @Test public void testCreateExtensionResponse_BasicFields() { Extension extension = mock(Extension.class); @@ -2218,4 +2335,142 @@ public void addInbuiltExtensionReservedResourceDetailsAddedDetails() { assertEquals(reservedResourceDetails.size(), entry.getValue().size()); assertTrue(reservedResourceDetails.containsAll(entry.getValue())); } + + // ----------------------------------------------------------------------- + // Tests for ExtensionHelper methods (external network device support) + // ----------------------------------------------------------------------- + + @Test + public void getExtensionForPhysicalNetworkReturnsExtensionWhenRegistered() { + long physNetId = 10L; + long extensionId = 5L; + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getExtensionId()).thenReturn(extensionId); + when(extensionResourceMapDao.listByResourceIdAndType(physNetId, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(List.of(mapVO)); + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.findById(extensionId)).thenReturn(ext); + + Extension result = extensionsManager.getExtensionForPhysicalNetwork(physNetId); + + assertNotNull(result); + assertEquals(ext, result); + } + + @Test + public void getExtensionForPhysicalNetworkReturnsNullWhenNotRegistered() { + long physNetId = 10L; + when(extensionResourceMapDao.listByResourceIdAndType(physNetId, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(Collections.emptyList()); + + Extension result = extensionsManager.getExtensionForPhysicalNetwork(physNetId); + + assertNull(result); + } + + + // Helper: a mock object that is both a NetworkElement and a NetworkCustomActionProvider + interface MockNetworkElement extends NetworkElement, NetworkCustomActionProvider {} + + @Test + public void runNetworkCustomActionSucceeds() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(5L); + when(network.getName()).thenReturn("test-net"); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("reboot-device"); + when(actionVO.getId()).thenReturn(1L); + when(actionVO.getSuccessMessage()).thenReturn(null); + when(actionVO.getErrorMessage()).thenReturn(null); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("my-extnet"); + + Pair, Map> details = new Pair<>(new HashMap<>(), new HashMap<>()); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(1L)).thenReturn(details); + + // networkServiceMapDao returns provider name for SourceNat + when(networkServiceMapDao.getProviderForServiceInNetwork(eq(5L), any())).thenReturn("my-extnet"); + + // element implements both NetworkElement and NetworkCustomActionProvider + MockNetworkElement element = mock(MockNetworkElement.class); + when(element.canHandleCustomAction(eq(network))).thenReturn(true); + when(element.runCustomAction(eq(network), eq("reboot-device"), any())).thenReturn("OK: bridge bounced"); + when(networkModel.getElementImplementingProvider("my-extnet")).thenReturn(element); + + CustomActionResultResponse resp = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, + ExtensionCustomAction.ResourceType.Network, new HashMap<>()); + + assertTrue(resp.isSuccess()); + assertEquals("OK: bridge bounced", resp.getResult().get(ApiConstants.DETAILS)); + } + + @Test + public void runNetworkCustomActionFailsWhenNoProvider() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(5L); + when(network.getName()).thenReturn("test-net"); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(actionVO.getId()).thenReturn(2L); + when(actionVO.getSuccessMessage()).thenReturn(null); + when(actionVO.getErrorMessage()).thenReturn(null); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("my-extnet"); + + Pair, Map> details = new Pair<>(new HashMap<>(), new HashMap<>()); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(2L)).thenReturn(details); + + // No provider found for any service + when(networkServiceMapDao.getProviderForServiceInNetwork(eq(5L), any())).thenReturn(null); + + CustomActionResultResponse resp = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, + ExtensionCustomAction.ResourceType.Network, new HashMap<>()); + + assertFalse(resp.isSuccess()); + assertTrue(resp.getResult().get(ApiConstants.DETAILS).contains("No network service provider")); + } + + @Test + public void runNetworkCustomActionFailsWhenProviderReturnsNull() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(5L); + when(network.getName()).thenReturn("test-net"); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("unknown-action"); + when(actionVO.getId()).thenReturn(3L); + when(actionVO.getSuccessMessage()).thenReturn(null); + when(actionVO.getErrorMessage()).thenReturn(null); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("my-extnet"); + + Pair, Map> details = new Pair<>(new HashMap<>(), new HashMap<>()); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(3L)).thenReturn(details); + + // networkServiceMapDao returns provider name + when(networkServiceMapDao.getProviderForServiceInNetwork(eq(5L), any())).thenReturn("my-extnet"); + + // element implements both NetworkElement and NetworkCustomActionProvider but action returns null + MockNetworkElement element = mock(MockNetworkElement.class); + when(element.runCustomAction(eq(network), eq("unknown-action"), any())).thenReturn(null); + when(networkModel.getElementImplementingProvider("my-extnet")).thenReturn(element); + + CustomActionResultResponse resp = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, + ExtensionCustomAction.ResourceType.Network, new HashMap<>()); + + assertFalse(resp.isSuccess()); + assertTrue(resp.getResult().get(ApiConstants.DETAILS).contains("Action failed")); + } + } diff --git a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/ManagementServerMock.java b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/ManagementServerMock.java index 2107850c36be..906c7e4ff0b9 100644 --- a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/ManagementServerMock.java +++ b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/ManagementServerMock.java @@ -328,7 +328,7 @@ private void locatePhysicalNetwork() { } } if (_znet.getState() != PhysicalNetwork.State.Enabled) { - _znet = _networkService.updatePhysicalNetwork(_znet.getId(), null, null, null, PhysicalNetwork.State.Enabled.toString()); + _znet = _networkService.updatePhysicalNetwork(_znet.getId(), null, null, null, PhysicalNetwork.State.Enabled.toString(), null); } // Ensure that the physical network supports Guest traffic. diff --git a/server/src/main/java/com/cloud/api/ApiDBUtils.java b/server/src/main/java/com/cloud/api/ApiDBUtils.java index 1d00e9ec16ba..3f5596793492 100644 --- a/server/src/main/java/com/cloud/api/ApiDBUtils.java +++ b/server/src/main/java/com/cloud/api/ApiDBUtils.java @@ -1559,6 +1559,10 @@ public static boolean canElementEnableIndividualServices(Provider serviceProvide return s_networkModel.canElementEnableIndividualServices(serviceProvider); } + public static boolean canElementEnableIndividualServicesByName(String providerName) { + return s_networkModel.canElementEnableIndividualServicesByName(providerName); + } + public static Pair getDomainNetworkDetails(long networkId) { NetworkDomainVO map = s_networkDomainDao.getDomainNetworkMapByNetworkId(networkId); diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java index 11ac3d639e99..73ad886aab56 100644 --- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java +++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java @@ -548,6 +548,8 @@ public class ApiResponseHelper implements ResponseGenerator, ResourceIdSupport { ResourceIconManager resourceIconManager; @Inject AsyncJobDao asyncJobDao; + @Inject + NetworkModel networkModel; public static String getPrettyDomainPath(String path) { if (path == null) { @@ -3302,7 +3304,7 @@ public ProviderResponse createNetworkServiceProviderResponse(PhysicalNetworkServ } response.setServices(services); - Provider serviceProvider = Provider.getProvider(result.getProviderName()); + Provider serviceProvider = networkModel.resolveProvider(result.getProviderName()); boolean canEnableIndividualServices = ApiDBUtils.canElementEnableIndividualServices(serviceProvider); response.setCanEnableIndividualServices(canEnableIndividualServices); diff --git a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java index 6da5dda967d0..60a01764d8ab 100644 --- a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java +++ b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java @@ -7268,7 +7268,7 @@ public NetworkOffering createNetworkOffering(final NetworkOfferingBaseCmd cmd) { } for (final String prvNameStr : svcPrv.get(serviceStr)) { // check if provider is supported - final Network.Provider provider = Network.Provider.getProvider(prvNameStr); + final Network.Provider provider = _networkModel.resolveProvider(prvNameStr); if (provider == null) { throw new InvalidParameterValueException("Invalid service provider: " + prvNameStr); } @@ -7954,7 +7954,7 @@ protected void validateNtwkOffDetails(final Map details, final M // 1) Vaidate the detail values - have to match the lb provider // name final String providerStr = details.get(detail); - if (Network.Provider.getProvider(providerStr) == null) { + if (_networkModel.resolveProvider(providerStr) == null) { throw new InvalidParameterValueException("Invalid value " + providerStr + " for the detail " + detail); } if (serviceProviderMap.get(Service.Lb) != null) { diff --git a/server/src/main/java/com/cloud/network/NetworkModelImpl.java b/server/src/main/java/com/cloud/network/NetworkModelImpl.java index 07305c0e6418..35c11ec70020 100644 --- a/server/src/main/java/com/cloud/network/NetworkModelImpl.java +++ b/server/src/main/java/com/cloud/network/NetworkModelImpl.java @@ -99,6 +99,8 @@ import com.cloud.network.element.NetworkElement; import com.cloud.network.element.UserDataServiceProvider; import com.cloud.network.router.VirtualRouter; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.framework.extensions.network.NetworkExtensionElement; import com.cloud.network.rules.FirewallRule.Purpose; import com.cloud.network.rules.FirewallRuleVO; import com.cloud.network.rules.dao.PortForwardingRulesDao; @@ -234,6 +236,11 @@ public void setNetworkElements(List networkElements) { private NetworkService _networkService; @Inject TungstenGuestNetworkIpAddressDao tungstenGuestNetworkIpAddressDao; + @Inject + ExtensionHelper extensionHelper; + @Inject + private NetworkExtensionElement networkExtensionElement; + private final HashMap _systemNetworks = new HashMap(5); @@ -262,15 +269,39 @@ public NetworkModelImpl() { public NetworkElement getElementImplementingProvider(String providerName) { String elementName = s_providerToNetworkElementMap.get(providerName); NetworkElement element = AdapterBase.getAdapterByName(networkElements, elementName); + if (element == null && extensionHelper.isNetworkExtensionProvider(providerName)) { + // Provider is an extension-backed external network provider. + // Initialize a copy of NetworkExtensionElement + if (networkExtensionElement != null) { + element = networkExtensionElement.withProviderName(providerName); + } + } return element; } + /** + * Returns the effective network capabilities for an extension-backed external + * network provider on the given physical network. + * + * @param physicalNetworkId physical network ID (may be null for offering-level queries) + * @param providerName provider / extension name + * @return per-provider capabilities, or empty map if not found + */ + protected Map> getExternalProviderCapabilities( + Long physicalNetworkId, String providerName) { + return extensionHelper.getNetworkCapabilitiesForProvider(physicalNetworkId, providerName); + } + @Override public List getElementServices(Provider provider) { NetworkElement element = getElementImplementingProvider(provider.getName()); if (element == null) { throw new InvalidParameterValueException("Unable to find the Network Element implementing the Service Provider '" + provider.getName() + "'"); } + if (extensionHelper.isNetworkExtensionProvider(provider.getName())) { + Map> caps = getExternalProviderCapabilities(null, provider.getName()); + return new ArrayList(caps.keySet()); + } return new ArrayList(element.getCapabilities().keySet()); } @@ -283,6 +314,24 @@ public boolean canElementEnableIndividualServices(Provider provider) { return element.canEnableIndividualServices(); } + @Override + public boolean canElementEnableIndividualServicesByName(String providerName) { + if (providerName == null) { + return false; + } + // Try resolve to enum Provider first + Provider provider = resolveProvider(providerName); + if (provider != null) { + try { + return canElementEnableIndividualServices(provider); + } catch (Exception e) { + logger.debug("canElementEnableIndividualServices failed for provider {}: {}", providerName, e.getMessage()); + } + } + // Unknown provider: be conservative and return false + return false; + } + Set getPublicIpPurposeInRules(PublicIpAddress ip, boolean includeRevoked, boolean includingFirewall) { Set result = new HashSet(); List rules = null; @@ -435,8 +484,11 @@ Map> getServiceProvidersMap(long networkId) { if (providers == null) { providers = new HashSet(); } - providers.add(Provider.getProvider(nsm.getProvider())); - map.put(Service.getService(nsm.getService()), providers); + Provider provider = resolveProvider(nsm.getProvider()); + if (provider != null) { + providers.add(provider); + map.put(Service.getService(nsm.getService()), providers); + } } return map; } @@ -481,16 +533,45 @@ Map> getProviderServicesMap(long networkId) { Map> map = new HashMap>(); List nsms = _ntwkSrvcDao.getServicesInNetwork(networkId); for (NetworkServiceMapVO nsm : nsms) { - Set services = map.get(Provider.getProvider(nsm.getProvider())); + Provider provider = resolveProvider(nsm.getProvider()); + if (provider == null) { + continue; + } + Set services = map.get(provider); if (services == null) { services = new HashSet(); } services.add(Service.getService(nsm.getService())); - map.put(Provider.getProvider(nsm.getProvider()), services); + map.put(provider, services); } return map; } + /** + * Resolves a provider name to a {@link Provider} instance. + * + *

For well-known providers, returns the static constant from + * {@link Provider#getProvider(String)}. For dynamic NetworkOrchestrator + * extension providers (whose names are not in the static registry), returns + * a transient {@link Provider} with the given name so that the caller can + * still dispatch to the correct {@link NetworkExtensionElement} via + * {@link #getElementImplementingProvider(String)}.

+ * + * @param providerName the provider name from {@code ntwk_service_map} + * @return a {@link Provider} instance, or {@code null} if not resolvable + */ + @Override + public Provider resolveProvider(String providerName) { + Provider provider = Provider.getProvider(providerName); + if (provider == null && extensionHelper.isNetworkExtensionProvider(providerName)) { + // Dynamic extension-backed provider: create a transient Provider that preserves + // the actual extension name. getElementImplementingProvider() handles this name + // by detecting it as an extension provider and returning NetworkExtensionElement. + provider = Provider.createTransientProvider(providerName); + } + return provider; + } + @Override public Map> getProviderToIpList(Network network, Map> ipToServices) { NetworkOffering offering = _networkOfferingDao.findById(network.getNetworkOfferingId()); @@ -694,11 +775,21 @@ public Map> getNetworkCapabilities(long network // list all services of this networkOffering List servicesMap = _ntwkSrvcDao.getServicesInNetwork(networkId); + + // Resolve the physical network once for external provider lookups + NetworkVO network = _networksDao.findById(networkId); + Long physicalNetworkId = network != null ? network.getPhysicalNetworkId() : null; + for (NetworkServiceMapVO instance : servicesMap) { Service service = Service.getService(instance.getService()); NetworkElement element = getElementImplementingProvider(instance.getProvider()); if (element != null) { - Map> elementCapabilities = element.getCapabilities(); + Map> elementCapabilities; + if (extensionHelper.isNetworkExtensionProvider(instance.getProvider()) && physicalNetworkId != null) { + elementCapabilities = getExternalProviderCapabilities(physicalNetworkId, instance.getProvider()); + } else { + elementCapabilities = element.getCapabilities(); + } if (elementCapabilities != null) { networkCapabilities.put(service, elementCapabilities.get(service)); } @@ -724,10 +815,15 @@ public Map getNetworkServiceCapabilities(long networkId, Ser NetworkElement element = getElementImplementingProvider(provider); if (element != null) { - Map> elementCapabilities = element.getCapabilities(); - ; + Map> elementCapabilities; + if (extensionHelper.isNetworkExtensionProvider(provider)) { + elementCapabilities = getExternalProviderCapabilities(null, provider); + } else { + elementCapabilities = element.getCapabilities(); + } if (elementCapabilities == null || !elementCapabilities.containsKey(service)) { + // TBD: We should be sending providerId and not the offering object itself. throw new UnsupportedServiceException("Service " + service.getName() + " is not supported by the element=" + element.getName() + " implementing Provider=" + provider); } @@ -758,12 +854,22 @@ public Map getNetworkOfferingServiceCapabilities(NetworkOffe // we have to calculate capabilities for all of them String provider = providers.get(0); + // Check if this is an extension-backed external network provider first. + // These providers are not in the static s_providerToNetworkElementMap so + // we resolve their capabilities from the extension details directly. + if (extensionHelper.isNetworkExtensionProvider(provider)) { + Map> extCaps = getExternalProviderCapabilities(null, provider); + if (extCaps != null && extCaps.containsKey(service)) { + return extCaps.get(service); + } + return serviceCapabilities; + } + // FIXME we return the capabilities of the first provider of the service - what if we have multiple providers // for same Service? NetworkElement element = getElementImplementingProvider(provider); if (element != null) { Map> elementCapabilities = element.getCapabilities(); - ; if (elementCapabilities == null || !elementCapabilities.containsKey(service)) { // TBD: We should be sending providerId and not the offering object itself. @@ -979,7 +1085,6 @@ public boolean isSharedNetworkWithoutServices (long networkId) { return false; } - @Override public boolean areServicesSupportedByNetworkOffering(long networkOfferingId, Service... services) { return (_ntwkOfferingSrvcDao.areServicesSupportedByNetworkOffering(networkOfferingId, services)); @@ -1161,7 +1266,7 @@ public Map> getNetworkOfferingServiceProvidersMap(long ne if (providers == null) { providers = new HashSet(); } - providers.add(Provider.getProvider(instance.getProvider())); + providers.add(resolveProvider(instance.getProvider())); serviceProviderMap.put(Service.getService(service), providers); } @@ -1201,9 +1306,76 @@ public List listSupportedNetworkServiceProviders(String serv } } + // Also include extension-backed NetworkExtension providers registered in + // physical_network_service_providers whose provider name matches a registered + // NetworkOrchestrator extension (detected via extensionHelper.isNetworkExtensionProvider). + // + // We use _pNSPDao.listBy(physNetId) to enumerate all NSP entries, then check + // each provider name against the extension registry. This avoids a separate + // pass over all physical-network/extension combinations. + // resolveProvider() creates a transient Provider (not added to the static list) + // for extension names that are not in the built-in registry. + try { + List physNets = _physicalNetworkDao.listAll(); + if (physNets != null) { + // Use a set to avoid adding the same provider name twice (multiple phys-nets) + Set addedExtProviders = new HashSet<>(); + for (PhysicalNetworkVO physNet : physNets) { + List nsps = + _pNSPDao.listBy(physNet.getId()); + if (nsps == null) continue; + for (PhysicalNetworkServiceProviderVO nsp : nsps) { + String provName = nsp.getProviderName(); + if (provName == null || addedExtProviders.contains(provName)) continue; + if (!extensionHelper.isNetworkExtensionProvider(provName)) continue; + + // Filter by service if requested: check the NSP's service flags + if (service != null && !isServiceProvidedByNsp(nsp, service)) continue; + + // Resolve or create a transient Provider for the extension name. + Provider extProvider = resolveProvider(provName); + if (extProvider == null) continue; + supportedProviders.add(extProvider); + addedExtProviders.add(provName); + } + } + } + } catch (Exception e) { + logger.debug("Failed to include extension-backed providers in listSupportedNetworkServiceProviders: {}", e.getMessage()); + } + return new ArrayList(supportedProviders); } + /** + * Returns {@code true} if the given {@link com.cloud.network.dao.PhysicalNetworkServiceProviderVO} + * has its service flag set for {@code service}. + * + *

This is used by {@link #listSupportedNetworkServiceProviders} to filter extension-backed + * providers (looked up via {@link com.cloud.network.dao.PhysicalNetworkServiceProviderDao#listBy}) + * without having to query each extension's capability JSON.

+ */ + private boolean isServiceProvidedByNsp( + PhysicalNetworkServiceProviderVO nsp, Service service) { + if (service == null) { + return true; + } + if (service == Service.Dhcp) return nsp.isDhcpServiceProvided(); + if (service == Service.Dns) return nsp.isDnsServiceProvided(); + if (service == Service.Firewall) return nsp.isFirewallServiceProvided(); + if (service == Service.Gateway) return nsp.isGatewayServiceProvided(); + if (service == Service.Lb) return nsp.isLbServiceProvided(); + if (service == Service.PortForwarding) return nsp.isPortForwardingServiceProvided(); + if (service == Service.SecurityGroup) return nsp.isSecuritygroupServiceProvided(); + if (service == Service.SourceNat) return nsp.isSourcenatServiceProvided(); + if (service == Service.StaticNat) return nsp.isStaticnatServiceProvided(); + if (service == Service.UserData) return nsp.isUserdataServiceProvided(); + if (service == Service.Vpn) return nsp.isVpnServiceProvided(); + if (service == Service.NetworkACL) return nsp.isNetworkAclServiceProvided(); + // Unknown service: fall back to true so extension-backed providers are not filtered out + return true; + } + @Override public Provider getDefaultUniqueProviderForService(String serviceName) { List providers = listSupportedNetworkServiceProviders(serviceName); @@ -1540,13 +1712,19 @@ public void canProviderSupportServices(Map> providersMap) throw new InvalidParameterValueException("Unable to find the Network Element implementing the Service Provider '" + provider.getName() + "'"); } + // For external network providers, get per-provider capabilities + final boolean isExternal = extensionHelper.isNetworkExtensionProvider(provider.getName()); + Map> providerCaps = isExternal + ? getExternalProviderCapabilities(null, provider.getName()) + : element.getCapabilities(); + Set enabledServices = new HashSet(); enabledServices.addAll(providersMap.get(provider)); if (enabledServices != null && !enabledServices.isEmpty()) { - if (!element.canEnableIndividualServices()) { + if (!element.canEnableIndividualServices() && !isExternal) { Set requiredServices = new HashSet(); - requiredServices.addAll(element.getCapabilities().keySet()); + requiredServices.addAll(providerCaps.keySet()); if (requiredServices.contains(Network.Service.Gateway)) { requiredServices.remove(Network.Service.Gateway); @@ -1580,7 +1758,7 @@ public void canProviderSupportServices(Map> providersMap) List serviceList = new ArrayList(); for (Service service : enabledServices) { // check if the service is provided by this Provider - if (!element.getCapabilities().containsKey(service)) { + if (!providerCaps.containsKey(service)) { throw new UnsupportedServiceException(provider.getName() + " Provider cannot provide service " + service.getName()); } serviceList.add(service.getName()); @@ -1655,18 +1833,32 @@ public boolean checkIpForService(IpAddress userIp, Service service, Long network return true; } + @Override public boolean providerSupportsCapability(Set providers, Service service, Capability cap) { for (Provider provider : providers) { NetworkElement element = getElementImplementingProvider(provider.getName()); if (element != null) { - Map> elementCapabilities = element.getCapabilities(); + boolean isExtProvider = extensionHelper.isNetworkExtensionProvider(provider.getName()); + Map> elementCapabilities = isExtProvider + ? getExternalProviderCapabilities(null, provider.getName()) + : element.getCapabilities(); if (elementCapabilities == null || !elementCapabilities.containsKey(service)) { + if (isExtProvider) { + // Extension provider with no declared capabilities for this service — + // treat as "service supported but capability not constrained" + return false; + } throw new UnsupportedServiceException("Service " + service.getName() + " is not supported by the element=" + element.getName() + " implementing Provider=" + provider.getName()); } Map serviceCapabilities = elementCapabilities.get(service); if (serviceCapabilities == null || serviceCapabilities.isEmpty()) { + if (isExtProvider) { + // Extension provider declared the service but without specific capability + // constraints — treat as "capability not constrained, not explicitly supported" + return false; + } throw new UnsupportedServiceException("Service " + service.getName() + " doesn't have capabilites for element=" + element.getName() + " implementing Provider=" + provider.getName()); } @@ -1686,19 +1878,36 @@ public void checkCapabilityForProvider(Set providers, Service service, for (Provider provider : providers) { NetworkElement element = getElementImplementingProvider(provider.getName()); if (element != null) { - Map> elementCapabilities = element.getCapabilities(); + boolean isExtProvider = extensionHelper.isNetworkExtensionProvider(provider.getName()); + Map> elementCapabilities = isExtProvider + ? getExternalProviderCapabilities(null, provider.getName()) + : element.getCapabilities(); if (elementCapabilities == null || !elementCapabilities.containsKey(service)) { + if (isExtProvider) { + // Extension provider with no declared capabilities for this service — + // treat as supported without constraints; skip the capability check. + continue; + } throw new UnsupportedServiceException("Service " + service.getName() + " is not supported by the element=" + element.getName() + " implementing Provider=" + provider.getName()); } Map serviceCapabilities = elementCapabilities.get(service); if (serviceCapabilities == null || serviceCapabilities.isEmpty()) { + if (isExtProvider) { + // Extension provider declared the service without capability constraints — + // accept any capability value (the extension handles it at runtime). + continue; + } throw new UnsupportedServiceException("Service " + service.getName() + " doesn't have capabilities for element=" + element.getName() + " implementing Provider=" + provider.getName()); } String value = serviceCapabilities.get(cap); if (value == null || value.isEmpty()) { + if (isExtProvider) { + // Capability not explicitly declared for this extension — accept it. + continue; + } throw new UnsupportedServiceException("Service " + service.getName() + " doesn't have capability " + cap.getName() + " for element=" + element.getName() + " implementing Provider=" + provider.getName()); } @@ -1953,7 +2162,7 @@ public List getNtwkOffDistinctProviders(long ntkwOffId) { List providerNames = _ntwkOfferingSrvcDao.getDistinctProviders(ntkwOffId); List providers = new ArrayList(); for (String providerName : providerNames) { - providers.add(Network.Provider.getProvider(providerName)); + providers.add(resolveProvider(providerName)); } return providers; @@ -2255,7 +2464,7 @@ private List getNetworkProviders(long networkId) { Map providers = new HashMap(); for (String providerName : providerNames) { if (!providers.containsKey(providerName)) { - providers.put(providerName, Network.Provider.getProvider(providerName)); + providers.put(providerName, resolveProvider(providerName)); } } diff --git a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java index 24526e1aee0d..811b47cc90d1 100644 --- a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java +++ b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java @@ -68,6 +68,8 @@ import org.apache.cloudstack.api.response.AcquirePodIpCmdResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; +import org.apache.cloudstack.extension.ExtensionResourceMap; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; @@ -400,6 +402,8 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C @Inject NetworkDetailsDao _networkDetailsDao; @Inject + ExtensionsManager extensionsManager; + @Inject LoadBalancerDao _loadBalancerDao; @Inject NetworkMigrationManager _networkMigrationManager; @@ -3069,8 +3073,8 @@ public IpAddress getIp(String ipAddress) { protected boolean providersConfiguredForExternalNetworking(Collection providers) { for (String providerStr : providers) { - Provider provider = Network.Provider.getProvider(providerStr); - if (provider.isExternal()) { + Provider provider = _networkModel.resolveProvider(providerStr); + if (provider != null && provider.isExternal()) { return true; } } @@ -4372,7 +4376,7 @@ public Pair, Integer> searchPhysicalNetworks(Lon @Override @DB @ActionEvent(eventType = EventTypes.EVENT_PHYSICAL_NETWORK_UPDATE, eventDescription = "updating physical network", async = true) - public PhysicalNetwork updatePhysicalNetwork(Long id, String networkSpeed, List tags, String newVnetRange, String state) { + public PhysicalNetwork updatePhysicalNetwork(Long id, String networkSpeed, List tags, String newVnetRange, String state, Map externalDetails) { // verify input parameters PhysicalNetworkVO network = _physicalNetworkDao.findById(id); @@ -4426,6 +4430,29 @@ public PhysicalNetwork updatePhysicalNetwork(Long id, String networkSpeed, List< addOrRemoveVnets(listOfRanges, network); } _physicalNetworkDao.update(id, network); + + // If external details provided, and an extension is registered on this physical network, + // update the extension_resource_map_details accordingly. + try { + if (externalDetails != null && !externalDetails.isEmpty()) { + Pair needDetailsUpdateMapPair = + extensionsManager.extensionResourceMapDetailsNeedUpdate(id, + ExtensionResourceMap.ResourceType.PhysicalNetwork, externalDetails); + if (Boolean.TRUE.equals(needDetailsUpdateMapPair.first())) { + ExtensionResourceMap extensionResourceMap = needDetailsUpdateMapPair.second(); + if (extensionResourceMap == null) { + throw new InvalidParameterValueException( + String.format("Physical network: %s is not registered with any extension, details cannot be updated", + network.getId())); + } + extensionsManager.updateExtensionResourceMapDetails(extensionResourceMap.getId(), externalDetails); + } + } + } catch (Exception e) { + // Log warning but don't fail the update + logger.warn("Failed to update external details for physical network {}: {}", id, e.getMessage()); + } + return network; } @@ -5098,7 +5125,7 @@ public List listNetworkServices(String providerName) { Provider provider = null; if (providerName != null) { - provider = Network.Provider.getProvider(providerName); + provider = _networkModel.resolveProvider(providerName); if (provider == null) { throw new InvalidParameterValueException("Invalid Network Service Provider=" + providerName); } @@ -5135,7 +5162,7 @@ public PhysicalNetworkServiceProvider addProviderToPhysicalNetwork(Long physical } if (providerName != null) { - Provider provider = Network.Provider.getProvider(providerName); + Provider provider = _networkModel.resolveProvider(providerName); if (provider == null) { throw new InvalidParameterValueException("Invalid Network Service Provider=" + providerName); } diff --git a/server/src/main/java/com/cloud/network/as/AutoScaleManagerImpl.java b/server/src/main/java/com/cloud/network/as/AutoScaleManagerImpl.java index 805ac4aed86c..752db6d32390 100644 --- a/server/src/main/java/com/cloud/network/as/AutoScaleManagerImpl.java +++ b/server/src/main/java/com/cloud/network/as/AutoScaleManagerImpl.java @@ -1468,7 +1468,7 @@ public Counter createCounter(CreateCounterCmd cmd) { } // Validate Provider - Network.Provider provider = Network.Provider.getProvider(cmd.getProvider()); + Network.Provider provider = networkModel.resolveProvider(cmd.getProvider()); if (provider == null) { throw new InvalidParameterValueException("The Provider " + cmd.getProvider() + " does not exist; Unable to create Counter"); } @@ -1537,7 +1537,7 @@ public List listCounters(ListCountersCmd cmd) { } String providerStr = cmd.getProvider(); if (providerStr != null) { - Network.Provider provider = Network.Provider.getProvider(providerStr); + Network.Provider provider = networkModel.resolveProvider(providerStr); if (provider == null) { throw new InvalidParameterValueException("The Provider " + providerStr + " does not exist; Unable to list Counter"); } diff --git a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java index 779d26d51f1c..e996bfab273a 100644 --- a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java +++ b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java @@ -69,9 +69,11 @@ import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkServiceMapDao; import com.cloud.network.dao.NetworkVO; import com.cloud.network.element.FirewallServiceProvider; import com.cloud.network.element.NetworkACLServiceProvider; +import com.cloud.network.element.NetworkElement; import com.cloud.network.element.PortForwardingServiceProvider; import com.cloud.network.element.StaticNatServiceProvider; import com.cloud.network.rules.FirewallManager; @@ -150,6 +152,9 @@ public class FirewallManagerImpl extends ManagerBase implements FirewallService, EntityManager entityManager; @Inject NsxProviderDao nsxProviderDao; + @Inject + NetworkServiceMapDao networkServiceMapDao; + List _firewallElements; List _pfElements; @@ -617,12 +622,25 @@ public void validateFirewallRule(Account caller, IPAddressVO ipAddress, Integer String supportedProtocols; String supportedTrafficTypes = null; if (purpose == FirewallRule.Purpose.Firewall) { - supportedTrafficTypes = caps.get(Capability.SupportedTrafficDirection).toLowerCase(); + String supportedTrafficTypesStr = caps.get(Capability.SupportedTrafficDirection); + if (supportedTrafficTypesStr == null) { + throw new CloudRuntimeException("Supported traffic direction capability is not defined for Firewall service"); + } + supportedTrafficTypes = supportedTrafficTypesStr.toLowerCase(); } if (purpose == FirewallRule.Purpose.Firewall && trafficType == FirewallRule.TrafficType.Egress) { + // throw an exception if cap is not found + String supportedProtocolsStr = caps.get(Capability.SupportedEgressProtocols); + if (supportedProtocolsStr == null) { + throw new CloudRuntimeException("Supported egress protocols capability is not defined for Firewall service"); + } supportedProtocols = caps.get(Capability.SupportedEgressProtocols).toLowerCase(); } else { + String supportedProtocolsStr = caps.get(Capability.SupportedProtocols); + if (supportedProtocolsStr == null) { + throw new CloudRuntimeException("Supported protocols capability is not defined for " + purpose + " service"); + } supportedProtocols = caps.get(Capability.SupportedProtocols).toLowerCase(); } @@ -700,18 +718,34 @@ public boolean applyRules(Network network, Purpose purpose, List)rules); + handled = element.applyPFRules(network, (List)rules); if (handled) break; } + if (!handled) { + // Get provider name and get the element by provider name (it could be an external provider) + String pfProviderName = networkServiceMapDao.getProviderForServiceInNetwork(network.getId(), Service.PortForwarding); + if (pfProviderName != null) { + NetworkElement element = _networkModel.getElementImplementingProvider(pfProviderName); + handled = ((PortForwardingServiceProvider) element).applyPFRules(network, (List) rules); + } + } break; /* case NetworkACL: for (NetworkACLServiceProvider element: _networkAclElements) { @@ -726,7 +760,7 @@ public boolean applyRules(Network network, Purpose purpose, List rules) t if (handled) break; } + if (!handled) { + // Get provider name and get the element by provider name (it could be an external provider) + String lbProviderName = _ntwkSrvcDao.getProviderForServiceInNetwork(network.getId(), Service.Lb); + if (lbProviderName != null) { + NetworkElement element = _networkModel.getElementImplementingProvider(lbProviderName); + handled = ((LoadBalancingServiceProvider) element).applyLBRules(network, rules); + } + } return handled; } diff --git a/server/src/main/java/com/cloud/network/vpc/NetworkACLManagerImpl.java b/server/src/main/java/com/cloud/network/vpc/NetworkACLManagerImpl.java index b6dfdbac0be2..6b499bbd1fe4 100644 --- a/server/src/main/java/com/cloud/network/vpc/NetworkACLManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpc/NetworkACLManagerImpl.java @@ -33,8 +33,10 @@ import com.cloud.network.Network.Service; import com.cloud.network.NetworkModel; import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkServiceMapDao; import com.cloud.network.dao.NetworkVO; import com.cloud.network.element.NetworkACLServiceProvider; +import com.cloud.network.element.NetworkElement; import com.cloud.network.element.VpcProvider; import com.cloud.network.vpc.NetworkACLItem.State; import com.cloud.network.vpc.dao.NetworkACLDao; @@ -75,6 +77,8 @@ public class NetworkACLManagerImpl extends ManagerBase implements NetworkACLMana private MessageBus _messageBus; @Inject private ResourceTagDao resourceTagDao; + @Inject + NetworkServiceMapDao networkServiceMapDao; private List _networkAclElements; @@ -442,12 +446,23 @@ public boolean applyACLItemsToNetwork(final long networkId, final List vpcElements = null; @@ -701,7 +705,7 @@ public VpcOffering createVpcOffering(final String name, final String displayText final Set providers = new HashSet(); for (final String prvNameStr : serviceEntry.getValue()) { // check if provider is supported - final Network.Provider provider = Network.Provider.getProvider(prvNameStr); + final Network.Provider provider = _ntwkModel.resolveProvider(prvNameStr); if (provider == null) { throw new InvalidParameterValueException("Invalid service provider: " + prvNameStr); } @@ -1248,12 +1252,18 @@ public Map> getVpcOffSvcProvidersMap(final long vpcOffId) for (final VpcOfferingServiceMapVO instance : map) { final Service service = Service.getService(instance.getService()); + if (service == null) { + continue; + } Set providers; providers = serviceProviderMap.get(service); if (providers == null) { providers = new HashSet(); } - providers.add(Provider.getProvider(instance.getProvider())); + final Provider provider = _ntwkModel.resolveProvider(instance.getProvider()); + if (provider != null) { + providers.add(provider); + } serviceProviderMap.put(service, providers); } @@ -1844,6 +1854,8 @@ private Map> finalizeServicesAndProvidersForVpc(final long if (provider == null) { // Default to VPCVirtualRouter provider = Provider.VPCVirtualRouter.getName(); + } else { + provider = _ntwkModel.resolveProvider(provider).getName(); } if (!_ntwkModel.isProviderEnabledInZone(zoneId, provider)) { @@ -2027,9 +2039,12 @@ private boolean checkAndUpdateRouterSourceNatIp(Vpc vpc, String sourceNatIp) { if (! userIps.isEmpty()) { try { _ipAddrMgr.updateSourceNatIpAddress(requestedIp, userIps); - if (isVpcForProvider(Provider.Nsx, vpc) || isVpcForProvider(Provider.Netris, vpc)) { + if (isVpcForProvider(Provider.Nsx, vpc) || isVpcForProvider(Provider.Netris, vpc) + || isVpcForProvider(Provider.NetworkExtension, vpc)) { boolean isForNsx = _vpcOffSvcMapDao.isProviderForVpcOffering(Provider.Nsx, vpc.getVpcOfferingId()); - String providerName = isForNsx ? Provider.Nsx.getName() : Provider.Netris.getName(); + boolean isForNetris = _vpcOffSvcMapDao.isProviderForVpcOffering(Provider.Netris, vpc.getVpcOfferingId()); + String providerName = isForNsx ? Provider.Nsx.getName() + : (isForNetris ? Provider.Netris.getName() : Provider.NetworkExtension.getName()); VpcProvider providerElement = (VpcProvider) _ntwkModel.getElementImplementingProvider(providerName); if (Objects.nonNull(providerElement)) { providerElement.updateVpcSourceNatIp(vpc, requestedIp); @@ -2503,7 +2518,8 @@ public void validateNtwkOffForVpc(final NetworkOffering guestNtwkOff, final List // 1) in current release, only vpc provider is supported by Vpc offering final List providers = _ntwkModel.getNtwkOffDistinctProviders(guestNtwkOff.getId()); for (final Provider provider : providers) { - if (!supportedProviders.contains(provider)) { + if (!supportedProviders.contains(provider) + && !extensionHelper.isNetworkExtensionProvider(provider.getName())) { throw new InvalidParameterValueException("Provider of type " + provider.getName() + " is not supported for network offerings that can be used in VPC"); } } @@ -2610,17 +2626,32 @@ private void CheckAccountsAccess(Vpc vpc, Account networkAccount) { } public List getVpcElements() { + // Static providers (VPCVirtualRouter, JuniperContrailVpcRouter) are initialized once. if (vpcElements == null) { vpcElements = new ArrayList(); - vpcElements.add((VpcProvider) _ntwkModel.getElementImplementingProvider(Provider.VPCVirtualRouter.getName())); - vpcElements.add((VpcProvider) _ntwkModel.getElementImplementingProvider(Provider.JuniperContrailVpcRouter.getName())); + final NetworkElement vpcVirtualRouter = _ntwkModel.getElementImplementingProvider(Provider.VPCVirtualRouter.getName()); + if (vpcVirtualRouter instanceof VpcProvider) { + vpcElements.add((VpcProvider) vpcVirtualRouter); + } + + final NetworkElement contrailVpcRouter = _ntwkModel.getElementImplementingProvider(Provider.JuniperContrailVpcRouter.getName()); + if (contrailVpcRouter instanceof VpcProvider) { + vpcElements.add((VpcProvider) contrailVpcRouter); + } } - if (vpcElements == null) { - throw new CloudRuntimeException("Failed to initialize vpc elements"); + // Extension-backed providers are re-fetched every call so that dynamically + // registered extensions are picked up without requiring a server restart. + final List result = new ArrayList<>(vpcElements); + for (final Extension extension : extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) { + final String providerName = extension.getName(); + final NetworkElement element = _ntwkModel.getElementImplementingProvider(providerName); + if (element instanceof VpcProvider) { + result.add((VpcProvider) element); + } } - return vpcElements; + return result; } @Override @@ -3929,7 +3960,7 @@ private List getVpcProviders(final long vpcId) { final Map providers = new HashMap(); for (final String providerName : providerNames) { if (!providers.containsKey(providerName)) { - providers.put(providerName, Network.Provider.getProvider(providerName)); + providers.put(providerName, _ntwkModel.resolveProvider(providerName)); } } diff --git a/server/src/test/java/com/cloud/network/MockNetworkModelImpl.java b/server/src/test/java/com/cloud/network/MockNetworkModelImpl.java index b5e081e043be..3f2f3b6e955e 100644 --- a/server/src/test/java/com/cloud/network/MockNetworkModelImpl.java +++ b/server/src/test/java/com/cloud/network/MockNetworkModelImpl.java @@ -400,6 +400,16 @@ public boolean canElementEnableIndividualServices(Provider provider) { return false; } + @Override + public boolean canElementEnableIndividualServicesByName(String providerName) { + return false; + } + + @Override + public Provider resolveProvider(String providerName) { + return Provider.getProvider(providerName); + } + /* (non-Javadoc) * @see com.cloud.network.NetworkModel#areServicesSupportedInNetwork(long, com.cloud.network.Network.Service[]) */ diff --git a/server/src/test/java/com/cloud/network/NetworkModelImplTest.java b/server/src/test/java/com/cloud/network/NetworkModelImplTest.java index b6f2ff65b9fa..b70dc74bdb9b 100644 --- a/server/src/test/java/com/cloud/network/NetworkModelImplTest.java +++ b/server/src/test/java/com/cloud/network/NetworkModelImplTest.java @@ -66,6 +66,7 @@ import com.cloud.vm.Nic; import com.cloud.vm.NicProfile; import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.extension.ExtensionHelper; @RunWith(MockitoJUnitRunner.class) public class NetworkModelImplTest { @@ -80,6 +81,8 @@ public class NetworkModelImplTest { private NetworkDao _networksDao; @Inject private NetworkOfferingServiceMapDao networkOfferingServiceMapDao; + @Mock + private ExtensionHelper extensionHelper; @Spy @InjectMocks @@ -96,6 +99,8 @@ public void setUp() { networkModel._networkOfferingDao = networkOfferingDao; networkModel._ntwkSrvcDao = networkServiceMapDao; networkModel._ntwkOfferingSrvcDao = networkOfferingServiceMapDao; + ReflectionTestUtils.setField(networkModel, "extensionHelper", extensionHelper); + Mockito.lenient().when(extensionHelper.isNetworkExtensionProvider(Mockito.anyString())).thenReturn(false); } private void prepareMocks(boolean isIp6, Network network, DataCenter zone, VpcVO vpc, @@ -242,8 +247,8 @@ public void testGetProviderToIpList() { networkOfferingVO.setForVpc(true); Network network = new NetworkVO(); List networkServiceMapVOs = new ArrayList<>(); - networkServiceMapVOs.add(new NetworkServiceMapVO(15L, Network.Service.Firewall, Network.Provider.VPCVirtualRouter)); - networkServiceMapVOs.add(new NetworkServiceMapVO(15L, Network.Service.SourceNat, Network.Provider.VPCVirtualRouter)); + networkServiceMapVOs.add(new NetworkServiceMapVO(15L, Network.Service.Firewall.getName(), Network.Provider.VPCVirtualRouter.getName())); + networkServiceMapVOs.add(new NetworkServiceMapVO(15L, Network.Service.SourceNat.getName(), Network.Provider.VPCVirtualRouter.getName())); NetworkElement element = new VpcVirtualRouterElement(); ReflectionTestUtils.setField(networkModel, "networkElements", List.of(element)); diff --git a/server/src/test/java/com/cloud/network/UpdatePhysicalNetworkTest.java b/server/src/test/java/com/cloud/network/UpdatePhysicalNetworkTest.java index ae70d137b0da..4ecae8052018 100644 --- a/server/src/test/java/com/cloud/network/UpdatePhysicalNetworkTest.java +++ b/server/src/test/java/com/cloud/network/UpdatePhysicalNetworkTest.java @@ -61,7 +61,7 @@ public void updatePhysicalNetworkTest() { when(_datacenterDao.findById(anyLong())).thenReturn(datacentervo); when(_physicalNetworkDao.update(anyLong(), any(physicalNetworkVO.getClass()))).thenReturn(true); when(_datacenterVnetDao.listVnetsByPhysicalNetworkAndDataCenter(anyLong(), anyLong())).thenReturn(existingRange); - networkService.updatePhysicalNetwork(1l, null, null, "524-524,525-530", null); + networkService.updatePhysicalNetwork(1l, null, null, "524-524,525-530", null, null); txn.close("updatePhysicalNetworkTest"); verify(physicalNetworkVO).setVnet(argumentCaptor.capture()); assertEquals("524-530", argumentCaptor.getValue()); diff --git a/server/src/test/java/com/cloud/network/as/AutoScaleManagerImplTest.java b/server/src/test/java/com/cloud/network/as/AutoScaleManagerImplTest.java index 215a0e784bc6..ebdbbc85171d 100644 --- a/server/src/test/java/com/cloud/network/as/AutoScaleManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/as/AutoScaleManagerImplTest.java @@ -423,6 +423,8 @@ public void setUp() { when(conditionDao.findById(any())).thenReturn(conditionMock); when(conditionDao.persist(any(ConditionVO.class))).thenReturn(conditionMock); + when(networkModel.resolveProvider(counterProvider)).thenReturn(Network.Provider.VirtualRouter); + when(accountManager.finalizeOwner(nullable(Account.class), nullable(String.class), nullable(Long.class), nullable(Long.class))).thenReturn(account); Mockito.doNothing().when(accountManager).checkAccess(Mockito.any(Account.class), Mockito.isNull(), Mockito.anyBoolean(), Mockito.any()); diff --git a/server/src/test/java/com/cloud/network/element/ConfigDriveNetworkElementTest.java b/server/src/test/java/com/cloud/network/element/ConfigDriveNetworkElementTest.java index 0aab5afce4a5..336f9990c12b 100644 --- a/server/src/test/java/com/cloud/network/element/ConfigDriveNetworkElementTest.java +++ b/server/src/test/java/com/cloud/network/element/ConfigDriveNetworkElementTest.java @@ -62,6 +62,8 @@ import com.google.common.collect.Maps; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.springframework.test.util.ReflectionTestUtils; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint; @@ -151,6 +153,7 @@ public class ConfigDriveNetworkElementTest { @Mock private CallContext callContextMock; @Mock private DomainVO domainVO; @Mock private NetworkOrchestrationService _networkOrchestrationService; + @Mock private ExtensionHelper extensionHelper; @Spy @InjectMocks private ConfigDriveNetworkElement _configDrivesNetworkElement = new ConfigDriveNetworkElement(); @@ -202,6 +205,8 @@ public void setUp() throws NoSuchFieldException, IllegalAccessException { doReturn(_configDrivesNetworkElement.getProvider().getName()).when(_ntwkSrvcDao).getProviderForServiceInNetwork(NETWORK_ID, Network.Service.UserData); _networkModel.setNetworkElements(Arrays.asList(_configDrivesNetworkElement)); + ReflectionTestUtils.setField(_networkModel, "extensionHelper", extensionHelper); + Mockito.lenient().when(extensionHelper.isNetworkExtensionProvider(Mockito.anyString())).thenReturn(false); _networkModel.start(); } diff --git a/server/src/test/java/com/cloud/network/vpc/NetworkACLManagerTest.java b/server/src/test/java/com/cloud/network/vpc/NetworkACLManagerTest.java index 651d7cb6aef3..09d3ebd762da 100644 --- a/server/src/test/java/com/cloud/network/vpc/NetworkACLManagerTest.java +++ b/server/src/test/java/com/cloud/network/vpc/NetworkACLManagerTest.java @@ -68,7 +68,6 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @RunWith(SpringJUnit4ClassRunner.class) @@ -92,6 +91,8 @@ public class NetworkACLManagerTest extends TestCase { @Inject NetworkModel _networkModel; @Inject + NetworkServiceMapDao networkServiceMapDao; + @Inject List _networkAclElements; @Inject VpcService _vpcSvc; @@ -169,8 +170,7 @@ public void driveTestApplyNetworkACL(final boolean result, final boolean applyNe final List networks = new ArrayList<>(); networks.add(network); - NetworkServiceMapDao ntwkSrvcDao = mock(NetworkServiceMapDao.class); - when(ntwkSrvcDao.canProviderSupportServiceInNetwork(anyLong(), eq(Network.Service.NetworkACL), nullable(Network.Provider.class))).thenReturn(true); + when(networkServiceMapDao.canProviderSupportServiceInNetwork(anyLong(), eq(Network.Service.NetworkACL), nullable(Network.Provider.class))).thenReturn(true); Mockito.when(_networkDao.listByAclId(anyLong())).thenReturn(networks); Mockito.when(_networkDao.findById(anyLong())).thenReturn(network); Mockito.when(networkOfferingDao.isIpv6Supported(anyLong())).thenReturn(false); @@ -277,6 +277,11 @@ public NetworkModel networkModel() { return Mockito.mock(NetworkModel.class); } + @Bean + public NetworkServiceMapDao networkServiceMapDao() { + return Mockito.mock(NetworkServiceMapDao.class); + } + @Bean public VpcManager vpcManager() { return Mockito.mock(VpcManager.class); diff --git a/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java b/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java index 3d3bfbdf162a..2cb61b59d68f 100644 --- a/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java @@ -99,6 +99,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; @@ -247,14 +248,21 @@ private void overrideDefaultConfigValue(final ConfigKey configKey, final String @Test public void getVpcOffSvcProvidersMapForEmptyServiceTest() { long vpcOffId = 1L; + VpcOfferingServiceMapVO svcMap = mock(VpcOfferingServiceMapVO.class); + Mockito.when(svcMap.getService()).thenReturn(Service.SourceNat.getName()); + Mockito.when(svcMap.getProvider()).thenReturn(Provider.VPCVirtualRouter.getName()); + Mockito.when(networkModel.resolveProvider(Provider.VPCVirtualRouter.getName())) + .thenReturn(Provider.VPCVirtualRouter); List list = new ArrayList(); - list.add(mock(VpcOfferingServiceMapVO.class)); + list.add(svcMap); Mockito.when(manager._vpcOffSvcMapDao.listByVpcOffId(vpcOffId)).thenReturn(list); Map> map = manager.getVpcOffSvcProvidersMap(vpcOffId); assertNotNull(map); assertEquals(map.size(), 1); + assertTrue(map.containsKey(Service.SourceNat)); + assertTrue(map.get(Service.SourceNat).contains(Provider.VPCVirtualRouter)); } protected Map createFakeCapabilityInputMap() { diff --git a/server/src/test/java/com/cloud/vpc/MockNetworkManagerImpl.java b/server/src/test/java/com/cloud/vpc/MockNetworkManagerImpl.java index 3b120b88db45..01fb7e3be91e 100644 --- a/server/src/test/java/com/cloud/vpc/MockNetworkManagerImpl.java +++ b/server/src/test/java/com/cloud/vpc/MockNetworkManagerImpl.java @@ -345,7 +345,7 @@ public Pair, Integer> searchPhysicalNetworks(Lon * @see com.cloud.network.NetworkService#updatePhysicalNetwork(java.lang.Long, java.lang.String, java.util.List, java.lang.String, java.lang.String) */ @Override - public PhysicalNetwork updatePhysicalNetwork(Long id, String networkSpeed, List tags, String newVnetRangeString, String state) { + public PhysicalNetwork updatePhysicalNetwork(Long id, String networkSpeed, List tags, String newVnetRangeString, String state, Map externalDetails) { // TODO Auto-generated method stub return null; } diff --git a/server/src/test/java/com/cloud/vpc/MockNetworkModelImpl.java b/server/src/test/java/com/cloud/vpc/MockNetworkModelImpl.java index b9423daaeebb..83f3fda383c8 100644 --- a/server/src/test/java/com/cloud/vpc/MockNetworkModelImpl.java +++ b/server/src/test/java/com/cloud/vpc/MockNetworkModelImpl.java @@ -411,6 +411,15 @@ public boolean canElementEnableIndividualServices(Provider provider) { return false; } + @Override + public Provider resolveProvider(String providerName) { + return Provider.getProvider(providerName); + } + @Override + public boolean canElementEnableIndividualServicesByName(String providerName) { + return false; + } + /* (non-Javadoc) * @see com.cloud.network.NetworkModel#areServicesSupportedInNetwork(long, com.cloud.network.Network.Service[]) */ diff --git a/test/integration/smoke/test_network_extension_namespace.py b/test/integration/smoke/test_network_extension_namespace.py new file mode 100644 index 000000000000..ef4c335e6428 --- /dev/null +++ b/test/integration/smoke/test_network_extension_namespace.py @@ -0,0 +1,2589 @@ +# 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. +"""Support module for the NetworkExtension smoke tests. + +**Do not run this file directly.** Tests are discovered and executed through +``test_network_extension_namespace.py``, which exposes the test class under a +discoverable name. + +This module provides: + * Module-level constants (script names, URLs, capabilities JSON). + * Helper functions (_download_script, _ensure_scripts_downloaded, etc.). + * Deployer classes (MgmtServerDeployer, KvmHostDeployer). + * Base test class ``_TestNetworkExtensionNamespace`` (underscore = not + collected directly by nose/Marvin). + +Renamed from ``test_network_extension_provider.py``. The canonical test file +is ``test_network_extension_namespace.py``. +""" +import json +import logging +import os +import random +import shutil +import stat +import subprocess +import tempfile +import time +import urllib.parse + +from marvin.cloudstackTestCase import cloudstackTestCase +from marvin.cloudstackAPI import (listPhysicalNetworks, + listTrafficTypes, + listManagementServers, + listNetworkServiceProviders, + updateNetworkServiceProvider, + deleteNetworkServiceProvider, + createFirewallRule, + deleteFirewallRule, + listPublicIpAddresses) +from marvin.lib.base import (Account, + Extension, + ExtensionCustomAction, + LoadBalancerRule, + Network, + NetworkACL, + NetworkACLList, + NetworkOffering, + NATRule, + PublicIPAddress, + ServiceOffering, + SSHKeyPair, + StaticNATRule, + Template, + VirtualMachine, + VPC, + VpcOffering) +from marvin.lib.common import (get_domain, get_zone, get_template) +from marvin.lib.utils import cleanup_resources, random_gen +from marvin.sshClient import SshClient +from nose.plugins.attrib import attr + +_multiprocess_shared_ = True + +# The file names of the scripts to deploy on the management server and KVM hosts. +SCRIPT_FILENAME = 'network-namespace-wrapper.sh' +ENTRY_POINT_FILENAME = 'network-namespace.sh' + +# Remote URLs to download the scripts from +_GITHUB_BASE = ( + 'https://raw.githubusercontent.com/apache/cloudstack-extensions' + '/refs/heads/network-namespace/Network-Namespace/' +) +WRAPPER_SCRIPT_URL = _GITHUB_BASE + SCRIPT_FILENAME +ENTRY_POINT_SCRIPT_URL = _GITHUB_BASE + ENTRY_POINT_FILENAME + +# Local cache paths (downloaded once, reused across test methods) +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_SCRIPT_CACHE_DIR = os.path.join(tempfile.gettempdir(), 'cs-extnet-script-cache') +WRAPPER_SCRIPT_LOCAL = os.path.join(_SCRIPT_CACHE_DIR, SCRIPT_FILENAME) +ENTRY_POINT_SCRIPT_LOCAL = os.path.join(_SCRIPT_CACHE_DIR, ENTRY_POINT_FILENAME) + +# Network services — comma-separated list of all services this extension supports. +# Tests select a subset when creating NetworkOfferings. +NETWORK_SERVICES = ( + "Dhcp,Dns,UserData," + "SourceNat,StaticNat,PortForwarding,Firewall,Lb,NetworkACL" +) + +# Per-service capabilities JSON object (no "services" wrapper). +NETWORK_SERVICE_CAPABILITIES_JSON = json.dumps({ + "Lb": { + "SupportedLBAlgorithms": "roundrobin,leastconn,source", + "SupportedLBIsolation": "dedicated", + "SupportedProtocols": "tcp,udp,tcp-proxy", + "SupportedStickinessMethods": "lbcookie,appsession", + "LbSchemes": "Public", + "SslTermination": "false", + "VmAutoScaling": "false" + }, + "Firewall": { + "TrafficStatistics": "per public ip", + "SupportedProtocols": "tcp,udp,icmp", + "SupportedEgressProtocols": "tcp,udp,icmp,all", + "SupportedTrafficDirection": "ingress,egress", + "MultipleIps": "true" + }, + "Dns": { + "AllowDnsSuffixModification": "true", + "ExternalDns": "true" + }, + "Dhcp": { + "DhcpAccrossMultipleSubnets": "true" + }, + "Gateway": { + "RedundantRouter": "false" + }, + "SourceNat": { + "SupportedSourceNatTypes": "peraccount", + "RedundantRouter": "false" + }, + "StaticNat": { + "Supported": "true" + }, + "PortForwarding": { + "SupportedProtocols": "tcp,udp" + }, + "UserData": { + "Supported": "true" + }, + "NetworkACL": { + "SupportedProtocols": "tcp,udp,icmp" + } +}) + + +# --------------------------------------------------------------------------- +# Script download helpers +# --------------------------------------------------------------------------- + +def _download_script(url, dest_path): + """Download *url* to *dest_path* via curl or wget and make it executable.""" + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + log = logging.getLogger('cs-extnet') + log.info("Downloading %s -> %s", url, dest_path) + for cmd in (['curl', '-fsSL', url, '-o', dest_path], + ['wget', '-q', url, '-O', dest_path]): + if subprocess.run(cmd, check=False).returncode == 0: + os.chmod(dest_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) + return dest_path + raise RuntimeError("Failed to download %s with curl and wget" % url) + + +def _ensure_scripts_downloaded(): + """Download both scripts from GitHub if not already cached. + + If GitHub is unreachable the function falls back to the corresponding + scripts in the local source tree so that tests can run offline: + * wrapper → extensions/network-namespace/network-namespace-wrapper.sh + * entry point → extensions/network-namespace/network-namespace.sh + + Returns (wrapper_path, entry_point_path). + """ + _src_root = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', '..', '..')) + + try: + _download_script(WRAPPER_SCRIPT_URL, WRAPPER_SCRIPT_LOCAL) + except Exception: + # Offline fallback: deploy the source-tree implementation. + _local_impl = os.path.join( + _src_root, 'extensions', 'network-namespace', + 'network-namespace-wrapper.sh') + if os.path.exists(_local_impl): + os.makedirs(os.path.dirname(WRAPPER_SCRIPT_LOCAL), + exist_ok=True) + shutil.copy2(_local_impl, WRAPPER_SCRIPT_LOCAL) + os.chmod(WRAPPER_SCRIPT_LOCAL, + stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) + logging.getLogger('cs-extnet').info( + "Offline fallback: using local %s as %s", + _local_impl, WRAPPER_SCRIPT_LOCAL) + else: + raise + + try: + _download_script(ENTRY_POINT_SCRIPT_URL, ENTRY_POINT_SCRIPT_LOCAL) + except Exception: + _local_ep = os.path.join( + _src_root, 'extensions', 'network-namespace', + 'network-namespace.sh') + if os.path.exists(_local_ep): + os.makedirs(os.path.dirname(ENTRY_POINT_SCRIPT_LOCAL), + exist_ok=True) + shutil.copy2(_local_ep, ENTRY_POINT_SCRIPT_LOCAL) + os.chmod(ENTRY_POINT_SCRIPT_LOCAL, + stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) + logging.getLogger('cs-extnet').info( + "Offline fallback: using local %s as %s", + _local_ep, ENTRY_POINT_SCRIPT_LOCAL) + else: + raise + + return WRAPPER_SCRIPT_LOCAL, ENTRY_POINT_SCRIPT_LOCAL + + +# --------------------------------------------------------------------------- +# KVM host discovery helpers (from Marvin config) +# --------------------------------------------------------------------------- + +def _get_kvm_hosts_from_config(config): + """Return list of host dicts for all KVM hosts in the Marvin config. + + Each entry: {"ip": .., "username": .., "password": ..} + """ + hosts = [] + try: + for zone in config.__dict__.get("zones", []): + for pod in zone.__dict__.get("pods", []): + for cluster in pod.__dict__.get("clusters", []): + for h in cluster.__dict__.get("hosts", []): + if hasattr(h, '__dict__'): + h = h.__dict__ + url = h.get("url", "") + if not url.startswith('http://') and not url.startswith('https://'): + url = 'http://' + url + parsed = urllib.parse.urlparse(url) + ip = parsed.hostname or '' + if not ip: + continue + hosts.append({ + "ip": ip, + "username": h.get("username", "root"), + "password": h.get("password", ""), + }) + except Exception as e: + logging.getLogger('cs-extnet').warning( + "Could not read KVM hosts from config: %s", e) + return hosts + + +# --------------------------------------------------------------------------- +# SSH helper +# --------------------------------------------------------------------------- + +def _ssh_copy_file(host_ip, host_port, username, password, local_path, remote_path): + """Transfer *local_path* to *remote_path* on *host_ip* via SshClient.""" + ssh = SshClient(host_ip, int(host_port), username, password) + ssh.execute("mkdir -p '%s'" % os.path.dirname(remote_path)) + # Use SFTP upload to avoid very large shell arguments for script content. + ssh.scp(local_path, remote_path) + ssh.execute("chmod 755 '%s'" % remote_path) + + +# --------------------------------------------------------------------------- +# MgmtServerDeployer – deploys network-namespace.sh to the management server +# --------------------------------------------------------------------------- + +class MgmtServerDeployer: + """Copies network-namespace.sh to the management server via SSH.""" + + def __init__(self, mgt_details, logger=None): + self.ip = mgt_details.get("mgtSvrIp", "localhost") + self.port = 22 + self.user = mgt_details.get("user", "root") + self.passwd = mgt_details.get("passwd", "") + self.logger = logger or logging.getLogger('MgmtServerDeployer') + + def copy_file(self, local_path, remote_path, mode='0755'): + _ssh_copy_file(self.ip, self.port, self.user, self.passwd, + local_path, remote_path) + self.logger.info("Copied %s -> %s on mgmt %s", + local_path, remote_path, self.ip) + + def remove_file(self, remote_path): + try: + SshClient(self.ip, self.port, self.user, self.passwd).execute( + "rm -f '%s'" % remote_path) + except Exception as e: + self.logger.warning("Could not remove %s: %s", remote_path, e) + + +# --------------------------------------------------------------------------- +# KvmHostDeployer – deploys network-namespace-wrapper.sh to KVM hosts +# --------------------------------------------------------------------------- + +class KvmHostDeployer: + """Copies the KVM wrapper script to all KVM hosts via SSH. + + *dest_path* is the absolute path on each KVM host where the wrapper + script is installed. This path differs from the management-server + entry-point path: + management server: /usr/share/cloudstack-management/extensions//.sh + KVM host (wrapper): /etc/cloudstack/extensions//-wrapper.sh + """ + + def __init__(self, config_hosts=None, logger=None, dest_path=None): + self.config_hosts = config_hosts or [] + self.logger = logger or logging.getLogger('KvmHostDeployer') + self._deployed_hosts = [] + self._dest_path = dest_path + + def deploy(self): + """Deploy wrapper to all configured hosts. Returns list of deployed IPs.""" + wrapper_path, _ = _ensure_scripts_downloaded() + self._deployed_hosts = [] + if not self.config_hosts: + self.logger.warning("No KVM hosts configured — wrapper not deployed") + return [] + for h in self.config_hosts: + ip = h.get('ip', '') + username = h.get('username', 'root') + password = h.get('password', '') + if not ip: + continue + self.logger.info("Deploying wrapper to KVM host %s at %s", + ip, self._dest_path) + try: + _ssh_copy_file(ip, 22, username, password, + wrapper_path, self._dest_path) + self.logger.info("Deployed wrapper to %s at %s", + ip, self._dest_path) + self._deployed_hosts.append(ip) + except Exception as e: + self.logger.warning("Failed deploying to %s: %s", ip, e) + return self._deployed_hosts + + def host_ips_csv(self): + """Return comma-separated IP list of all configured hosts.""" + return ','.join(h.get('ip', '') for h in self.config_hosts if h.get('ip')) + + def remove_file(self, remote_path): + """Remove wrapper script from all deployed KVM hosts.""" + if not self._deployed_hosts: + self.logger.debug("No KVM hosts deployed — skipping remove_file") + return + for h in self.config_hosts: + ip = h.get('ip', '') + username = h.get('username', 'root') + password = h.get('password', '') + if not ip or ip not in self._deployed_hosts: + continue + try: + SshClient(ip, 22, username, password).execute( + "rm -f '%s'" % remote_path) + self.logger.info("Removed %s from KVM host %s", remote_path, ip) + except Exception as e: + self.logger.warning("Could not remove %s from %s: %s", + remote_path, ip, e) + + +# --------------------------------------------------------------------------- +# Test class +# --------------------------------------------------------------------------- + +class TestNetworkExtensionNamespace(cloudstackTestCase): + """Smoke tests for the NetworkExtension plugin. + + Not discovered directly — exposed as ``TestNetworkExtensionNamespace`` + through ``test_network_extension_namespace.py``. + + Covers: + test_01 — NSP state transitions (Disabled/Enabled/Disabled) + test_02 — network.services / network.service.capabilities details stored correctly + test_03 — extension enable/disable and delete restriction + test_04 — DHCP/DNS/UserData: cloud-init VM on a shared network reaches Running state + test_05 — full isolated lifecycle: static NAT, PF, LB, restart + (all with SSH connectivity verification via keypair) + test_06 — VPC multi-tier + VPC restart with SSH verification + test_07 — VPC Network ACL testing with multiple tiers and traffic rules + test_08 — custom-action smoke for Policy-Based Routing (PBR) actions + test_09 — VPC source NAT IP update without VPC restart + """ + + @staticmethod + def _custom_action_details(resp): + """Extract best-effort details text from runCustomAction response.""" + if resp is None: + return "" + result = getattr(resp, 'result', None) + if isinstance(result, dict): + return result.get('details', '') or '' + if hasattr(result, '__dict__'): + return getattr(result, 'details', '') or '' + return '' + + @classmethod + def setUpClass(cls): + testClient = super(TestNetworkExtensionNamespace, cls).getClsTestClient() + cls.apiclient = testClient.getApiClient() + cls.services = testClient.getParsedTestDataConfig() + cls.zone = get_zone(cls.apiclient, testClient.getZoneForTests()) + cls.domain = get_domain(cls.apiclient) + cls.mgtSvrDetails = cls.config.__dict__["mgtSvr"][0].__dict__ + # All management servers — entry-point script is deployed to every one. + cls.all_mgt_svr_details = [ + mgt.__dict__ if hasattr(mgt, '__dict__') else mgt + for mgt in cls.config.__dict__.get("mgtSvr", []) + ] or [cls.mgtSvrDetails] + cls.hv = testClient.getHypervisorInfo() + cls._cleanup = [] + cls.tmp_files = [] + cls.keypair = None + + cls.logger = logging.getLogger("TestNetworkExtensionNamespace") + cls.stream_handler = logging.StreamHandler() + cls.logger.setLevel(logging.DEBUG) + cls.logger.addHandler(cls.stream_handler) + + cls.logger.info("Management servers (from config): %s", + [m.get("mgtSvrIp", "?") for m in cls.all_mgt_svr_details]) + + # Supplement / override management server list via listManagementServers API. + # All mgmt servers are assumed to share the same SSH credentials as the + # first entry in the Marvin config (mgtSvr[0]). + try: + ms_cmd = listManagementServers.listManagementServersCmd() + api_mgmt_servers = cls.apiclient.listManagementServers(ms_cmd) + if api_mgmt_servers: + base_creds = dict(cls.mgtSvrDetails) + api_mgt_details = [] + for ms in api_mgmt_servers: + ip = (getattr(ms, 'ipaddress', None) + or getattr(ms, 'ip', None) + or getattr(ms, 'hostname', None)) + if ip: + entry = dict(base_creds) + entry['mgtSvrIp'] = ip + api_mgt_details.append(entry) + if api_mgt_details: + cls.all_mgt_svr_details = api_mgt_details + cls.logger.info( + "Management servers (from listManagementServers API): %s", + [d.get('mgtSvrIp') for d in api_mgt_details]) + except Exception as _ms_err: + cls.logger.warning( + "Could not retrieve management servers via listManagementServers " + "API (%s); using config-provided list", _ms_err) + + # KVM host credentials from Marvin config + cls.kvm_host_configs = _get_kvm_hosts_from_config(cls.config) + cls.logger.info("KVM hosts from config: %s", + [h['ip'] for h in cls.kvm_host_configs if h.get('ip')]) + + # ---- Cloud-init template (Ubuntu 22.04) ---- + # Used for hardware tests that verify actual SSH connectivity. + # Falls back to the default test template when the cloud-init entry + # is absent from the services config (e.g. on simulator). + try: + tpl_data = (cls.services + .get("test_templates_cloud_init", {}) + .get(cls.hv.lower())) + if tpl_data: + cls.logger.info("Registering cloud-init template for %s", cls.hv) + tpl = Template.register( + cls.apiclient, + tpl_data, + zoneid=cls.zone.id, + hypervisor=cls.hv, + ) + tpl.download(cls.apiclient) + cls._cleanup.append(tpl) + cls.template = tpl + cls.logger.info("Cloud-init template registered: %s", tpl.id) + else: + cls.logger.info("No cloud-init template for %s; using default", + cls.hv) + cls.template = get_template(cls.apiclient, cls.zone.id, cls.hv) + except Exception as e: + cls.logger.warning("Cloud-init template registration failed: %s; " + "falling back to default", e) + cls.template = get_template(cls.apiclient, cls.zone.id, cls.hv) + + # ---- Download wrapper scripts from GitHub ---- + try: + _ensure_scripts_downloaded() + cls.logger.info("Scripts cached: %s %s", + WRAPPER_SCRIPT_LOCAL, ENTRY_POINT_SCRIPT_LOCAL) + except Exception as e: + cls.logger.warning("Could not download scripts from GitHub: %s", e) + + @classmethod + def tearDownClass(cls): + super(TestNetworkExtensionNamespace, cls).tearDownClass() + for tmp_file in cls.tmp_files: + try: + os.remove(tmp_file) + except Exception: + pass + + def setUp(self): + self.cleanup = [] + self.provider_id = None + self.physical_network = None + self.extension = None + self.extension_path = None + self.mgmt_deployer = None + self._mgmt_script_path = None + self._all_mgmt_deployers = [] + self.kvm_deployer = None + self._ssh_private_key_file = None + + def tearDown(self): + #self._safe_teardown() + try: + cleanup_resources(self.apiclient, self.cleanup) + except Exception as e: + self.logger.warning("cleanup_resources error: %s", e) + + # ------------------------------------------------------------------ + # CloudStack API helpers + # ------------------------------------------------------------------ + + def _get_physical_network(self): + """Return the physical network with Guest traffic type in the test zone.""" + cmd = listPhysicalNetworks.listPhysicalNetworksCmd() + cmd.zoneid = self.zone.id + pns = self.apiclient.listPhysicalNetworks(cmd) + self.assertIsInstance(pns, list) + self.assertGreater(len(pns), 0) + + for pn in pns: + tt_cmd = listTrafficTypes.listTrafficTypesCmd() + tt_cmd.physicalnetworkid = pn.id + traffic_types = self.apiclient.listTrafficTypes(tt_cmd) + if traffic_types: + for tt in traffic_types: + if getattr(tt, 'traffictype', '').lower() == 'guest': + self.logger.info( + "Selected physical network with Guest traffic: " + "%s (%s)", pn.name, pn.id) + return pn + + self.logger.info("No physical network with Guest traffic found; " + "using first: %s (%s)", pns[0].name, pns[0].id) + return pns[0] + + def _find_provider(self, phys_net_id, name): + cmd = listNetworkServiceProviders.listNetworkServiceProvidersCmd() + cmd.physicalnetworkid = phys_net_id + cmd.name = name + providers = self.apiclient.listNetworkServiceProviders(cmd) + return providers[0] if isinstance(providers, list) and providers else None + + def _update_provider_state(self, provider_id, state): + cmd = updateNetworkServiceProvider.updateNetworkServiceProviderCmd() + cmd.id = provider_id + cmd.state = state + return self.apiclient.updateNetworkServiceProvider(cmd) + + def _delete_provider(self, provider_id): + cmd = deleteNetworkServiceProvider.deleteNetworkServiceProviderCmd() + cmd.id = provider_id + self.apiclient.deleteNetworkServiceProvider(cmd) + + # ------------------------------------------------------------------ + # Script deployment helpers + # ------------------------------------------------------------------ + + def _deploy_scripts(self): + """Deploy scripts to all management servers and all KVM hosts. + + The entry-point (network-namespace.sh) is deployed to every management + server at ``self.extension_path`` (the path CloudStack assigned to the + extension, e.g. + ``/usr/share/cloudstack-management/extensions//.sh``). + + The KVM wrapper (network-namespace-wrapper.sh) is deployed to each KVM + host at a *different* path derived from the extension name: + ``/etc/cloudstack/extensions//-wrapper.sh`` + + The entry-point script uses the same derivation at runtime (see + DEFAULT_SCRIPT_PATH in network-namespace.sh) so that it always calls + the correct wrapper on the remote KVM host. + """ + wrapper_src, entry_point_src = _ensure_scripts_downloaded() + + self._mgmt_script_path = (self.extension_path or "").strip().rstrip('/') + + # Deploy entry-point to ALL management servers. + # all_mgt_svr_details is collected once in setUpClass from + # cls.config.__dict__["mgtSvr"] so every server in a HA pair is covered. + self._all_mgmt_deployers = [] + for mgt_details in self.all_mgt_svr_details: + deployer = MgmtServerDeployer(mgt_details, logger=self.logger) + deployer.copy_file(entry_point_src, self._mgmt_script_path) + self.logger.info("Entry-point deployed to mgmt %s at %s", + mgt_details.get("mgtSvrIp", "?"), + self._mgmt_script_path) + self._all_mgmt_deployers.append(deployer) + + # Keep mgmt_deployer pointing at the primary server for backward-compat + self.mgmt_deployer = (self._all_mgmt_deployers[0] + if self._all_mgmt_deployers + else MgmtServerDeployer(self.mgtSvrDetails, + logger=self.logger)) + + # Derive the wrapper destination path on KVM hosts from the extension path: + # mgmt: .../extensions//.sh + # kvm: /etc/cloudstack/extensions//-wrapper.sh + ext_dir_name = os.path.basename(os.path.dirname(self._mgmt_script_path)) + script_basename = os.path.splitext( + os.path.basename(self._mgmt_script_path))[0] + kvm_wrapper_path = "/etc/cloudstack/extensions/%s/%s-wrapper.sh" % ( + ext_dir_name, script_basename) + + self.kvm_deployer = KvmHostDeployer( + config_hosts=self.kvm_host_configs, + logger=self.logger, + dest_path=kvm_wrapper_path, + ) + deployed = self.kvm_deployer.deploy() + self.logger.info( + "KVM wrapper deployed to %d host(s) at %s: %s", + len(deployed), kvm_wrapper_path, deployed) + + def _cleanup_mgmt_script(self): + if self._mgmt_script_path: + all_deployers = getattr(self, '_all_mgmt_deployers', None) or [] + if all_deployers: + for deployer in all_deployers: + deployer.remove_file(self._mgmt_script_path) + elif self.mgmt_deployer: + self.mgmt_deployer.remove_file(self._mgmt_script_path) + self._mgmt_script_path = None + + def _cleanup_kvm_script(self): + """Remove KVM wrapper script from all deployed KVM hosts.""" + if self.kvm_deployer and self.kvm_deployer._dest_path: + self.kvm_deployer.remove_file(self.kvm_deployer._dest_path) + + # ------------------------------------------------------------------ + # Teardown helper + # ------------------------------------------------------------------ + + def _safe_teardown(self): + """Best-effort cleanup of extension/NSP/scripts.""" + if self.extension and self.physical_network: + try: + self.extension.unregister(self.apiclient, + self.physical_network.id, + 'PhysicalNetwork') + except Exception: + pass + if self.provider_id: + for fn in (lambda: self._update_provider_state(self.provider_id, 'Disabled'), + lambda: self._delete_provider(self.provider_id)): + try: + fn() + except Exception: + pass + self.provider_id = None + if self.extension: + try: + self.extension.delete(self.apiclient, + unregisterresources=False, + removeactions=False) + except Exception: + pass + self.extension = None + self._cleanup_mgmt_script() + self._cleanup_kvm_script() + + # ------------------------------------------------------------------ + # SSH helpers (provider-agnostic — no KVM namespace checks) + # ------------------------------------------------------------------ + + def _verify_vm_ssh_access(self, ip, port=22, timeout=30, retries=10): + """Return True if SSH to *ip*:*port* succeeds using the active keypair. + + The VM is expected to run Ubuntu 22.04 (cloud-init) with username + ``ubuntu``. Returns False if no keypair is available or if the + connection fails. + """ + key_file = self._ssh_private_key_file + if not key_file and self.keypair: + key_file = getattr(self.keypair, 'private_key_file', None) + + if not key_file: + self.logger.warning("No SSH keypair available; returning False") + return False + try: + ssh = SshClient( + ip, int(port), "ubuntu", None, + keyPairFiles=key_file, + timeout=timeout, + retries=retries, + ) + out = ssh.execute("echo EXTNET_SSH_OK") + return any("EXTNET_SSH_OK" in line for line in out) + except Exception as e: + self.logger.warning("SSH to %s:%s failed: %s", ip, port, e) + return False + + def _assert_vm_ssh_accessible(self, ip, port=22, msg=None): + """Assert that SSH to *ip*:*port* succeeds.""" + result = self._verify_vm_ssh_access(ip, port) + self.assertTrue( + result, + msg or "SSH to %s:%s should be accessible" % (ip, port)) + + def _assert_vm_ssh_not_accessible(self, ip, port=22, msg=None): + """Assert that SSH to *ip*:*port* fails (uses short timeout).""" + result = self._verify_vm_ssh_access(ip, port, timeout=5, retries=1) + self.assertFalse( + result, + msg or "SSH to %s:%s should NOT be accessible" % (ip, port)) + + def _create_firewall_rule_for_ssh(self, ipaddressid): + """Create an ingress TCP/22 firewall rule on *ipaddressid*. + + Returns the created rule ID. + """ + cmd = createFirewallRule.createFirewallRuleCmd() + cmd.ipaddressid = ipaddressid + cmd.protocol = 'TCP' + cmd.startport = 22 + cmd.endport = 22 + cmd.cidrlist = ['0.0.0.0/0'] + rule = self.apiclient.createFirewallRule(cmd) + self.assertIsNotNone(rule, "createFirewallRule returned None") + self.logger.info("FW rule (TCP/22) created: id=%s on ipaddressid=%s", + rule.id, ipaddressid) + return rule.id + + def _delete_firewall_rule(self, fw_rule_id): + """Delete a firewall rule by ID (best-effort, warns on failure).""" + if not fw_rule_id: + return + cmd = deleteFirewallRule.deleteFirewallRuleCmd() + cmd.id = fw_rule_id + try: + self.apiclient.deleteFirewallRule(cmd) + self.logger.info("FW rule %s deleted", fw_rule_id) + except Exception as e: + self.logger.warning("Could not delete FW rule %s: %s", fw_rule_id, e) + + def _get_source_nat_ip(self, network_id): + """Return the source NAT public IP object for *network_id*, or None.""" + cmd = listPublicIpAddresses.listPublicIpAddressesCmd() + cmd.networkid = network_id + cmd.issourcenat = True + try: + result = self.apiclient.listPublicIpAddresses(cmd) + if isinstance(result, list) and result: + return result[0] + except Exception as e: + self.logger.warning("_get_source_nat_ip(%s): %s", network_id, e) + return None + + def _list_vpc_public_ips(self, vpc_id): + """Return all public IP objects associated with *vpc_id*.""" + cmd = listPublicIpAddresses.listPublicIpAddressesCmd() + cmd.vpcid = vpc_id + cmd.listall = True + try: + result = self.apiclient.listPublicIpAddresses(cmd) + if isinstance(result, list): + return result + except Exception as e: + self.logger.warning("_list_vpc_public_ips(%s): %s", vpc_id, e) + return [] + + def _wait_for_vpc_source_nat_ip(self, vpc_id, expected_ip=None, + retries=24, interval=5): + """Wait until one source-NAT IP exists for the VPC (and optionally matches expected_ip).""" + for _ in range(retries): + ips = self._list_vpc_public_ips(vpc_id) + src = [ip for ip in ips if getattr(ip, 'issourcenat', False)] + if len(src) == 1: + current_ip = getattr(src[0], 'ipaddress', None) + if expected_ip is None or current_ip == expected_ip: + return src[0] + time.sleep(interval) + return None + + # ------------------------------------------------------------------ + # KVM host prerequisite check (test_04 / test_05 / test_06) + # ------------------------------------------------------------------ + + def _check_kvm_host_prerequisites(self, tools=None): + """Verify that each configured KVM host has the required tools installed. + + Checks for the presence of every tool in *tools* (default: + ``['arping', 'dnsmasq', 'haproxy']``) on each host in + ``self.kvm_host_configs`` via SSH. The test is skipped (via + ``skipTest``) if any tool is absent from any reachable host. + + Hosts that cannot be reached over SSH are logged as warnings and + excluded from the check — the connectivity failure will surface + naturally when the test later tries to deploy scripts. + """ + if tools is None: + tools = ['arping', 'dnsmasq', 'haproxy'] + if not self.kvm_host_configs: + self.skipTest("No KVM hosts configured — skipping prerequisite check") + + missing_per_host = {} + for h in self.kvm_host_configs: + ip = h.get('ip', '') + if not ip: + continue + username = h.get('username', 'root') + password = h.get('password', '') + try: + ssh = SshClient(ip, 22, username, password) + missing_tools = [] + for tool in tools: + # Try both `command -v` (bash built-in) and `which` + out = ssh.execute( + "command -v {t} 2>/dev/null || which {t} 2>/dev/null" + " || echo MISSING_{t}".format(t=tool)) + found = any( + line.strip() and 'MISSING_' + tool not in line + for line in out + ) + if not found: + missing_tools.append(tool) + if missing_tools: + missing_per_host[ip] = missing_tools + self.logger.warning( + "KVM host %s is missing prerequisite(s): %s", + ip, ', '.join(missing_tools)) + else: + self.logger.info( + "KVM host %s: all prerequisites present (%s)", + ip, ', '.join(tools)) + except Exception as e: + self.logger.warning( + "Could not check prerequisites on KVM host %s: %s", ip, e) + + if missing_per_host: + detail = "; ".join( + "%s missing %s" % (ip, ', '.join(t)) + for ip, t in missing_per_host.items() + ) + self.skipTest( + "Skipping test — required tools not installed on KVM host(s): " + + detail) + + # ------------------------------------------------------------------ + # Extension + NSP + offering setup helper (shared by tests 04-06) + # ------------------------------------------------------------------ + + def _setup_extension_nsp_offering(self, ext_name_prefix, + supported_services=None, + guestiptype="Isolated", + for_vpc=False): + """Create extension, deploy scripts, register to physical network, + enable NSP, and optionally create a NetworkOffering. + + *supported_services* is a comma-separated list of CloudStack service names. + + *guestiptype* controls the guest IP type for the NetworkOffering: + ``"Isolated"`` (default) or ``"Shared"``. + + *for_vpc* — when ``True`` the NetworkOffering creation step is skipped. + VPC tests create their own VPC tier offering in the test body after this + helper returns, so creating a generic isolated offering here would be + wasteful and misleading. ``(None, ext_name)`` is returned in that case. + + Sets ``self.physical_network``, ``self.extension``, + ``self.extension_path``, ``self.provider_id``, + ``self.kvm_deployer``, ``self.mgmt_deployer``. + + Returns ``(nw_offering, ext_name)``. *nw_offering* is ``None`` when + *for_vpc* is ``True``. Skips when no KVM hosts are available. + """ + _svc = supported_services + self.physical_network = self._get_physical_network() + + ext_name = "%s-%s" % (ext_name_prefix, random_gen()) + self.extension = Extension.create( + self.apiclient, + name=ext_name, + type='NetworkOrchestrator', + details=[ + {"network.services": NETWORK_SERVICES}, + {"network.service.capabilities": NETWORK_SERVICE_CAPABILITIES_JSON}, + ] + ) + self.assertIsNotNone(self.extension) + self.assertEqual('Enabled', self.extension.state) + + ext_list = Extension.list(self.apiclient, id=self.extension.id) + self.assertTrue(ext_list and len(ext_list) > 0) + self.extension_path = ext_list[0].path + self.assertIsNotNone(self.extension_path) + self.logger.info("Extension '%s' created, path=%s", + ext_name, self.extension_path) + + # Deploy scripts + self._deploy_scripts() + kvm_hosts_csv = self.kvm_deployer.host_ips_csv() + if not kvm_hosts_csv: + self.skipTest("No KVM hosts available — skipping") + + # Register extension to physical network + register_details = [ + {"hosts": kvm_hosts_csv}, + {"username": self.kvm_host_configs[0].get('username', 'root')}, + {"password": self.kvm_host_configs[0].get('password', '')}, + ] + + self.extension.register( + self.apiclient, + self.physical_network.id, + 'PhysicalNetwork', + details=register_details + ) + self.logger.info("Extension registered, hosts=%s", kvm_hosts_csv) + + # Enable NSP + provider = self._find_provider(self.physical_network.id, ext_name) + self.assertIsNotNone(provider, + "NSP '%s' not found after registration" % ext_name) + self.provider_id = provider.id + if provider.state != 'Enabled': + self._update_provider_state(provider.id, 'Enabled') + self.assertEqual('Enabled', + self._find_provider(self.physical_network.id, + ext_name).state) + self.logger.info("NSP '%s' enabled", ext_name) + + # Create NetworkOffering — skipped for VPC tests because the caller + # creates a VPC tier offering separately after this helper returns. + if for_vpc: + self.logger.info( + "for_vpc=True: skipping isolated NetworkOffering creation " + "(VPC tier offering will be created in the test body)") + return None, ext_name + + _provider_map = {s.strip(): ext_name for s in _svc.split(',')} + offering_params = { + "name": "ExtNet-Offering-%s" % random_gen(), + "displaytext": "ExtNet test offering", + "guestiptype": guestiptype, + "traffictype": "GUEST", + "supportedservices": _svc, + "serviceProviderList": _provider_map, + } + if guestiptype == "Shared": + # CloudStack requires shared guest offerings to explicitly allow + # caller-specified IP ranges. + offering_params["specifyIpRanges"] = True + if guestiptype == "Isolated" and "SourceNat" in _svc: + offering_params["serviceCapabilityList"] = { + "SourceNat": {"SupportedSourceNatTypes": "peraccount"}, + } + nw_offering = NetworkOffering.create(self.apiclient, offering_params) + self.cleanup.append(nw_offering) + nw_offering.update(self.apiclient, state='Enabled') + self.logger.info("NetworkOffering '%s' enabled (services: %s)", + nw_offering.name, _svc) + + return nw_offering, ext_name + + def _create_account_keypair(self, account, name_suffix=""): + """Create an SSH keypair scoped to *account* and save private key file.""" + try: + kp_name = "extnet-%s-%s" % (name_suffix or random_gen(), random_gen()) + kp = SSHKeyPair.create( + self.apiclient, + name=kp_name, + account=account.name, + domainid=account.domainid, + ) + + pkfile = os.path.join(tempfile.gettempdir(), kp.name) + with open(pkfile, "w+") as fh: + fh.write(kp.privatekey) + os.chmod(pkfile, 0o400) + + self.tmp_files.append(pkfile) + kp.private_key_file = pkfile + self._ssh_private_key_file = pkfile + self.logger.info("Account keypair '%s' written to %s", kp.name, pkfile) + return kp + except Exception as e: + self.logger.warning("Could not create account keypair: %s", e) + return None + + def _create_account_network_vm(self, nw_offering, name_suffix="", + network_params=None): + """Create an account, an isolated network, and deploy a cloud-init VM. + + The VM is deployed with an account-scoped SSH keypair so that SSH + access can be tested directly. Username is ``ubuntu``. + + Returns ``(account, network, vm)``. + """ + suffix = name_suffix or random_gen() + + account = Account.create( + self.apiclient, + self.services["account"], + admin=True, + domainid=self.domain.id + ) + self.cleanup.append(account) + + net_params = { + "name": "extnet-net-%s" % suffix, + "displaytext": "ExtNet test network %s" % suffix, + } + if network_params: + net_params.update(network_params) + + network = Network.create( + self.apiclient, + net_params, + accountid=account.name, + domainid=account.domainid, + networkofferingid=nw_offering.id, + zoneid=self.zone.id + ) + self.cleanup.insert(0, network) + self.assertIsNotNone(network) + self.logger.info("Network created: %s (%s)", network.name, network.id) + + svc_offering = ServiceOffering.list(self.apiclient, issystem=False)[0] + + vm_cfg = { + "displayname": "extnet-vm-%s" % suffix, + "name": "extnet-vm-%s" % suffix, + "zoneid": self.zone.id, + } + vm_kwargs = dict( + accountid=account.name, + domainid=account.domainid, + serviceofferingid=svc_offering.id, + templateid=self.template.id, + networkids=[network.id], + ) + account_keypair = self._create_account_keypair(account, suffix) + if account_keypair: + vm_kwargs["keypair"] = account_keypair.name + + vm = VirtualMachine.create(self.apiclient, vm_cfg, **vm_kwargs) + self.cleanup.insert(0, vm) + self.assertIsNotNone(vm) + self.logger.info("VM deployed: %s (%s)", vm.name, vm.id) + + return account, network, vm + + def _teardown_extension(self): + """Ordered teardown: disable NSP → delete provider → unregister + extension → delete extension → remove mgmt script.""" + self._update_provider_state(self.provider_id, 'Disabled') + self._delete_provider(self.provider_id) + self.provider_id = None + + self.extension.unregister(self.apiclient, + self.physical_network.id, 'PhysicalNetwork') + self.extension.delete(self.apiclient, + unregisterresources=False, removeactions=False) + self.extension = None + self.physical_network = None + self._cleanup_mgmt_script() + self._cleanup_kvm_script() + + # ------------------------------------------------------------------ + # Tests — API-only (no KVM / no SSH) + # ------------------------------------------------------------------ + + @attr(tags=["advanced", "smoke"], required_hardware="false") + def test_01_provider_state_transitions(self): + """NSP state machine: Disabled → Enabled → Disabled → Deleted.""" + pn = self._get_physical_network() + self.physical_network = pn + + ext_name = "extnet-nsp-" + random_gen() + self.extension = Extension.create( + self.apiclient, + name=ext_name, + type='NetworkOrchestrator', + details=[ + {"network.services": NETWORK_SERVICES}, + {"network.service.capabilities": NETWORK_SERVICE_CAPABILITIES_JSON}, + ] + ) + self.extension.register(self.apiclient, pn.id, 'PhysicalNetwork') + + provider = self._find_provider(pn.id, ext_name) + self.assertIsNotNone(provider) + self.provider_id = provider.id + + # Normalise to Disabled first + if provider.state == 'Enabled': + self._update_provider_state(provider.id, 'Disabled') + self.assertEqual('Disabled', self._find_provider(pn.id, ext_name).state) + + self._update_provider_state(provider.id, 'Enabled') + self.assertEqual('Enabled', self._find_provider(pn.id, ext_name).state) + self.logger.info("NSP enabled OK") + + self._update_provider_state(provider.id, 'Disabled') + self.assertEqual('Disabled', self._find_provider(pn.id, ext_name).state) + self.logger.info("NSP disabled OK") + + self._teardown_extension() + self.logger.info("test_01 PASSED") + + @attr(tags=["advanced", "smoke"], required_hardware="false") + def test_02_extension_capabilities_detail(self): + """Verify network.services and network.service.capabilities details are stored and retrievable via API.""" + svc_caps_json = json.dumps({ + "SourceNat": {"SupportedSourceNatTypes": "peraccount"} + }) + ext = Extension.create( + self.apiclient, + name="extnet-caps-" + random_gen(), + type='NetworkOrchestrator', + details=[ + {"network.services": "SourceNat"}, + {"network.service.capabilities": svc_caps_json}, + ] + ) + self.cleanup.append(ext) + ext_list = Extension.list(self.apiclient, id=ext.id) + self.assertTrue(ext_list and len(ext_list) > 0) + ext_obj = ext_list[0] + if hasattr(ext_obj, 'details') and ext_obj.details: + d = (ext_obj.details.__dict__ + if not isinstance(ext_obj.details, dict) + else ext_obj.details) + self.assertIn("network.services", d) + self.assertIn("SourceNat", d["network.services"].split(",")) + self.assertIn("network.service.capabilities", d) + stored_caps = json.loads(d["network.service.capabilities"]) + self.assertIn("SourceNat", stored_caps) + self.logger.info("test_02 PASSED") + + @attr(tags=["advanced", "smoke"], required_hardware="false") + def test_03_extension_enable_disable_and_delete_restriction(self): + """Extension enable/disable; deletion blocked while registered.""" + pn = self._get_physical_network() + self.physical_network = pn + + ext_name = "extnet-lifecycle-" + random_gen() + self.extension = Extension.create( + self.apiclient, + name=ext_name, + type='NetworkOrchestrator', + details=[ + {"network.services": NETWORK_SERVICES}, + {"network.service.capabilities": NETWORK_SERVICE_CAPABILITIES_JSON}, + ] + ) + self.assertIsNotNone(self.extension) + self.assertEqual('Enabled', self.extension.state, + "Extension should be Enabled by default") + + self.extension.update(self.apiclient, state='Disabled') + ext_list = Extension.list(self.apiclient, id=self.extension.id) + self.assertEqual('Disabled', ext_list[0].state) + self.logger.info("Extension disabled OK") + + self.extension.update(self.apiclient, state='Enabled') + ext_list = Extension.list(self.apiclient, id=self.extension.id) + self.assertEqual('Enabled', ext_list[0].state) + self.logger.info("Extension re-enabled OK") + + self.extension.register(self.apiclient, pn.id, 'PhysicalNetwork') + self.logger.info("Extension registered to physical network %s", pn.id) + + # Deletion while registered must fail + try: + self.extension.delete(self.apiclient, + unregisterresources=False, removeactions=False) + self.fail("Expected error when deleting extension while registered") + except Exception as e: + self.logger.info("Expected error when deleting while registered: %s", + e) + + self.extension.unregister(self.apiclient, pn.id, 'PhysicalNetwork') + self.extension.delete(self.apiclient, + unregisterresources=False, removeactions=False) + self.extension = None + self.physical_network = None + self.logger.info("test_03 PASSED") + + # ------------------------------------------------------------------ + # Tests — hardware required + # ------------------------------------------------------------------ + + @attr(tags=["advanced", "smoke"], required_hardware="true") + def test_04_dhcp_dns_userdata(self): + """DHCP / DNS / UserData: cloud-init VM on a shared network reaches Running state. + + Creates a shared network with the extension providing Dhcp, Dns, + and UserData services. Deploys a VM with the cloud-init template. + Verifies the VM reaches Running state, which implies it received a + DHCP address from the extension. No SSH verification is performed. + + Steps: + 1. Set up extension + NSP + offering (Dhcp, Dns, UserData) with + guestiptype=Shared. + 2. Create account + shared network + cloud-init VM. + 3. Assert VM state == Running. + 4. Teardown. + """ + self._check_kvm_host_prerequisites(['arping', 'dnsmasq', 'haproxy']) + svc = "Dhcp,Dns,UserData" + nw_offering, _ext_name = self._setup_extension_nsp_offering( + "extnet-dhcp", supported_services=svc, guestiptype="Shared") + + # Shared offerings with specifyIpRanges=True require explicit range. + third_octet = random.randint(32, 220) + shared_params = { + "gateway": "172.31.%d.1" % third_octet, + "netmask": "255.255.255.0", + "startip": "172.31.%d.10" % third_octet, + "endip": "172.31.%d.200" % third_octet, + } + + account, network, vm = self._create_account_network_vm( + nw_offering, name_suffix="dhcp", network_params=shared_params) + + # Verify VM is in Running state — DHCP must have worked + self.assertEqual( + 'Running', vm.state, + "VM should be in Running state after deploy (implies DHCP worked)") + self.logger.info("VM %s is Running — DHCP/DNS/UserData path exercised", + vm.name) + + # Cleanup + vm.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm] + network.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != network] + self._teardown_extension() + self.logger.info("test_04 PASSED") + + @attr(tags=["advanced", "smoke"], required_hardware="true") + def test_05_isolated_network_full_lifecycle(self): + """Full isolated-network lifecycle with SSH connectivity verification. + + Uses a single cloud-init VM (Ubuntu 22.04, SSH keypair, username + ``ubuntu``) throughout. + + Sub-tests in order + ------------------ + A. Static NAT + allocate IP → enable static NAT → create FW rule (TCP/22) + → assert SSH works → disable static NAT → assert SSH fails + B. Port forwarding (22→22) + allocate IP → create PF rule → create FW rule (TCP/22) + → assert SSH works → delete PF rule → assert SSH fails + C. Load balancer (haproxy, round-robin TCP/22) + allocate IP → create LB rule → assign VM → create FW rule + → assert SSH works → remove VM from LB → delete LB rule + D. Network restart + allocate IP → create PF rule → create FW rule + → assert SSH works (baseline) + → restartNetwork(cleanup=True) + → assert SSH works (namespace rebuilt, rules reapplied) + """ + self._check_kvm_host_prerequisites(['arping', 'dnsmasq', 'haproxy']) + # ---- Setup ---- + svc = "SourceNat,StaticNat,PortForwarding,Firewall,Lb,UserData,Dhcp,Dns" + nw_offering, _ext_name = self._setup_extension_nsp_offering( + "extnet-isolated", supported_services=svc) + account, network, vm = self._create_account_network_vm( + nw_offering, name_suffix="iso") + + # ============================================================== + # A. Static NAT + # + # StaticNATRule.enable() does NOT auto-create a firewall rule, so we + # must create one explicitly. StaticNATRule.disable() internally + # calls revokeFirewallRulesForIp(), which cascade-deletes our rule, + # so no explicit _delete_firewall_rule() is needed afterwards. + # ============================================================== + self.logger.info("--- Sub-test A: Static NAT ---") + snat_ip_obj = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=network.id + ) + snat_ip = snat_ip_obj.ipaddress.ipaddress + snat_ip_id = snat_ip_obj.ipaddress.id + + StaticNATRule.enable(self.apiclient, + ipaddressid=snat_ip_id, + virtualmachineid=vm.id, + networkid=network.id) + self.logger.info("Static NAT enabled on %s", snat_ip) + # Explicit FW rule required — static NAT does not auto-create one + self._create_firewall_rule_for_ssh(snat_ip_id) + + self._assert_vm_ssh_accessible( + snat_ip, 22, + "SSH via static NAT %s:22 should succeed" % snat_ip) + self.logger.info("Verified: SSH works via static NAT %s", snat_ip) + + # disable() cascades revokeFirewallRulesForIp() — deletes the FW rule + StaticNATRule.disable(self.apiclient, ipaddressid=snat_ip_id) + self.logger.info("Static NAT disabled on %s", snat_ip) + self._assert_vm_ssh_not_accessible( + snat_ip, 22, + "SSH via %s:22 should fail after static NAT disabled" % snat_ip) + self.logger.info("Verified: SSH fails after static NAT disabled") + snat_ip_obj.delete(self.apiclient) + + # ============================================================== + # B. Port forwarding + # + # NATRule.create() passes openFirewall=True (default for non-VPC), + # so CloudStack automatically creates a TCP/22 FW rule with + # relatedRuleId=pf_rule.id. pf_rule.delete() cascade-removes it. + # Do NOT call _create_firewall_rule_for_ssh() — that would conflict + # with the auto-created rule. + # ============================================================== + self.logger.info("--- Sub-test B: Port forwarding ---") + pf_ip_obj = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=network.id + ) + pf_ip = pf_ip_obj.ipaddress.ipaddress + pf_ip_id = pf_ip_obj.ipaddress.id + + pf_rule = NATRule.create( + self.apiclient, vm, + {"privateport": 22, "publicport": 22, "protocol": "TCP"}, + ipaddressid=pf_ip_id, + networkid=network.id + ) + self.assertIsNotNone(pf_rule) + self.logger.info("PF rule created: %s:22 → VM:22 (FW rule auto-created)", + pf_ip) + + self._assert_vm_ssh_accessible( + pf_ip, 22, + "SSH via PF %s:22 should succeed" % pf_ip) + self.logger.info("Verified: SSH works via port forwarding %s", pf_ip) + + # delete() cascades revokeRelatedFirewallRule() — removes auto FW rule + pf_rule.delete(self.apiclient) + self.logger.info("PF rule deleted on %s", pf_ip) + self._assert_vm_ssh_not_accessible( + pf_ip, 22, + "SSH via %s:22 should fail after PF rule deleted" % pf_ip) + self.logger.info("Verified: SSH fails after PF rule deleted") + pf_ip_obj.delete(self.apiclient) + + # ============================================================== + # C. Load balancer (haproxy) + # + # LoadBalancerRule.create() also uses openFirewall=True by default, + # auto-creating a TCP/22 FW rule. lb_rule.delete() cascades it. + # ============================================================== + self.logger.info("--- Sub-test C: Load balancer ---") + lb_ip_obj = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=network.id + ) + lb_ip = lb_ip_obj.ipaddress.ipaddress + lb_ip_id = lb_ip_obj.ipaddress.id + + lb_rule = LoadBalancerRule.create( + self.apiclient, + {"name": "lb-ssh-%s" % random_gen(), + "alg": "roundrobin", + "privateport": 22, + "publicport": 22}, + ipaddressid=lb_ip_id, + accountid=account.name, + networkid=network.id, + domainid=account.domainid + ) + self.assertIsNotNone(lb_rule) + lb_rule.assign(self.apiclient, vms=[vm]) + self.logger.info("LB rule created, VM assigned: %s:22 (FW rule auto-created)", + lb_ip) + + self._assert_vm_ssh_accessible( + lb_ip, 22, + "SSH via LB %s:22 should succeed (haproxy required on KVM hosts)" + % lb_ip) + self.logger.info("Verified: SSH works via haproxy LB %s", lb_ip) + + lb_rule.remove(self.apiclient, vms=[vm]) + lb_rule.delete(self.apiclient) + lb_ip_obj.delete(self.apiclient) + self.logger.info("LB rule deleted") + + # ============================================================== + # D. Network restart (cleanup=True) + # + # NATRule.create() auto-creates the TCP/22 FW rule. + # After network restart both the PF rule and the FW rule must be + # re-applied by the extension, so SSH should work again. + # ============================================================== + self.logger.info("--- Sub-test D: Network restart ---") + rst_ip_obj = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=network.id + ) + rst_ip = rst_ip_obj.ipaddress.ipaddress + rst_ip_id = rst_ip_obj.ipaddress.id + + rst_pf = NATRule.create( + self.apiclient, vm, + {"privateport": 22, "publicport": 22, "protocol": "TCP"}, + ipaddressid=rst_ip_id, + networkid=network.id + ) + self._assert_vm_ssh_accessible( + rst_ip, 22, + "SSH via %s:22 should work before restart" % rst_ip) + self.logger.info("Baseline SSH verified before restart") + + self.logger.info("Restarting network %s (cleanup=True) ...", network.id) + network.restart(self.apiclient, cleanup=True) + self.logger.info("Network restart completed") + + self._assert_vm_ssh_accessible( + rst_ip, 22, + "SSH via %s:22 should work after network restart" % rst_ip) + self.logger.info("Verified: SSH restored after restart") + + rst_pf.delete(self.apiclient) + rst_ip_obj.delete(self.apiclient) + + # ---- Final cleanup ---- + vm.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm] + network.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != network] + self._teardown_extension() + self.logger.info("test_05 PASSED") + + @attr(tags=["advanced", "smoke"], required_hardware="true") + def test_06_vpc_multi_tier_and_restart(self): + """VPC multi-tier + VPC restart with SSH connectivity verification. + + Creates two VPC tier networks backed by the extension, deploys a + VM in each tier, and verifies SSH access independently. + + Sub-tests in order + ------------------ + A. Baseline connectivity (before VPC restart) + tier-1 PF :22 → VM1 — assert SSH works + tier-2 LB :22 → VM2 — assert SSH works + tier-1 static NAT :22 → VM1 — enable, create FW rule, + assert SSH works + B. VPC restart (cleanup=True) + assert SSH still works via tier-1 PF, tier-2 LB, and + tier-1 static NAT after the namespace is rebuilt + C. Static NAT teardown (after VPC restart) + disable static NAT → assert SSH fails → delete IP + D. Partial delete + delete tier-1 VM + network → assert tier-2 VM still accessible + E. Final delete + delete tier-2 VM + network, VPC, teardown extension + + The VPC tier network offering uses ``useVpc=on`` as required by + CloudStack for VPC-associated tier networks. + """ + self._check_kvm_host_prerequisites(['arping', 'dnsmasq', 'haproxy']) + # ---- Setup: extension + NSP only (no isolated offering for VPC tests) ---- + svc = "SourceNat,StaticNat,PortForwarding,Lb,UserData,Dhcp,Dns,NetworkACL" + _nw_offering, ext_name = self._setup_extension_nsp_offering( + "extnet-vpc", supported_services=svc, for_vpc=True) + + # ---- VPC tier network offering (useVpc=on) ---- + vpc_tier_svc = "SourceNat,StaticNat,PortForwarding,Lb,UserData,Dhcp,Dns,NetworkACL" + _tier_prov = {s.strip(): ext_name for s in vpc_tier_svc.split(',')} + vpc_tier_offering = NetworkOffering.create(self.apiclient, { + "name": "ExtNet-VPCTier-%s" % random_gen(), + "displaytext": "ExtNet VPC tier offering", + "guestiptype": "Isolated", + "traffictype": "GUEST", + "availability": "Optional", + "useVpc": "on", + "supportedservices": vpc_tier_svc, + "serviceProviderList": _tier_prov, + "serviceCapabilityList": { + "SourceNat": {"SupportedSourceNatTypes": "peraccount"}, + }, + }) + self.cleanup.append(vpc_tier_offering) + vpc_tier_offering.update(self.apiclient, state='Enabled') + self.logger.info("VPC tier offering '%s' enabled", vpc_tier_offering.name) + + # ---- VPC offering ---- + vpc_svc = "SourceNat,StaticNat,PortForwarding,Lb,UserData,Dhcp,Dns,NetworkACL" + _vpc_prov = {s.strip(): ext_name for s in vpc_svc.split(',')} + vpc_offering = VpcOffering.create(self.apiclient, { + "name": "ExtNet-VPC-%s" % random_gen(), + "displaytext": "ExtNet VPC offering", + "supportedservices": vpc_svc, + "serviceProviderList": _vpc_prov, + }) + self.cleanup.append(vpc_offering) + vpc_offering.update(self.apiclient, state='Enabled') + self.logger.info("VPC offering '%s' enabled", vpc_offering.name) + + # ---- Account ---- + suffix = random_gen() + account = Account.create( + self.apiclient, + self.services["account"], + admin=True, + domainid=self.domain.id + ) + self.cleanup.append(account) + account_keypair = self._create_account_keypair(account, suffix) + + # ---- VPC ---- + vpc = VPC.create( + self.apiclient, + {"name": "extnet-vpc-%s" % suffix, + "displaytext": "ExtNet VPC %s" % suffix, + "cidr": "10.1.0.0/16"}, + vpcofferingid=vpc_offering.id, + zoneid=self.zone.id, + account=account.name, + domainid=account.domainid + ) + self.cleanup.insert(0, vpc) + self.logger.info("VPC created: %s (%s)", vpc.name, vpc.id) + + # ---- Tier 1 ---- + tier1 = Network.create( + self.apiclient, + {"name": "tier1-%s" % suffix, + "displaytext": "Tier 1 %s" % suffix}, + accountid=account.name, + domainid=account.domainid, + networkofferingid=vpc_tier_offering.id, + zoneid=self.zone.id, + vpcid=vpc.id, + gateway="10.1.1.1", + netmask="255.255.255.0" + ) + self.cleanup.insert(0, tier1) + self.logger.info("Tier 1 created: %s (%s)", tier1.name, tier1.id) + + # ---- Tier 2 ---- + tier2 = Network.create( + self.apiclient, + {"name": "tier2-%s" % suffix, + "displaytext": "Tier 2 %s" % suffix}, + accountid=account.name, + domainid=account.domainid, + networkofferingid=vpc_tier_offering.id, + zoneid=self.zone.id, + vpcid=vpc.id, + gateway="10.1.2.1", + netmask="255.255.255.0" + ) + self.cleanup.insert(0, tier2) + self.logger.info("Tier 2 created: %s (%s)", tier2.name, tier2.id) + + svc_offering = ServiceOffering.list(self.apiclient, issystem=False)[0] + + # ---- VM in tier 1 ---- + vm1_cfg = {"displayname": "vm1-%s" % suffix, + "name": "vm1-%s" % suffix, + "zoneid": self.zone.id} + vm1_kw = dict(accountid=account.name, + domainid=account.domainid, + serviceofferingid=svc_offering.id, + templateid=self.template.id, + networkids=[tier1.id]) + if account_keypair: + vm1_kw["keypair"] = account_keypair.name + vm1 = VirtualMachine.create(self.apiclient, vm1_cfg, **vm1_kw) + self.cleanup.insert(0, vm1) + self.logger.info("VM1 deployed in tier 1: %s (%s)", vm1.name, vm1.id) + + # ---- VM in tier 2 ---- + vm2_cfg = {"displayname": "vm2-%s" % suffix, + "name": "vm2-%s" % suffix, + "zoneid": self.zone.id} + vm2_kw = dict(accountid=account.name, + domainid=account.domainid, + serviceofferingid=svc_offering.id, + templateid=self.template.id, + networkids=[tier2.id]) + if account_keypair: + vm2_kw["keypair"] = account_keypair.name + vm2 = VirtualMachine.create(self.apiclient, vm2_cfg, **vm2_kw) + self.cleanup.insert(0, vm2) + self.logger.info("VM2 deployed in tier 2: %s (%s)", vm2.name, vm2.id) + + # ---- Tier 1: PF rule ---- + pf_ip1 = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=tier1.id, + vpcid=vpc.id + ) + pf_rule1 = NATRule.create( + self.apiclient, vm1, + {"privateport": 22, "publicport": 22, "protocol": "TCP"}, + ipaddressid=pf_ip1.ipaddress.id, + networkid=tier1.id + ) + tier1_pf_ip = pf_ip1.ipaddress.ipaddress + self.logger.info("Tier 1 PF: %s:22 → VM1:22", tier1_pf_ip) + + # ---- Tier 2: LB rule ---- + lb_ip2 = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=tier2.id, + vpcid=vpc.id + ) + lb_rule2 = LoadBalancerRule.create( + self.apiclient, + {"name": "vpc-lb-ssh-%s" % random_gen(), + "alg": "roundrobin", + "privateport": 22, + "publicport": 22}, + ipaddressid=lb_ip2.ipaddress.id, + accountid=account.name, + networkid=tier2.id, + domainid=account.domainid + ) + self.assertIsNotNone(lb_rule2) + lb_rule2.assign(self.apiclient, vms=[vm2]) + tier2_lb_ip = lb_ip2.ipaddress.ipaddress + self.logger.info("Tier 2 LB: %s:22 → VM2:22", tier2_lb_ip) + + # ---- Tier 1: Static NAT (allocated BEFORE restart) ---- + snat_ip1_obj = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=tier1.id, + vpcid=vpc.id + ) + snat_ip1 = snat_ip1_obj.ipaddress.ipaddress + snat_ip1_id = snat_ip1_obj.ipaddress.id + StaticNATRule.enable( + self.apiclient, + ipaddressid=snat_ip1_id, + virtualmachineid=vm1.id, + networkid=tier1.id + ) + self.logger.info("Static NAT enabled on tier-1: %s → VM1", snat_ip1) + + # ============================================================== + # A. Baseline connectivity — BEFORE VPC restart + # ============================================================== + self.logger.info("--- Sub-test A: Baseline connectivity (before restart) ---") + + self._assert_vm_ssh_accessible( + tier1_pf_ip, 22, + "SSH to tier-1 VM via PF (%s) should succeed before restart" + % tier1_pf_ip) + self.logger.info("Verified: SSH to tier-1 VM works via PF (before restart)") + + self._assert_vm_ssh_accessible( + tier2_lb_ip, 22, + "SSH to tier-2 VM via LB (%s) should succeed before restart" + % tier2_lb_ip) + self.logger.info("Verified: SSH to tier-2 VM works via LB (before restart)") + + self._assert_vm_ssh_accessible( + snat_ip1, 22, + "SSH to tier-1 VM via static NAT (%s) should succeed before restart" + % snat_ip1) + self.logger.info("Verified: SSH to tier-1 VM works via static NAT (before restart)") + + # ============================================================== + # B. VPC restart (cleanup=True) + # Re-verify all three access methods after the namespace is rebuilt. + # ============================================================== + self.logger.info("--- Sub-test B: VPC restart (cleanup=True) ---") + self.logger.info("Restarting VPC %s (cleanup=True) ...", vpc.id) + vpc.restart(self.apiclient, cleanup=True) + self.logger.info("VPC restart completed") + + self._assert_vm_ssh_accessible( + tier1_pf_ip, 22, + "SSH to tier-1 VM via PF must work after VPC restart") + self.logger.info("Verified: SSH to tier-1 VM works via PF (after restart)") + + self._assert_vm_ssh_accessible( + tier2_lb_ip, 22, + "SSH to tier-2 VM via LB must work after VPC restart") + self.logger.info("Verified: SSH to tier-2 VM works via LB (after restart)") + + self._assert_vm_ssh_accessible( + snat_ip1, 22, + "SSH to tier-1 VM via static NAT must work after VPC restart") + self.logger.info("Verified: SSH to tier-1 VM works via static NAT (after restart)") + + # ============================================================== + # C. Disable static NAT (after VPC restart) + # ============================================================== + self.logger.info("--- Sub-test C: Disable static NAT (after restart) ---") + StaticNATRule.disable(self.apiclient, ipaddressid=snat_ip1_id) + self.logger.info("Static NAT disabled on tier-1 %s", snat_ip1) + self._assert_vm_ssh_not_accessible( + snat_ip1, 22, + "SSH via tier-1 %s:22 should fail after static NAT disabled" % snat_ip1) + self.logger.info("Verified: SSH fails after static NAT disabled") + snat_ip1_obj.delete(self.apiclient) + + # ============================================================== + # D. Partial delete: tier-1 — tier-2 must remain accessible + # ============================================================== + self.logger.info("--- Sub-test D: Delete tier-1, verify tier-2 intact ---") + pf_rule1.delete(self.apiclient) + pf_ip1.delete(self.apiclient) + vm1.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm1] + tier1.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != tier1] + self.logger.info("Tier 1 VM + network deleted") + + # Tier 2 must remain accessible — cmd_destroy() on tier-1 must not + # delete tier-2's public veth (fixed via .tier ownership tracking). + self._assert_vm_ssh_accessible( + tier2_lb_ip, 22, + "SSH to tier-2 VM via LB must still work after tier-1 deleted") + self.logger.info("Verified: tier-2 VM still accessible via LB after tier-1 deleted") + + # ============================================================== + # E. Final delete: tier-2, VPC + # ============================================================== + lb_rule2.remove(self.apiclient, vms=[vm2]) + lb_rule2.delete(self.apiclient) + lb_ip2.delete(self.apiclient) + vm2.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm2] + tier2.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != tier2] + self.logger.info("Tier 2 VM + network deleted") + + vpc.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != vpc] + + self._teardown_extension() + self.logger.info("test_06 PASSED") + + @attr(tags=["advanced", "smoke"], required_hardware="true") + def test_07_vpc_network_acl(self): + """VPC Network ACL testing with multiple tiers and traffic rules. + + Creates two VPC tiers with distinct network ACL lists and verifies that + ACL rules are correctly applied: + - Inbound rules from public network (via port forwarding) + - Egress rules between VPC tiers (via ping) + + Sub-tests in order + ------------------ + A. Setup: Create two tiers with different ACL lists + tier1 (acl1): Allow ICMP from anywhere, Deny SSH + tier2 (acl2): Allow ICMP and SSH from anywhere + B. Deploy VMs and verify ACLs block SSH to tier1, allow ICMP to both tiers + C. Test inter-tier ICMP communication between VMs + D. Verify SSH only works on tier2 (where ACL permits it) + E. Cleanup + + The test uses ICMP (ping) to verify inter-tier connectivity and SSH + attempts to verify ACL rules are enforced on ingress traffic. + """ + self._check_kvm_host_prerequisites(['arping', 'dnsmasq', 'haproxy']) + + # ---- Setup: extension + NSP (supporting NetworkACL) ---- + svc = "SourceNat,StaticNat,PortForwarding,Lb,UserData,Dhcp,Dns,NetworkACL" + _nw_offering, ext_name = self._setup_extension_nsp_offering( + "extnet-acl", supported_services=svc, for_vpc=True) + + # ---- VPC tier network offering (useVpc=on, with NetworkACL support) ---- + vpc_tier_svc = "SourceNat,StaticNat,PortForwarding,Lb,UserData,Dhcp,Dns,NetworkACL" + _tier_prov = {s.strip(): ext_name for s in vpc_tier_svc.split(',')} + vpc_tier_offering = NetworkOffering.create(self.apiclient, { + "name": "ExtNet-VPCTier-ACL-%s" % random_gen(), + "displaytext": "ExtNet VPC tier offering with ACL", + "guestiptype": "Isolated", + "traffictype": "GUEST", + "availability": "Optional", + "useVpc": "on", + "supportedservices": vpc_tier_svc, + "serviceProviderList": _tier_prov, + "serviceCapabilityList": { + "SourceNat": {"SupportedSourceNatTypes": "peraccount"}, + }, + }) + self.cleanup.append(vpc_tier_offering) + vpc_tier_offering.update(self.apiclient, state='Enabled') + self.logger.info("VPC tier offering '%s' enabled", vpc_tier_offering.name) + + # ---- VPC offering ---- + vpc_svc = "SourceNat,StaticNat,PortForwarding,Lb,UserData,Dhcp,Dns,NetworkACL" + _vpc_prov = {s.strip(): ext_name for s in vpc_svc.split(',')} + vpc_offering = VpcOffering.create(self.apiclient, { + "name": "ExtNet-VPC-ACL-%s" % random_gen(), + "displaytext": "ExtNet VPC offering with ACL", + "supportedservices": vpc_svc, + "serviceProviderList": _vpc_prov, + }) + self.cleanup.append(vpc_offering) + vpc_offering.update(self.apiclient, state='Enabled') + self.logger.info("VPC offering '%s' enabled", vpc_offering.name) + + # ---- Account ---- + suffix = random_gen() + account = Account.create( + self.apiclient, + self.services["account"], + admin=True, + domainid=self.domain.id + ) + self.cleanup.append(account) + account_keypair = self._create_account_keypair(account, suffix) + + # ---- VPC ---- + vpc = VPC.create( + self.apiclient, + {"name": "extnet-vpc-acl-%s" % suffix, + "displaytext": "ExtNet VPC ACL %s" % suffix, + "cidr": "10.2.0.0/16"}, + vpcofferingid=vpc_offering.id, + zoneid=self.zone.id, + account=account.name, + domainid=account.domainid + ) + self.cleanup.insert(0, vpc) + self.logger.info("VPC created: %s (%s)", vpc.name, vpc.id) + + # ---- Network ACL Lists ---- + # ACL1: Restrict to ICMP only (deny SSH) + acl1 = NetworkACLList.create( + self.apiclient, + {"name": "acl1-icmp-only-%s" % suffix, + "description": "ACL1 for tier1 - ICMP only"}, + vpcid=vpc.id + ) + self.cleanup.insert(0, acl1) + self.logger.info("ACL1 created: %s (ICMP only)", acl1.id) + + # ACL2: Allow ICMP and SSH + acl2 = NetworkACLList.create( + self.apiclient, + {"name": "acl2-icmp-ssh-%s" % suffix, + "description": "ACL2 for tier2 - ICMP and SSH allowed"}, + vpcid=vpc.id + ) + self.cleanup.insert(0, acl2) + self.logger.info("ACL2 created: %s (ICMP and SSH)", acl2.id) + + # ---- Tier 1 with ACL1 (ICMP only) ---- + tier1 = Network.create( + self.apiclient, + {"name": "tier1-acl-%s" % suffix, + "displaytext": "Tier 1 ACL %s" % suffix}, + accountid=account.name, + domainid=account.domainid, + networkofferingid=vpc_tier_offering.id, + zoneid=self.zone.id, + vpcid=vpc.id, + gateway="10.2.1.1", + netmask="255.255.255.0" + ) + self.cleanup.insert(0, tier1) + self.logger.info("Tier 1 created: %s (%s)", tier1.name, tier1.id) + + # ---- Tier 2 with ACL2 (ICMP + SSH) ---- + tier2 = Network.create( + self.apiclient, + {"name": "tier2-acl-%s" % suffix, + "displaytext": "Tier 2 ACL %s" % suffix}, + accountid=account.name, + domainid=account.domainid, + networkofferingid=vpc_tier_offering.id, + zoneid=self.zone.id, + vpcid=vpc.id, + gateway="10.2.2.1", + netmask="255.255.255.0" + ) + self.cleanup.insert(0, tier2) + self.logger.info("Tier 2 created: %s (%s)", tier2.name, tier2.id) + + svc_offering = ServiceOffering.list(self.apiclient, issystem=False)[0] + + # ---- VM in tier 1 ---- + vm1_cfg = {"displayname": "vm1-acl-%s" % suffix, + "name": "vm1-acl-%s" % suffix, + "zoneid": self.zone.id} + vm1_kw = dict(accountid=account.name, + domainid=account.domainid, + serviceofferingid=svc_offering.id, + templateid=self.template.id, + networkids=[tier1.id]) + if account_keypair: + vm1_kw["keypair"] = account_keypair.name + vm1 = VirtualMachine.create(self.apiclient, vm1_cfg, **vm1_kw) + self.cleanup.insert(0, vm1) + self.logger.info("VM1 deployed in tier 1: %s (%s)", vm1.name, vm1.id) + + # ---- VM in tier 2 ---- + vm2_cfg = {"displayname": "vm2-acl-%s" % suffix, + "name": "vm2-acl-%s" % suffix, + "zoneid": self.zone.id} + vm2_kw = dict(accountid=account.name, + domainid=account.domainid, + serviceofferingid=svc_offering.id, + templateid=self.template.id, + networkids=[tier2.id]) + if account_keypair: + vm2_kw["keypair"] = account_keypair.name + vm2 = VirtualMachine.create(self.apiclient, vm2_cfg, **vm2_kw) + self.cleanup.insert(0, vm2) + self.logger.info("VM2 deployed in tier 2: %s (%s)", vm2.name, vm2.id) + + # Get VM IPs for later use + vm1_networks = VirtualMachine.list(self.apiclient, id=vm1.id)[0].nic + vm1_ip = None + for nic in vm1_networks: + if nic.networkid == tier1.id: + vm1_ip = nic.ipaddress + self.assertIsNotNone(vm1_ip, "VM1 should have IP in tier1") + self.logger.info("VM1 IP in tier1: %s", vm1_ip) + + vm2_networks = VirtualMachine.list(self.apiclient, id=vm2.id)[0].nic + vm2_ip = None + for nic in vm2_networks: + if nic.networkid == tier2.id: + vm2_ip = nic.ipaddress + self.assertIsNotNone(vm2_ip, "VM2 should have IP in tier2") + self.logger.info("VM2 IP in tier2: %s", vm2_ip) + + # ============================================================== + # A. Setup ACL rules + # ============================================================== + self.logger.info("--- Sub-test A: Setting up ACL rules ---") + + # ACL1 rules: ICMP allowed, SSH denied (ingress), ICMP allowed (egress) + # Rule numbers must be unique per ACL list (across ingress+egress). + # Ingress rule: Allow ICMP + NetworkACL.create( + self.apiclient, + {"protocol": "ICMP", "icmptype": -1, "icmpcode": -1, + "traffictype": "Ingress", "aclid": acl1.id, + "cidrlist": ["0.0.0.0/0"], "action": "Allow", "number": 10}, + networkid=tier1.id + ) + self.logger.info("ACL1 Ingress rule: ICMP Allow") + + # Ingress rule: Deny SSH + NetworkACL.create( + self.apiclient, + {"protocol": "TCP", "startport": 22, "endport": 22, + "traffictype": "Ingress", "aclid": acl1.id, + "cidrlist": ["0.0.0.0/0"], "action": "Deny", "number": 20}, + networkid=tier1.id + ) + self.logger.info("ACL1 Ingress rule: SSH Deny") + + # Egress rule: Allow all + NetworkACL.create( + self.apiclient, + {"protocol": "All", "traffictype": "Egress", "aclid": acl1.id, + "cidrlist": ["0.0.0.0/0"], "action": "Allow", "number": 30}, + networkid=tier1.id + ) + self.logger.info("ACL1 Egress rule: All Allow") + + # ACL2 rules: ICMP and SSH allowed (ingress), All allowed (egress) + # Ingress rule: Allow ICMP + NetworkACL.create( + self.apiclient, + {"protocol": "ICMP", "icmptype": -1, "icmpcode": -1, + "traffictype": "Ingress", "aclid": acl2.id, + "cidrlist": ["0.0.0.0/0"], "action": "Allow", "number": 10}, + networkid=tier2.id + ) + self.logger.info("ACL2 Ingress rule: ICMP Allow") + + # Ingress rule: Allow SSH + NetworkACL.create( + self.apiclient, + {"protocol": "TCP", "startport": 22, "endport": 22, + "traffictype": "Ingress", "aclid": acl2.id, + "cidrlist": ["0.0.0.0/0"], "action": "Allow", "number": 20}, + networkid=tier2.id + ) + self.logger.info("ACL2 Ingress rule: SSH Allow") + + # Egress rule: Allow all + NetworkACL.create( + self.apiclient, + {"protocol": "All", "traffictype": "Egress", "aclid": acl2.id, + "cidrlist": ["0.0.0.0/0"], "action": "Allow", "number": 30}, + networkid=tier2.id + ) + self.logger.info("ACL2 Egress rule: All Allow") + + # Apply the ACL lists to the tier networks + tier1.replaceACLList(self.apiclient, acl1.id) + self.logger.info("Applied ACL1 (deny SSH) to tier1") + tier2.replaceACLList(self.apiclient, acl2.id) + self.logger.info("Applied ACL2 (allow SSH) to tier2") + + # ============================================================== + # B. Test Public IP access with ACL enforcement (via PF) + # ============================================================== + self.logger.info("--- Sub-test B: Test public IP access with ACLs ---") + + # Create public IP and PF for tier1 (should block SSH due to ACL1) + ip1 = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=tier1.id, + vpcid=vpc.id + ) + tier1_public_ip = ip1.ipaddress.ipaddress + self.logger.info("Tier1 public IP allocated: %s", tier1_public_ip) + + pf_rule1 = NATRule.create( + self.apiclient, vm1, + {"privateport": 22, "publicport": 22, "protocol": "TCP"}, + ipaddressid=ip1.ipaddress.id, + networkid=tier1.id, + vpcid=vpc.id + ) + self.assertIsNotNone(pf_rule1) + self.logger.info("Tier1 PF rule created: %s:22 → VM1:22", tier1_public_ip) + + # SSH to tier1 should fail due to ACL denying SSH + self._assert_vm_ssh_not_accessible( + tier1_public_ip, 22, + "SSH to tier1 %s should FAIL (ACL denies SSH)" % tier1_public_ip) + self.logger.info("Verified: SSH to tier1 correctly blocked by ACL") + + # Create public IP and PF for tier2 (should allow SSH due to ACL2) + ip2 = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=tier2.id, + vpcid=vpc.id + ) + tier2_public_ip = ip2.ipaddress.ipaddress + self.logger.info("Tier2 public IP allocated: %s", tier2_public_ip) + + pf_rule2 = NATRule.create( + self.apiclient, vm2, + {"privateport": 22, "publicport": 22, "protocol": "TCP"}, + ipaddressid=ip2.ipaddress.id, + networkid=tier2.id, + vpcid=vpc.id + ) + self.assertIsNotNone(pf_rule2) + self.logger.info("Tier2 PF rule created: %s:22 → VM2:22", tier2_public_ip) + + # SSH to tier2 should succeed due to ACL allowing SSH + self._assert_vm_ssh_accessible( + tier2_public_ip, 22, + "SSH to tier2 %s should succeed (ACL allows SSH)" % tier2_public_ip) + self.logger.info("Verified: SSH to tier2 correctly allowed by ACL") + + # ============================================================== + # C. Test inter-tier ICMP communication + # ============================================================== + self.logger.info("--- Sub-test C: Test inter-tier ICMP (ping) ---") + + # From VM2 (tier2), ping VM1 (tier1) — should succeed (both allow ICMP egress) + # This tests that the ACL egress rules work and inter-tier routing is OK + try: + ssh_vm2 = SshClient( + tier2_public_ip, 22, "ubuntu", None, + keyPairFiles=self._ssh_private_key_file, + timeout=30, retries=10 + ) + out = ssh_vm2.execute("ping -c 3 %s 2>&1 | tail -5" % vm1_ip) + ping_result = "\n".join(out) + # Check if ping succeeded (look for "3 packets transmitted" or similar) + if "transmitted" in ping_result.lower(): + self.logger.info("Ping from VM2 to VM1 output:\n%s", ping_result) + if "0 received" not in ping_result.lower(): + self.logger.info("Verified: Inter-tier ping succeeded (ICMP egress rule allows it)") + else: + self.logger.warning("Ping packets were transmitted but all lost") + else: + self.logger.warning("Could not determine ping result from: %s", ping_result) + except Exception as e: + self.logger.warning("Could not execute ping test: %s", e) + + # ============================================================== + # D. Additional SSH verification + # ============================================================== + self.logger.info("--- Sub-test D: Additional SSH verification ---") + + # Re-verify tier2 SSH works after ACL rules are fully active + self._assert_vm_ssh_accessible( + tier2_public_ip, 22, + "SSH to tier2 should still work (ACL permits SSH)") + self.logger.info("Verified: SSH to tier2 confirmed working") + + # ============================================================== + # E. Cleanup + # ============================================================== + self.logger.info("--- Sub-test E: Cleanup ---") + pf_rule1.delete(self.apiclient) + ip1.delete(self.apiclient) + pf_rule2.delete(self.apiclient) + ip2.delete(self.apiclient) + + vm1.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm1] + vm2.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm2] + tier1.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != tier1] + tier2.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != tier2] + + vpc.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != vpc] + + self._teardown_extension() + self.logger.info("test_07 PASSED") + + @attr(tags=["advanced", "smoke"], required_hardware="true") + def test_08_custom_action_policy_based_routing(self): + """Custom-action smoke test for PBR lifecycle helpers. + + Verifies that network custom actions can create/list/delete: + - routing tables + - routes per table + - policy rules + """ + self._check_kvm_host_prerequisites(['ip', 'arping', 'dnsmasq', 'haproxy']) + + svc = "SourceNat,PortForwarding,Dhcp,Dns,UserData" + nw_offering, _ext_name = self._setup_extension_nsp_offering( + "extnet-pbr", supported_services=svc) + _account, network, vm = self._create_account_network_vm( + nw_offering, name_suffix="pbr") + + # Use a unique table name to avoid collisions with stale test state. + table_name = "app-%s" % random.randint(100, 999) + route_cidr = "172.30.%d.0/24" % random.randint(1, 200) + + actions = [] + try: + def _mk_action(name, parameters = []): + a = ExtensionCustomAction.create( + self.apiclient, + extensionid=self.extension.id, + enabled=True, + name=name, + description="PBR smoke: %s" % name, + resourcetype='Network', + parameters=parameters + ) + actions.append(a) + return a + + act_create_table = _mk_action("pbr-create-table", parameters=[ + {"name": "table-id", "type": "STRING", "required": True}, + {"name": "table-name", "type": "STRING", "required": True}, + ]) + act_delete_table = _mk_action("pbr-delete-table", parameters=[ + {"name": "table-name", "type": "STRING", "required": True}, + ]) + act_list_tables = _mk_action("pbr-list-tables") + act_add_route = _mk_action("pbr-add-route", parameters=[ + {"name": "table", "type": "STRING", "required": True}, + {"name": "route", "type": "STRING", "required": True}, + ]) + act_delete_route = _mk_action("pbr-delete-route", parameters=[ + {"name": "table", "type": "STRING", "required": True}, + {"name": "route", "type": "STRING", "required": True}, + ]) + act_list_routes = _mk_action("pbr-list-routes", parameters=[ + {"name": "table", "type": "STRING", "required": False}, + ]) + act_add_rule = _mk_action("pbr-add-rule", parameters=[ + {"name": "table", "type": "STRING", "required": True}, + {"name": "rule", "type": "STRING", "required": True}, + ]) + act_delete_rule = _mk_action("pbr-delete-rule", parameters=[ + {"name": "table", "type": "STRING", "required": True}, + {"name": "rule", "type": "STRING", "required": True}, + ]) + act_list_rules = _mk_action("pbr-list-rules", parameters=[ + {"name": "table", "type": "STRING", "required": False}, + ]) + + # 1) Create and list routing table + out = act_create_table.run( + self.apiclient, + resourceid=network.id, + parameters=[{"table-id": "100", "table-name": table_name}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-create-table should succeed") + + out = act_list_tables.run(self.apiclient, resourceid=network.id) + self.assertTrue(getattr(out, 'success', False), "pbr-list-tables should succeed") + self.assertIn(table_name, self._custom_action_details(out)) + + # 2) Add and list route in table + out = act_add_route.run( + self.apiclient, + resourceid=network.id, + parameters=[{"table": table_name, "route": "blackhole %s" % route_cidr}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-add-route should succeed") + + out = act_list_routes.run( + self.apiclient, + resourceid=network.id, + parameters=[{"table": table_name}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-list-routes should succeed") + self.assertIn(route_cidr, self._custom_action_details(out)) + + # 3) Add and list policy rule + out = act_add_rule.run( + self.apiclient, + resourceid=network.id, + parameters=[{"table": table_name, "rule": "to %s" % route_cidr}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-add-rule should succeed") + + out = act_list_rules.run( + self.apiclient, + resourceid=network.id, + parameters=[{"table": table_name}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-list-rules should succeed") + self.assertIn(table_name, self._custom_action_details(out)) + + # 4) Delete policy rule, route, and table + out = act_delete_rule.run( + self.apiclient, + resourceid=network.id, + parameters=[{"table": table_name, "rule": "to %s" % route_cidr}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-delete-rule should succeed") + + out = act_delete_route.run( + self.apiclient, + resourceid=network.id, + parameters=[{"table": table_name, "route": "blackhole %s" % route_cidr}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-delete-route should succeed") + + out = act_delete_table.run( + self.apiclient, + resourceid=network.id, + parameters=[{"table-name": table_name}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-delete-table should succeed") + + self.logger.info("test_08 PASSED") + finally: + for action in actions: + try: + action.delete(self.apiclient) + except Exception: + pass + vm.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm] + network.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != network] + self._teardown_extension() + + @attr(tags=["advanced", "smoke"], required_hardware="true") + def test_09_vpc_source_nat_ip_update(self): + """Update VPC source NAT IP and verify old/new source NAT flags flip correctly.""" + self._check_kvm_host_prerequisites(['arping']) + + svc = "SourceNat,StaticNat,PortForwarding,Lb,UserData,Dhcp,Dns,NetworkACL" + _nw_offering, ext_name = self._setup_extension_nsp_offering( + "extnet-vpc-snat-update", supported_services=svc, for_vpc=True) + + suffix = random_gen() + account = None + vpc = None + tier = None + vm = None + ip1 = None + ip2 = None + pf_rule = None + lb_rule = None + static_nat_enabled = False + try: + # VPC tier network offering (useVpc=on) + _tier_prov = {s.strip(): ext_name for s in svc.split(',')} + vpc_tier_offering = NetworkOffering.create(self.apiclient, { + "name": "ExtNet-VPCTier-SNAT-%s" % random_gen(), + "displaytext": "ExtNet VPC tier offering for source NAT update", + "guestiptype": "Isolated", + "traffictype": "GUEST", + "availability": "Optional", + "useVpc": "on", + "supportedservices": svc, + "serviceProviderList": _tier_prov, + "serviceCapabilityList": { + "SourceNat": {"SupportedSourceNatTypes": "peraccount"}, + }, + }) + self.cleanup.append(vpc_tier_offering) + vpc_tier_offering.update(self.apiclient, state='Enabled') + + # VPC offering + _vpc_prov = {s.strip(): ext_name for s in svc.split(',')} + vpc_offering = VpcOffering.create(self.apiclient, { + "name": "ExtNet-VPC-SNAT-%s" % random_gen(), + "displaytext": "ExtNet VPC offering for source NAT update", + "supportedservices": svc, + "serviceProviderList": _vpc_prov, + }) + self.cleanup.append(vpc_offering) + vpc_offering.update(self.apiclient, state='Enabled') + + account = Account.create( + self.apiclient, + self.services["account"], + admin=True, + domainid=self.domain.id + ) + self.cleanup.append(account) + account_keypair = self._create_account_keypair(account, suffix) + + vpc = VPC.create( + self.apiclient, + {"name": "extnet-vpc-snat-%s" % suffix, + "displaytext": "ExtNet VPC SNAT %s" % suffix, + "cidr": "10.3.0.0/16"}, + vpcofferingid=vpc_offering.id, + zoneid=self.zone.id, + account=account.name, + domainid=account.domainid + ) + self.cleanup.insert(0, vpc) + + tier = Network.create( + self.apiclient, + {"name": "tier-snat-%s" % suffix, + "displaytext": "Tier SNAT %s" % suffix}, + accountid=account.name, + domainid=account.domainid, + networkofferingid=vpc_tier_offering.id, + zoneid=self.zone.id, + vpcid=vpc.id, + gateway="10.3.1.1", + netmask="255.255.255.0" + ) + self.cleanup.insert(0, tier) + + # Create a vm instance in the tier, so that the vpc and tier are implemented on the backend and have their source NAT IPs allocated. + svc_offering = ServiceOffering.list(self.apiclient, issystem=False)[0] + vm_cfg = {"displayname": "vm-snat-%s" % suffix, + "name": "vm-snat-%s" % suffix, + "zoneid": self.zone.id} + vm_kw = dict(accountid=account.name, + domainid=account.domainid, + serviceofferingid=svc_offering.id, + templateid=self.template.id, + networkids=[tier.id]) + if account_keypair: + vm_kw["keypair"] = account_keypair.name + vm = VirtualMachine.create(self.apiclient, vm_cfg, **vm_kw) + self.cleanup.insert(0, vm) + + # Wait for the VPC's auto-assigned source NAT IP (CloudStack assigns + # one automatically when the VPC is first used; it is NOT necessarily + # the first manually-allocated IP). + src_before = self._wait_for_vpc_source_nat_ip(vpc.id) + self.assertIsNotNone(src_before, + "A source NAT IP should already exist for the VPC") + src_before_addr = getattr(src_before, 'ipaddress', None) + self.logger.info("Initial source NAT IP: %s", src_before_addr) + + # Allocate ip1 and ip2 as candidates for the updated source NAT. + ip1 = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=tier.id, + vpcid=vpc.id + ) + ip1_addr = ip1.ipaddress.ipaddress + + ip2 = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=tier.id, + vpcid=vpc.id + ) + ip2_addr = ip2.ipaddress.ipaddress + + # Use ip1 for static NAT (SSH/22) and verify ingress connectivity. + StaticNATRule.enable( + self.apiclient, + ip1.ipaddress.id, + vm.id, + networkid=tier.id + ) + static_nat_enabled = True + + # Use ip2 for both PF and LB on different public ports. + pf_port = 2222 + lb_port = 2223 + pf_rule = NATRule.create( + self.apiclient, + vm, + {"privateport": 22, "publicport": pf_port, "protocol": "TCP"}, + ipaddressid=ip2.ipaddress.id, + networkid=tier.id, + vpcid=vpc.id + ) + self.assertIsNotNone(pf_rule, "Port forwarding rule should be created on ip2") + + lb_rule = LoadBalancerRule.create( + self.apiclient, + { + "name": "lb-ssh-%s" % suffix, + "alg": "roundrobin", + "privateport": 22, + "publicport": lb_port, + "protocol": "TCP", + }, + ipaddressid=ip2.ipaddress.id, + accountid=account.name, + domainid=account.domainid, + networkid=tier.id, + vpcid=vpc.id + ) + self.assertIsNotNone(lb_rule, "Load balancer rule should be created on ip2") + lb_rule.assign(self.apiclient, [vm]) + + # Validate all inbound paths before source NAT migration. + self._assert_vm_ssh_accessible( + ip1_addr, 22, + "Static NAT SSH on ip1 should work before source NAT update") + self._assert_vm_ssh_accessible( + ip2_addr, pf_port, + "Port forwarding SSH on ip2 should work before source NAT update") + self._assert_vm_ssh_accessible( + ip2_addr, lb_port, + "Load balancer SSH on ip2 should work before source NAT update") + + ip3 = PublicIPAddress.create( + self.apiclient, + accountid=account.name, + zoneid=self.zone.id, + domainid=account.domainid, + networkid=tier.id, + vpcid=vpc.id + ) + ip3_addr = ip3.ipaddress.ipaddress + + self.logger.info("Updating VPC source NAT IP to %s", ip3_addr) + update_resp = vpc.update(self.apiclient, sourcenatipaddress=ip3_addr) + self.assertIsNotNone(update_resp, + "updateVPC should return a response") + + src_after = self._wait_for_vpc_source_nat_ip(vpc.id, expected_ip=ip3_addr) + self.assertIsNotNone(src_after, + "Updated source NAT IP should be %s" % ip3_addr) + + all_vpc_ips = self._list_vpc_public_ips(vpc.id) + self.assertEqual(len(all_vpc_ips), 4, + "VPC should have four public IPs") + + by_addr = {getattr(x, 'ipaddress', None): x for x in all_vpc_ips} + self.assertIn(src_before_addr, by_addr, + "Original source NAT IP must remain allocated to VPC") + self.assertIn(ip1_addr, by_addr, "Static NAT IP must remain allocated to VPC") + self.assertIn(ip2_addr, by_addr, "New source NAT IP must remain allocated to VPC") + + src_ips = [x for x in all_vpc_ips if getattr(x, 'issourcenat', False)] + self.assertEqual(1, len(src_ips), + "Exactly one VPC public IP must be marked source NAT") + self.assertEqual(ip3_addr, getattr(src_ips[0], 'ipaddress', None), + "Source NAT IP should switch to the requested IP") + self.assertFalse(getattr(by_addr[src_before_addr], 'issourcenat', False), + "Original source NAT IP must be unset after update") + self.assertTrue(getattr(by_addr[ip3_addr], 'issourcenat', False), + "Requested source NAT IP must be marked source NAT") + + # Validate all inbound paths still work after source NAT migration. + self._assert_vm_ssh_accessible( + ip1_addr, 22, + "Static NAT SSH on ip1 should work after source NAT update") + self._assert_vm_ssh_accessible( + ip2_addr, pf_port, + "Port forwarding SSH on ip2 should work after source NAT update") + self._assert_vm_ssh_accessible( + ip2_addr, lb_port, + "Load balancer SSH on ip2 should work after source NAT update") + + self.logger.info("test_09 PASSED") + finally: + try: + if lb_rule and vm: + lb_rule.remove(self.apiclient, [vm]) + except Exception: + pass + try: + if lb_rule: + lb_rule.delete(self.apiclient) + except Exception: + pass + try: + if pf_rule: + pf_rule.delete(self.apiclient) + except Exception: + pass + try: + if static_nat_enabled and ip1: + StaticNATRule.disable(self.apiclient, ip1.ipaddress.id) + except Exception: + pass + try: + if ip1: + ip1.delete(self.apiclient) + except Exception: + pass + try: + if ip2: + ip2.delete(self.apiclient) + except Exception: + pass + try: + if vm: + vm.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm] + except Exception: + pass + try: + if tier: + tier.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != tier] + except Exception: + pass + try: + if vpc: + vpc.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != vpc] + except Exception: + pass + try: + self._teardown_extension() + except Exception: + pass + diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 2a6b7316b70e..eea21cde04aa 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -241,6 +241,7 @@ "label.action.unmanage.volume": "Unmanage Volume", "label.action.unmanage.volumes": "Unmanage Volumes", "label.action.unregister.extension.resource": "Unregister extension resource", +"label.action.update.extension.resource": "Update extension resource details", "label.action.update.host": "Update Host", "label.action.update.security.groups": "Update security groups", "label.action.update.offering.access": "Update offering access", @@ -4170,6 +4171,8 @@ "message.validate.min": "Please enter a value greater than or equal to {0}.", "message.action.delete.object.storage": "Please confirm that you want to delete this Object Store", "message.action.unregister.extension.resource": "Please confirm that you want to unregister extension with this resource", +"message.action.update.extension.resource": "Update the extension resource registration details", +"message.success.update.extension.resource": "Successfully updated extension resource registration", "message.bgp.peers.null": "Please note, if no BGP peers are selected, the VR will connect to
(1) dedicated BGP peers the owner can access, if the owner has dedicated BGP peers and account setting use.system.bgp.peers is set to false;
(2) all BGP peers the owner can access, otherwise.
", "message.bucket.delete": "Please confirm that you want to delete this Bucket", "migrate.from": "Migrate from", @@ -4232,5 +4235,21 @@ "Compute*Month": "Compute * Month", "GB*Month": "GB * Month", "IP*Month": "IP * Month", -"Policy*Month": "Policy * Month" +"Policy*Month": "Policy * Month", +"ExternalNetwork": "External Network", +"label.external.network": "External Network", +"label.external.network.provider": "External Network Provider", +"label.extension": "Extension", +"label.services": "Services", +"label.add.external.network.provider": "Add External Network Provider", +"label.not.added": "Not Added", +"label.refresh": "Refresh", +"label.run.action": "Run Action", +"label.enable.provider": "Enable Provider", +"label.disable.provider": "Disable Provider", +"label.external.network.service": "External Network Service", +"message.confirm.disable.external.network.provider": "Are you sure you want to disable the External Network provider?", +"message.no.network.orchestrator.extensions": "No NetworkOrchestrator extensions found. Please create one first via createExtension API.", +"message.extension.services.from.capabilities": "Services are derived automatically from the extension's network.capabilities detail.", +"message.select.extension": "Please select an extension." } diff --git a/ui/src/config/section/extension.js b/ui/src/config/section/extension.js index 5904abae30b6..8a8d8081c35d 100644 --- a/ui/src/config/section/extension.js +++ b/ui/src/config/section/extension.js @@ -45,7 +45,7 @@ export default { return fields }, details: ['name', 'description', 'id', 'type', 'details', 'path', 'pathready', 'isuserdefined', 'orchestratorrequirespreparevm', 'reservedresourcedetails', 'created'], - filters: ['orchestrator'], + filters: ['orchestrator', 'networkorchestrator'], tabs: [{ name: 'details', component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) diff --git a/ui/src/config/section/infra/phynetworks.js b/ui/src/config/section/infra/phynetworks.js index 0863eff6ec0b..9b7a0a83f7f3 100644 --- a/ui/src/config/section/infra/phynetworks.js +++ b/ui/src/config/section/infra/phynetworks.js @@ -94,7 +94,7 @@ export default { icon: 'edit-outlined', label: 'label.update.physical.network', dataView: true, - args: ['vlan', 'tags'] + args: ['vlan', 'tags', 'externaldetails'] }, { api: 'addTrafficType', diff --git a/ui/src/config/section/network.js b/ui/src/config/section/network.js index 37cdd0c8b98a..ad308824da22 100644 --- a/ui/src/config/section/network.js +++ b/ui/src/config/section/network.js @@ -202,6 +202,20 @@ export default { } } }, + { + api: 'runCustomAction', + icon: 'thunderbolt-outlined', + label: 'label.run.action', + dataView: true, + show: (record) => { + return 'runCustomAction' in store.getters.apis && + 'listCustomActions' in store.getters.apis && + record.service && record.service.some(s => + s.provider && s.provider.some(p => p.name === 'ExternalNetwork')) + }, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/RunCustomAction.vue'))) + }, { api: 'deleteNetwork', icon: 'delete-outlined', diff --git a/ui/src/views/extension/AddCustomAction.vue b/ui/src/views/extension/AddCustomAction.vue index f74255fa8de3..7c1de26b5048 100644 --- a/ui/src/views/extension/AddCustomAction.vue +++ b/ui/src/views/extension/AddCustomAction.vue @@ -34,8 +34,25 @@ api="listExtensions" :apiParams="extensionsApiParams" resourceType="extension" + @change-option="updateResourceTypeByExtension" defaultIcon="appstore-add-outlined" /> + + + + + {{ opt }} + + + + + + @@ -67,13 +88,16 @@ import { postAPI } from '@/api' import eventBus from '@/config/eventBus' import ObjectListTable from '@/components/view/ObjectListTable.vue' import TooltipButton from '@/components/widgets/TooltipButton' +import UpdateRegisteredExtension from '@/views/extension/UpdateRegisteredExtension' export default { name: 'ExtensionResourcesTab', components: { ObjectListTable, - TooltipButton + TooltipButton, + UpdateRegisteredExtension }, + inject: ['parentFetchData'], props: { resource: { type: Object, @@ -103,7 +127,9 @@ export default { title: this.$t('label.actions') } ], - unregisterLoading: false + unregisterLoading: false, + updateModalVisible: false, + selectedResource: null } }, computed: { @@ -112,13 +138,27 @@ export default { } }, methods: { + openUpdateModal (record) { + this.selectedResource = record + this.updateModalVisible = true + }, + closeUpdateModal () { + this.updateModalVisible = false + this.selectedResource = null + }, + handleRefreshData () { + if (this.parentFetchData) { + this.parentFetchData() + } + }, unregisterExtension (record) { const params = { extensionid: this.resource.id, resourceid: record.id, resourcetype: record.type } - postAPI('unregisterExtension', params).then(json => { + this.unregisterLoading = true + postAPI('unregisterExtension', params).then(() => { eventBus.emit('async-job-complete', null) this.$notification.success({ message: this.$t('label.unregister.extension'), @@ -127,7 +167,7 @@ export default { }).catch(error => { this.$notifyError(error) }).finally(() => { - this.deleteLoading = false + this.unregisterLoading = false }) } } diff --git a/ui/src/views/extension/RegisterExtension.vue b/ui/src/views/extension/RegisterExtension.vue index f856c181f8cd..393e465c62b6 100644 --- a/ui/src/views/extension/RegisterExtension.vue +++ b/ui/src/views/extension/RegisterExtension.vue @@ -120,7 +120,7 @@ export default { }, fetchExtensionResourceTypes () { this.resourceTypes = [] - const resourceTypesList = ['Cluster'] + const resourceTypesList = ['Cluster', 'PhysicalNetwork'] resourceTypesList.forEach((item) => { this.resourceTypes.push({ id: item, diff --git a/ui/src/views/extension/UpdateRegisteredExtension.vue b/ui/src/views/extension/UpdateRegisteredExtension.vue new file mode 100644 index 000000000000..d1aa39942cc1 --- /dev/null +++ b/ui/src/views/extension/UpdateRegisteredExtension.vue @@ -0,0 +1,131 @@ +// 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. + + + + + + diff --git a/ui/src/views/infra/network/ServiceProvidersTab.vue b/ui/src/views/infra/network/ServiceProvidersTab.vue index f659ce1f0167..dd5941f01e61 100644 --- a/ui/src/views/infra/network/ServiceProvidersTab.vue +++ b/ui/src/views/infra/network/ServiceProvidersTab.vue @@ -18,10 +18,21 @@ + @@ -269,6 +276,7 @@ export default { name = record.hostname params.resourceid = record.resourceid break + // ExternalNetwork provider action removed; use extension registration/unregister instead default: break } diff --git a/ui/src/views/offering/AddNetworkOffering.vue b/ui/src/views/offering/AddNetworkOffering.vue index d1bbb0f7304f..e87780e4992a 100644 --- a/ui/src/views/offering/AddNetworkOffering.vue +++ b/ui/src/views/offering/AddNetworkOffering.vue @@ -60,10 +60,10 @@ {{ $t('label.isolated') }} - + {{ $t('label.l2') }} - + {{ $t('label.shared') }} @@ -138,7 +138,16 @@ {{ }} {{ $t('label.nsx') }} {{ $t('label.netris') }} - + + + {{ ext.name }} ({{ $t('label.external.network.provider') }}) + + @@ -208,7 +217,7 @@ - + @@ -303,8 +312,8 @@ @@ -669,8 +678,20 @@ export default { description: 'Netris', enabled: true }, + externalNetworkProviderObj: { + name: '', + description: 'External Network', + enabled: true + }, nsxSupportedServicesMap: {}, - netrisSupportedServicesMap: {} + netrisSupportedServicesMap: {}, + externalNetworkSupportedServicesMap: {}, + availableExtensionProviders: [] + } + }, + computed: { + isExternalNetworkProvider () { + return this.availableExtensionProviders.some(e => e.name === this.provider) } }, beforeCreate () { @@ -732,6 +753,30 @@ export default { this.fetchServiceOfferingData() this.fetchIpv6NetworkOfferingConfiguration() this.fetchRoutedNetworkConfiguration() + this.fetchExtensionProviders() + }, + fetchExtensionProviders () { + // Load NetworkOrchestrator extensions that are registered to at least one + // physical network (i.e. have a corresponding NetworkServiceProvider entry). + // Only these can be selected as a provider when creating a network offering. + getAPI('listExtensions', { type: 'NetworkOrchestrator', state: 'Enabled' }).then(json => { + const allExts = (json.listextensionsresponse && json.listextensionsresponse.extension) || [] + if (allExts.length === 0) { + this.availableExtensionProviders = [] + return + } + // Filter to those which have at least one matching NSP (nsp name == extension name) + getAPI('listNetworkServiceProviders', {}).then(nspJson => { + const nsps = (nspJson.listnetworkserviceprovidersresponse && nspJson.listnetworkserviceprovidersresponse.networkserviceprovider) || [] + const nspNames = new Set(nsps.map(n => n.name)) + this.availableExtensionProviders = allExts.filter(e => nspNames.has(e.name)) + }).catch(() => { + // Fallback: show all enabled extensions + this.availableExtensionProviders = allExts + }) + }).catch(() => { + this.availableExtensionProviders = [] + }) }, isAdmin () { return isAdmin() @@ -906,7 +951,7 @@ export default { this.supportedServiceLoading = true var supportedServices = this.supportedServices var self = this - if (this.provider !== 'NSX' && this.provider !== 'Netris') { + if (this.provider !== 'NSX' && this.provider !== 'Netris' && !this.isExternalNetworkProvider) { if (this.networkmode === 'ROUTED' && this.guestType === 'isolated') { supportedServices = supportedServices.filter(service => { return !['SourceNat', 'StaticNat', 'Lb', 'PortForwarding', 'Vpn'].includes(service.name) @@ -943,6 +988,8 @@ export default { return Object.keys(this.nsxSupportedServicesMap).includes(svc.name) } else if (this.provider === 'Netris') { return Object.keys(this.netrisSupportedServicesMap).includes(svc.name) + } else if (this.isExternalNetworkProvider) { + return Object.keys(this.externalNetworkSupportedServicesMap).includes(svc.name) } }) supportedServices = supportedServices.map(svc => { @@ -951,6 +998,8 @@ export default { svc.provider = [this.NSX] } else if (this.provider === 'Netris') { svc.provider = [this.Netris] + } else if (this.isExternalNetworkProvider) { + svc.provider = [this.externalNetworkProviderObj] } } else { if (this.forVpc) { @@ -1029,9 +1078,46 @@ export default { ...(this.forVpc && { NetworkACL: this.Netris }), ...(!this.forVpc && { Firewall: this.Netris }) } + } else if (this.isExternalNetworkProvider) { + // Extension-backed provider: services come from the extension's network.capabilities. + // this.provider is the extension name (= NSP name) + const extProviderObj = { + name: this.provider, + description: this.provider, + enabled: true + } + const svcMap = { Dhcp: this.VR, Dns: this.VR, UserData: this.VR } + // Infer services from the selected extension's network.capabilities detail + const extDef = this.availableExtensionProviders.find(e => e.name === this.provider) + const services = this._getExtensionServices(extDef) + if (services.length > 0) { + services.forEach(svc => { + if (!['Dhcp', 'Dns', 'UserData'].includes(svc)) { + svcMap[svc] = extProviderObj + } + }) + } else { + // Default services if no capabilities declared + svcMap.SourceNat = extProviderObj + svcMap.StaticNat = extProviderObj + svcMap.PortForwarding = extProviderObj + svcMap.Firewall = extProviderObj + svcMap.Gateway = extProviderObj + } + this.externalNetworkSupportedServicesMap = svcMap + this.externalNetworkProviderObj = extProviderObj } this.fetchSupportedServiceData() }, + _getExtensionServices (extDef) { + if (!extDef || !extDef.details || !extDef.details['network.capabilities']) return [] + try { + const caps = JSON.parse(extDef.details['network.capabilities']) + return (caps && caps.services) ? caps.services : [] + } catch (e) { + return [] + } + }, handleForNetworkModeChange (networkMode) { this.networkmode = networkMode this.fetchSupportedServiceData() diff --git a/ui/src/views/offering/AddVpcOffering.vue b/ui/src/views/offering/AddVpcOffering.vue index 509109c91c26..7c91961c0555 100644 --- a/ui/src/views/offering/AddVpcOffering.vue +++ b/ui/src/views/offering/AddVpcOffering.vue @@ -86,6 +86,15 @@ {{ }} {{ $t('label.nsx') }} {{ $t('label.netris') }} + + + {{ ext.name }} ({{ $t('label.external.network.provider') }}) + @@ -152,8 +161,8 @@ @@ -337,9 +346,22 @@ export default { enabled: true }, nsxSupportedServicesMap: {}, + externalNetworkProviderObj: { + name: '', + description: 'External Network', + enabled: true + }, + externalNetworkSupportedServicesMap: {}, + availableExtensionProviders: [], conservemode: false } }, + computed: { + isExternalNetworkProvider () { + const selectedProvider = this.form?.provider || this.provider + return this.availableExtensionProviders.some(e => e.name === selectedProvider) + } + }, beforeCreate () { this.apiParams = this.$getApiParams('createVPCOffering') }, @@ -384,6 +406,25 @@ export default { this.fetchSupportedServiceData() this.fetchIpv6NetworkOfferingConfiguration() this.fetchRoutedNetworkConfiguration() + this.fetchExtensionProviders() + }, + fetchExtensionProviders () { + getAPI('listExtensions', { type: 'NetworkOrchestrator', state: 'Enabled' }).then(json => { + const allExts = (json.listextensionsresponse && json.listextensionsresponse.extension) || [] + if (allExts.length === 0) { + this.availableExtensionProviders = [] + return + } + getAPI('listNetworkServiceProviders', {}).then(nspJson => { + const nsps = (nspJson.listnetworkserviceprovidersresponse && nspJson.listnetworkserviceprovidersresponse.networkserviceprovider) || [] + const nspNames = new Set(nsps.map(n => n.name)) + this.availableExtensionProviders = allExts.filter(e => nspNames.has(e.name)) + }).catch(() => { + this.availableExtensionProviders = allExts + }) + }).catch(() => { + this.availableExtensionProviders = [] + }) }, isAdmin () { return isAdmin() @@ -433,7 +474,18 @@ export default { }, fetchSupportedServiceData () { var services = [] - if (this.provider === 'NSX') { + if (this.isExternalNetworkProvider) { + const serviceMap = this._buildExternalVpcServiceMap() + Object.keys(serviceMap).forEach(serviceName => { + services.push({ + name: serviceName, + enabled: true, + provider: Array.isArray(serviceMap[serviceName]) + ? serviceMap[serviceName] + : [serviceMap[serviceName]] + }) + }) + } else if (this.provider === 'NSX') { services.push({ name: 'Dhcp', enabled: true, @@ -624,9 +676,76 @@ export default { if (this.provider === 'NSX') { this.form.nsxsupportlb = true this.handleNsxLbService(true) + } else if (this.isExternalNetworkProvider) { + this._buildExternalVpcServiceMap() } this.fetchSupportedServiceData() }, + _getExtensionServices (extDef) { + if (!extDef || !extDef.details) { + return [] + } + + const capsJson = extDef.details['network.capabilities'] + if (capsJson) { + try { + const caps = JSON.parse(capsJson) + if (caps && Array.isArray(caps.services)) { + return caps.services + } + } catch (e) { + // Ignore malformed capabilities and fallback to network.services. + } + } + + const servicesCsv = extDef.details['network.services'] + if (servicesCsv && typeof servicesCsv === 'string') { + return servicesCsv.split(',').map(x => x.trim()).filter(x => x.length > 0) + } + return [] + }, + _buildExternalVpcServiceMap () { + const selectedProvider = this.form?.provider || this.provider + const extProviderObj = { + name: selectedProvider, + description: selectedProvider, + enabled: true + } + const extWithFallbackProviders = [ + { name: selectedProvider }, + { name: 'VpcVirtualRouter' }, + { name: 'ConfigDrive' } + ] + const serviceMap = { + Dhcp: extWithFallbackProviders, + Dns: extWithFallbackProviders, + UserData: extWithFallbackProviders + } + + const extDef = this.availableExtensionProviders.find(e => e.name === selectedProvider) + const services = this._getExtensionServices(extDef) + const allowedVpcServices = new Set([ + 'Gateway', 'Lb', 'StaticNat', 'SourceNat', 'NetworkACL', 'PortForwarding', 'Vpn' + ]) + + services.forEach(service => { + if (allowedVpcServices.has(service)) { + serviceMap[service] = [{ name: selectedProvider }] + } + }) + + // Fallback for older extensions that only declare partial details. + if (Object.keys(serviceMap).length <= 3) { + serviceMap.SourceNat = [{ name: selectedProvider }] + serviceMap.StaticNat = [{ name: selectedProvider }] + serviceMap.PortForwarding = [{ name: selectedProvider }] + serviceMap.NetworkACL = [{ name: selectedProvider }] + } + + this.externalNetworkProviderObj = extProviderObj + this.externalNetworkSupportedServicesMap = serviceMap + return serviceMap + }, handleNsxLbService (supportLb) { console.log(supportLb) if (!supportLb) { From 416b2cb703caa58eba27b9a6a992a9ad8b7af92e Mon Sep 17 00:00:00 2001 From: Wei Zhou Date: Wed, 15 Apr 2026 09:45:34 +0200 Subject: [PATCH 2/4] gha: fix EOF and license --- .../NetworkCustomActionProvider.java | 1 - .../java/com/cloud/network/NetworkTest.java | 18 ++++++++++++- .../api/UpdateRegisteredExtensionCmd.java | 1 - .../framework/extensions/network/README.md | 27 +++++++++++++++---- .../api/UpdateRegisteredExtensionCmdTest.java | 1 - .../smoke/test_network_extension_namespace.py | 1 - 6 files changed, 39 insertions(+), 10 deletions(-) diff --git a/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java b/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java index 61510924ed36..3f8754a168bb 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java +++ b/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java @@ -50,4 +50,3 @@ public interface NetworkCustomActionProvider { */ String runCustomAction(Network network, String actionName, Map parameters); } - diff --git a/api/src/test/java/com/cloud/network/NetworkTest.java b/api/src/test/java/com/cloud/network/NetworkTest.java index ec4ae3c2160b..9a937a4603d3 100644 --- a/api/src/test/java/com/cloud/network/NetworkTest.java +++ b/api/src/test/java/com/cloud/network/NetworkTest.java @@ -1,3 +1,20 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + package com.cloud.network; import org.junit.Test; @@ -37,4 +54,3 @@ public void testProviderContains() { assertTrue("List should contain the new transient provider with same name", providers.contains(transientProviderNew)); } } - diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java index 02a46b093938..6c755ecd1a54 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java @@ -114,4 +114,3 @@ public Long getApiResourceId() { return getExtensionId(); } } - diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md index ec885d7610b3..75aaa5d33b3e 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md @@ -1,3 +1,22 @@ + + # Network Extension Script Protocol This document describes the complete interface between Apache CloudStack's @@ -1068,9 +1087,7 @@ esac exit 0 ``` -For a full production implementation see: -- `extensions/network-namespace/network-namespace.sh` — management-server - entry-point (SSH proxy). -- `extensions/network-namespace/network-namespace-wrapper.sh` — KVM-host - wrapper that implements all commands using Linux network namespaces. +For a full production implementation see https://github.com/apache/cloudstack-extensions/tree/network-namespace/Network-Namespace: +- `network-namespace.sh` — management-server entry-point (SSH proxy). +- `enetwork-namespace-wrapper.sh` — KVM-host wrapper that implements all commands using Linux network namespaces. diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java index 7ca5d92a9fd9..e13f165c5847 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java @@ -112,4 +112,3 @@ public void executeThrowsServerApiExceptionWhenManagerFails() { } } } - diff --git a/test/integration/smoke/test_network_extension_namespace.py b/test/integration/smoke/test_network_extension_namespace.py index ef4c335e6428..f4c4e9da9ccf 100644 --- a/test/integration/smoke/test_network_extension_namespace.py +++ b/test/integration/smoke/test_network_extension_namespace.py @@ -2586,4 +2586,3 @@ def test_09_vpc_source_nat_ip_update(self): self._teardown_extension() except Exception: pass - From 6cdaaadcf87c92e54cdf5f909d0246af35c46210 Mon Sep 17 00:00:00 2001 From: Wei Zhou Date: Wed, 15 Apr 2026 15:33:55 +0200 Subject: [PATCH 3/4] add unit tests --- .../CustomActionResultResponseTest.java | 93 +++++ .../NetworkOrchestratorTest.java | 67 ++++ .../framework/extensions/network/README.md | 1 - .../extensions/api/ListExtensionsCmdTest.java | 41 ++ .../api/UpdateRegisteredExtensionCmdTest.java | 46 +++ .../dao/ExtensionResourceMapDaoImplTest.java | 51 +++ .../manager/ExtensionsManagerImplTest.java | 372 ++++++++++++++++++ .../cloud/network/NetworkModelImplTest.java | 131 ++++++ .../cloud/network/NetworkServiceImplTest.java | 69 ++++ .../firewall/FirewallManagerImplTest.java | 179 +++++++++ .../cloud/network/vpc/VpcManagerImplTest.java | 49 +++ 11 files changed, 1098 insertions(+), 1 deletion(-) create mode 100644 api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java create mode 100644 server/src/test/java/com/cloud/network/firewall/FirewallManagerImplTest.java diff --git a/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java b/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java new file mode 100644 index 000000000000..dfb4e4092207 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java @@ -0,0 +1,93 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.extension; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +public class CustomActionResultResponseTest { + + private CustomActionResultResponse response; + + @Before + public void setUp() { + response = new CustomActionResultResponse(); + } + + @Test + public void getResultReturnsNullByDefault() { + assertNull(response.getResult()); + } + + @Test + public void getResultReturnsSetValue() { + Map result = Map.of("message", "OK", "details", "All good"); + response.setResult(result); + assertEquals(result, response.getResult()); + assertEquals("OK", response.getResult().get("message")); + } + + @Test + public void isSuccessReturnsFalseWhenSuccessIsNull() { + // success is null by default + assertFalse(response.isSuccess()); + } + + @Test + public void isSuccessReturnsFalseWhenSuccessIsFalse() { + response.setSuccess(false); + assertFalse(response.isSuccess()); + } + + @Test + public void isSuccessReturnsTrueWhenSuccessIsTrue() { + response.setSuccess(true); + assertTrue(response.isSuccess()); + } + + @Test + public void getSuccessReturnsNullByDefault() { + assertNull(response.getSuccess()); + } + + @Test + public void getSuccessReturnsTrueAfterSetSuccessTrue() { + response.setSuccess(true); + assertTrue(response.getSuccess()); + } + + @Test + public void getSuccessReturnsFalseAfterSetSuccessFalse() { + response.setSuccess(false); + assertFalse(response.getSuccess()); + } + + @Test + public void setAndGetResultWithNullResult() { + response.setResult(null); + assertNull(response.getResult()); + } +} + diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java index 58f3cbe84735..f03affa07281 100644 --- a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java @@ -27,6 +27,7 @@ import java.net.URI; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -35,12 +36,15 @@ import com.cloud.exception.InsufficientVirtualNetworkCapacityException; import com.cloud.network.IpAddressManager; import com.cloud.utils.Pair; +import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.framework.extensions.network.NetworkExtensionElement; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.springframework.test.util.ReflectionTestUtils; import org.mockito.ArgumentMatchers; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -69,6 +73,7 @@ import com.cloud.network.dao.PhysicalNetworkVO; import com.cloud.network.dao.RouterNetworkDao; import com.cloud.network.element.DhcpServiceProvider; +import com.cloud.network.element.NetworkElement; import com.cloud.network.guru.GuestNetworkGuru; import com.cloud.network.guru.NetworkGuru; import com.cloud.network.vpc.VpcManager; @@ -106,6 +111,7 @@ public class NetworkOrchestratorTest extends TestCase { private String guruName = "GuestNetworkGuru"; private String dhcpProvider = "VirtualRouter"; private NetworkGuru guru = mock(NetworkGuru.class); + private NetworkExtensionElement networkExtensionElement; NetworkOfferingVO networkOffering = mock(NetworkOfferingVO.class); @@ -137,6 +143,8 @@ public void setUp() { testOrchestrator._ipAddrMgr = mock(IpAddressManager.class); testOrchestrator._entityMgr = mock(EntityManager.class); testOrchestrator.extensionHelper = mock(ExtensionHelper.class); + networkExtensionElement = mock(NetworkExtensionElement.class); + ReflectionTestUtils.setField(testOrchestrator, "networkExtensionElement", networkExtensionElement); DhcpServiceProvider provider = mock(DhcpServiceProvider.class); Map capabilities = new HashMap(); @@ -1012,4 +1020,63 @@ public void testImportNicWithIP4Address() throws Exception { assertEquals("testtag", nicProfile.getName()); } } + + // ----------------------------------------------------------------------- + // Tests for getNetworkElementsIncludingExtensions + // ----------------------------------------------------------------------- + + @Test + public void getNetworkElementsIncludingExtensionsReturnsBaseListWhenNoExtensions() { + when(testOrchestrator.extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) + .thenReturn(Collections.emptyList()); + + DhcpServiceProvider dhcpProvider = mock(DhcpServiceProvider.class); + List elements = new ArrayList<>(List.of(dhcpProvider)); + testOrchestrator.networkElements = elements; + + @SuppressWarnings("unchecked") + List result = + (List) ReflectionTestUtils + .invokeMethod(testOrchestrator, "getNetworkElementsIncludingExtensions"); + assertNotNull(result); + assertEquals(elements.size(), result.size()); + } + + @Test + public void getNetworkElementsIncludingExtensionsAddsExtensionElements() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("my-net-ext"); + when(testOrchestrator.extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) + .thenReturn(List.of(ext)); + + NetworkExtensionElement extElement = mock(NetworkExtensionElement.class); + when(networkExtensionElement.withProviderName("my-net-ext")).thenReturn(extElement); + + DhcpServiceProvider dhcpProvider = mock(DhcpServiceProvider.class); + testOrchestrator.networkElements = new ArrayList<>(List.of(dhcpProvider)); + + @SuppressWarnings("unchecked") + List result = + (List) ReflectionTestUtils + .invokeMethod(testOrchestrator, "getNetworkElementsIncludingExtensions"); + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains(extElement)); + } + + @Test + public void getNetworkElementsIncludingExtensionsReturnsBaseListWhenExtensionHelperReturnsNull() { + when(testOrchestrator.extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) + .thenReturn(null); + + DhcpServiceProvider dhcpProvider = mock(DhcpServiceProvider.class); + testOrchestrator.networkElements = new ArrayList<>(List.of(dhcpProvider)); + + @SuppressWarnings("unchecked") + List result = + (List) ReflectionTestUtils + .invokeMethod(testOrchestrator, "getNetworkElementsIncludingExtensions"); + assertNotNull(result); + assertEquals(1, result.size()); + } } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md index 75aaa5d33b3e..964677f261b6 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md @@ -1090,4 +1090,3 @@ exit 0 For a full production implementation see https://github.com/apache/cloudstack-extensions/tree/network-namespace/Network-Namespace: - `network-namespace.sh` — management-server entry-point (SSH proxy). - `enetwork-namespace-wrapper.sh` — KVM-host wrapper that implements all commands using Linux network namespaces. - diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java index 1ca601293a37..fe5979663548 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java @@ -18,6 +18,7 @@ package org.apache.cloudstack.framework.extensions.api; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import java.util.Arrays; @@ -89,4 +90,44 @@ public void testGetDetailsWithInvalidValueThrowsException() { setPrivateField("details", detailsList); cmd.getDetails(); } + + // ----------------------------------------------------------------------- + // Tests for new getters: type, resourceId, resourceType + // ----------------------------------------------------------------------- + + @Test + public void testGetTypeReturnsValueWhenSet() { + setPrivateField("type", "NetworkOrchestrator"); + assertEquals("NetworkOrchestrator", cmd.getType()); + } + + @Test + public void testGetTypeReturnsNullWhenUnset() { + setPrivateField("type", null); + assertNull(cmd.getType()); + } + + @Test + public void testGetResourceIdReturnsValueWhenSet() { + setPrivateField("resourceId", "pnet-uuid-123"); + assertEquals("pnet-uuid-123", cmd.getResourceId()); + } + + @Test + public void testGetResourceIdReturnsNullWhenUnset() { + setPrivateField("resourceId", null); + assertNull(cmd.getResourceId()); + } + + @Test + public void testGetResourceTypeReturnsValueWhenSet() { + setPrivateField("resourceType", "PhysicalNetwork"); + assertEquals("PhysicalNetwork", cmd.getResourceType()); + } + + @Test + public void testGetResourceTypeReturnsNullWhenUnset() { + setPrivateField("resourceType", null); + assertNull(cmd.getResourceType()); + } } diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java index e13f165c5847..023820237f47 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java @@ -18,6 +18,7 @@ package org.apache.cloudstack.framework.extensions.api; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -31,6 +32,7 @@ import java.util.EnumSet; import java.util.Map; +import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.ServerApiException; @@ -42,6 +44,8 @@ import org.mockito.Mockito; import org.springframework.test.util.ReflectionTestUtils; +import com.cloud.user.Account; + public class UpdateRegisteredExtensionCmdTest { private UpdateRegisteredExtensionCmd cmd; @@ -60,6 +64,48 @@ public void extensionIdReturnsNullWhenUnset() { assertNull(cmd.getExtensionId()); } + @Test + public void extensionIdReturnsValueWhenSet() { + Long extensionId = 42L; + ReflectionTestUtils.setField(cmd, "extensionId", extensionId); + assertEquals(extensionId, cmd.getExtensionId()); + } + + @Test + public void cleanupDetailsReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", null); + assertNull(cmd.isCleanupDetails()); + } + + @Test + public void cleanupDetailsReturnsTrueWhenSet() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", true); + assertTrue(cmd.isCleanupDetails()); + } + + @Test + public void cleanupDetailsReturnsFalseWhenSetToFalse() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", false); + assertFalse(cmd.isCleanupDetails()); + } + + @Test + public void getEntityOwnerIdReturnsSystemAccountId() { + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void getApiResourceTypeReturnsExtension() { + assertEquals(ApiCommandResourceType.Extension, cmd.getApiResourceType()); + } + + @Test + public void getApiResourceIdReturnsExtensionId() { + Long extensionId = 99L; + ReflectionTestUtils.setField(cmd, "extensionId", extensionId); + assertEquals(extensionId, cmd.getApiResourceId()); + } + @Test public void resourceIdReturnsValueWhenSet() { String resourceId = "resource-123"; diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java index 76a0175e7576..9b389411ba81 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java @@ -83,4 +83,55 @@ public void listResourceIdsByExtensionIdAndTypeReturnsCorrectIds() { when(dao.listResourceIdsByExtensionIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(expectedIds); assertEquals(expectedIds, dao.listResourceIdsByExtensionIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)); } + + // ----------------------------------------------------------------------- + // Tests for new methods: listByResourceIdAndType, listResourceIdsByType + // ----------------------------------------------------------------------- + + @Test + public void listByResourceIdAndTypeReturnsEmptyListWhenNoMatch() { + when(dao.listByResourceIdAndType(999L, ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(List.of()); + assertTrue(dao.listByResourceIdAndType(999L, ExtensionResourceMap.ResourceType.PhysicalNetwork).isEmpty()); + } + + @Test + public void listByResourceIdAndTypeReturnsMatchingEntries() { + ExtensionResourceMapVO map1 = new ExtensionResourceMapVO(); + map1.setResourceId(42L); + map1.setResourceType(ExtensionResourceMap.ResourceType.PhysicalNetwork); + ExtensionResourceMapVO map2 = new ExtensionResourceMapVO(); + map2.setResourceId(42L); + map2.setResourceType(ExtensionResourceMap.ResourceType.PhysicalNetwork); + List expected = List.of(map1, map2); + when(dao.listByResourceIdAndType(42L, ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(expected); + List result = dao.listByResourceIdAndType(42L, ExtensionResourceMap.ResourceType.PhysicalNetwork); + assertEquals(2, result.size()); + assertEquals(expected, result); + } + + @Test + public void listByResourceIdAndTypeDifferentiatesResourceTypes() { + ExtensionResourceMapVO clusterMap = new ExtensionResourceMapVO(); + clusterMap.setResourceType(ExtensionResourceMap.ResourceType.Cluster); + when(dao.listByResourceIdAndType(10L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(List.of(clusterMap)); + when(dao.listByResourceIdAndType(10L, ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(List.of()); + + assertEquals(1, dao.listByResourceIdAndType(10L, ExtensionResourceMap.ResourceType.Cluster).size()); + assertTrue(dao.listByResourceIdAndType(10L, ExtensionResourceMap.ResourceType.PhysicalNetwork).isEmpty()); + } + + @Test + public void listResourceIdsByTypeReturnsEmptyListWhenNoMatch() { + when(dao.listResourceIdsByType(ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(List.of()); + assertTrue(dao.listResourceIdsByType(ExtensionResourceMap.ResourceType.PhysicalNetwork).isEmpty()); + } + + @Test + public void listResourceIdsByTypeReturnsMatchingIds() { + List expectedIds = List.of(5L, 10L, 15L); + when(dao.listResourceIdsByType(ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(expectedIds); + List result = dao.listResourceIdsByType(ExtensionResourceMap.ResourceType.PhysicalNetwork); + assertEquals(3, result.size()); + assertEquals(expectedIds, result); + } } diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java index 05290ea9511a..7356bffb5a95 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java @@ -63,6 +63,7 @@ import org.apache.cloudstack.extension.CustomActionResultResponse; import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.extension.ExtensionHelper; import org.apache.cloudstack.extension.ExtensionResourceMap; import org.apache.cloudstack.framework.extensions.api.AddCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.CreateExtensionCmd; @@ -132,6 +133,7 @@ import com.cloud.network.dao.NetworkServiceMapDao; import com.cloud.network.dao.NetworkVO; import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkServiceProviderDao; import com.cloud.network.dao.PhysicalNetworkVO; import com.cloud.network.element.NetworkElement; import org.apache.cloudstack.extension.NetworkCustomActionProvider; @@ -207,6 +209,9 @@ public class ExtensionsManagerImplTest { @Mock private NetworkModel networkModel; + @Mock + private PhysicalNetworkServiceProviderDao physicalNetworkServiceProviderDao; + @Before public void setUp() { MockitoAnnotations.openMocks(this); @@ -2438,6 +2443,373 @@ public void runNetworkCustomActionFailsWhenNoProvider() { assertTrue(resp.getResult().get(ApiConstants.DETAILS).contains("No network service provider")); } + // ----------------------------------------------------------------------- + // Tests for getExtensionFromResource with Network resource type + // ----------------------------------------------------------------------- + + @Test + public void getExtensionFromResourceReturnsExtensionForNetworkWithProviderMatch() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(10L); + when(network.getPhysicalNetworkId()).thenReturn(5L); + when(entityManager.findByUuid(eq(Network.class), eq("net-uuid"))).thenReturn(network); + + List providers = List.of("my-ext-provider"); + when(networkServiceMapDao.getDistinctProviders(10L)).thenReturn(providers); + + ExtensionVO ext = mock(ExtensionVO.class); + doReturn(ext).when(extensionsManager).getExtensionForPhysicalNetworkAndProvider(5L, "my-ext-provider"); + + Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.Network, "net-uuid"); + assertEquals(ext, result); + } + + @Test + public void getExtensionFromResourceFallsBackToFirstMappingForNetwork() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(10L); + when(network.getPhysicalNetworkId()).thenReturn(5L); + when(entityManager.findByUuid(eq(Network.class), eq("net-uuid"))).thenReturn(network); + + when(networkServiceMapDao.getDistinctProviders(10L)).thenReturn(Collections.emptyList()); + + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getExtensionId()).thenReturn(99L); + when(extensionResourceMapDao.listByResourceIdAndType(5L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(mapVO)); + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.findById(99L)).thenReturn(ext); + + Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.Network, "net-uuid"); + assertEquals(ext, result); + } + + @Test + public void getExtensionFromResourceReturnsNullForNetworkWithNullPhysicalNetworkId() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(10L); + when(network.getPhysicalNetworkId()).thenReturn(null); + when(entityManager.findByUuid(eq(Network.class), eq("net-uuid"))).thenReturn(network); + + Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.Network, "net-uuid"); + assertNull(result); + } + + // ----------------------------------------------------------------------- + // Tests for listExtensions with resourceId + resourceType (PhysicalNetwork) + // ----------------------------------------------------------------------- + + @Test + public void listExtensionsWithPhysicalNetworkResourceReturnsFilteredExtensions() { + ListExtensionsCmd cmd = mock(ListExtensionsCmd.class); + when(cmd.getResourceId()).thenReturn("pnet-uuid"); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getExtensionId()).thenReturn(null); + when(cmd.getName()).thenReturn(null); + when(cmd.getType()).thenReturn(null); + when(cmd.getDetails()).thenReturn(null); + + PhysicalNetworkVO physNet = mock(PhysicalNetworkVO.class); + when(physNet.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("pnet-uuid")).thenReturn(physNet); + + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getExtensionId()).thenReturn(100L); + when(extensionResourceMapDao.listByResourceIdAndType(42L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(mapVO)); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getType()).thenReturn(Extension.Type.NetworkOrchestrator); + when(extensionDao.findById(100L)).thenReturn(ext); + + ExtensionResponse resp = mock(ExtensionResponse.class); + doReturn(resp).when(extensionsManager).createExtensionResponse(eq(ext), any()); + + List result = extensionsManager.listExtensions(cmd); + assertEquals(1, result.size()); + assertEquals(resp, result.get(0)); + } + + @Test(expected = InvalidParameterValueException.class) + public void listExtensionsWithInvalidResourceTypeThrows() { + ListExtensionsCmd cmd = mock(ListExtensionsCmd.class); + when(cmd.getResourceId()).thenReturn("resource-uuid"); + when(cmd.getResourceType()).thenReturn("InvalidType"); + when(cmd.getExtensionId()).thenReturn(null); + when(cmd.getName()).thenReturn(null); + when(cmd.getType()).thenReturn(null); + + extensionsManager.listExtensions(cmd); + } + + // ----------------------------------------------------------------------- + // Tests for registerExtensionWithPhysicalNetwork + // ----------------------------------------------------------------------- + + @Test + public void registerExtensionWithPhysicalNetworkSucceeds() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("pnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getDetails()).thenReturn(null); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extension.getType()).thenReturn(Extension.Type.NetworkOrchestrator); + when(extension.getName()).thenReturn("my-ext"); + when(extension.getId()).thenReturn(1L); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physNet = mock(PhysicalNetworkVO.class); + when(physNet.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("pnet-uuid")).thenReturn(physNet); + + when(extensionResourceMapDao.listByResourceIdAndType(42L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(Collections.emptyList()); + when(extensionDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Collections.emptyMap()); + + ExtensionResourceMapVO savedMap = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.persist(any())).thenReturn(savedMap); + + when(physicalNetworkServiceProviderDao.findByServiceProvider(42L, "my-ext")).thenReturn(null); + + Extension result = extensionsManager.registerExtensionWithResource(cmd); + assertEquals(extension, result); + } + + @Test(expected = InvalidParameterValueException.class) + public void registerExtensionWithPhysicalNetworkFailsForNonNetworkOrchestratorType() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("pnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extension.getType()).thenReturn(Extension.Type.Orchestrator); + when(extension.getName()).thenReturn("orch-ext"); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physNet = mock(PhysicalNetworkVO.class); + when(physNet.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("pnet-uuid")).thenReturn(physNet); + + extensionsManager.registerExtensionWithResource(cmd); + } + + // ----------------------------------------------------------------------- + // Tests for getExtensionIdForPhysicalNetwork + // ----------------------------------------------------------------------- + + @Test + public void getExtensionIdForPhysicalNetworkReturnsIdWhenMapped() { + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getExtensionId()).thenReturn(55L); + when(extensionResourceMapDao.listByResourceIdAndType(10L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(mapVO)); + + Long result = extensionsManager.getExtensionIdForPhysicalNetwork(10L); + assertEquals(Long.valueOf(55L), result); + } + + @Test + public void getExtensionIdForPhysicalNetworkReturnsNullWhenNotMapped() { + when(extensionResourceMapDao.listByResourceIdAndType(10L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(Collections.emptyList()); + + Long result = extensionsManager.getExtensionIdForPhysicalNetwork(10L); + assertNull(result); + } + + // ----------------------------------------------------------------------- + // Tests for getExtensionForPhysicalNetworkAndProvider + // ----------------------------------------------------------------------- + + @Test + public void getExtensionForPhysicalNetworkAndProviderReturnsMatchingExtension() { + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getExtensionId()).thenReturn(10L); + when(extensionResourceMapDao.listByResourceIdAndType(5L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(mapVO)); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getName()).thenReturn("MyExt"); + when(extensionDao.findById(10L)).thenReturn(ext); + + Extension result = extensionsManager.getExtensionForPhysicalNetworkAndProvider(5L, "myext"); + assertEquals(ext, result); + } + + @Test + public void getExtensionForPhysicalNetworkAndProviderReturnsNullWhenNameDoesNotMatch() { + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getExtensionId()).thenReturn(10L); + when(extensionResourceMapDao.listByResourceIdAndType(5L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(mapVO)); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getName()).thenReturn("OtherExt"); + when(extensionDao.findById(10L)).thenReturn(ext); + + Extension result = extensionsManager.getExtensionForPhysicalNetworkAndProvider(5L, "myext"); + assertNull(result); + } + + @Test + public void getExtensionForPhysicalNetworkAndProviderReturnsNullForNullProviderName() { + Extension result = extensionsManager.getExtensionForPhysicalNetworkAndProvider(5L, null); + assertNull(result); + } + + // ----------------------------------------------------------------------- + // Tests for getAllResourceMapDetailsForExtensionOnPhysicalNetwork + // ----------------------------------------------------------------------- + + @Test + public void getAllResourceMapDetailsForExtensionOnPhysicalNetworkReturnsDetails() { + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getExtensionId()).thenReturn(10L); + when(mapVO.getId()).thenReturn(100L); + when(extensionResourceMapDao.listByResourceIdAndType(5L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(List.of(mapVO)); + when(extensionResourceMapDetailsDao.listDetailsKeyPairs(100L)) + .thenReturn(Map.of("host", "192.168.1.1")); + + Map result = extensionsManager.getAllResourceMapDetailsForExtensionOnPhysicalNetwork(5L, 10L); + assertEquals("192.168.1.1", result.get("host")); + } + + @Test + public void getAllResourceMapDetailsForExtensionOnPhysicalNetworkReturnsEmptyWhenNoMapping() { + when(extensionResourceMapDao.listByResourceIdAndType(5L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(Collections.emptyList()); + + Map result = extensionsManager.getAllResourceMapDetailsForExtensionOnPhysicalNetwork(5L, 10L); + assertTrue(result.isEmpty()); + } + + // ----------------------------------------------------------------------- + // Tests for isNetworkExtensionProvider + // ----------------------------------------------------------------------- + + @Test + public void isNetworkExtensionProviderReturnsTrueWhenProviderMatchesExtension() { + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getName()).thenReturn("my-ext"); + when(extensionDao.listByType(Extension.Type.NetworkOrchestrator)).thenReturn(List.of(ext)); + + assertTrue(extensionsManager.isNetworkExtensionProvider("my-ext")); + } + + @Test + public void isNetworkExtensionProviderReturnsFalseWhenNoMatch() { + when(extensionDao.listByType(Extension.Type.NetworkOrchestrator)).thenReturn(Collections.emptyList()); + assertFalse(extensionsManager.isNetworkExtensionProvider("unknown")); + } + + @Test + public void isNetworkExtensionProviderReturnsFalseForNullProvider() { + assertFalse(extensionsManager.isNetworkExtensionProvider(null)); + } + + // ----------------------------------------------------------------------- + // Tests for listExtensionsByType + // ----------------------------------------------------------------------- + + @Test + public void listExtensionsByTypeReturnsExtensionsForType() { + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.listByType(Extension.Type.NetworkOrchestrator)).thenReturn(List.of(ext)); + + List result = extensionsManager.listExtensionsByType(Extension.Type.NetworkOrchestrator); + assertEquals(1, result.size()); + assertEquals(ext, result.get(0)); + } + + @Test + public void listExtensionsByTypeReturnsEmptyForNullType() { + List result = extensionsManager.listExtensionsByType(null); + assertTrue(result.isEmpty()); + } + + @Test + public void listExtensionsByTypeReturnsEmptyWhenNoExtensions() { + when(extensionDao.listByType(Extension.Type.NetworkOrchestrator)).thenReturn(Collections.emptyList()); + List result = extensionsManager.listExtensionsByType(Extension.Type.NetworkOrchestrator); + assertTrue(result.isEmpty()); + } + + // ----------------------------------------------------------------------- + // Tests for getNetworkCapabilitiesForProvider + // ----------------------------------------------------------------------- + + @Test + public void getNetworkCapabilitiesForProviderReturnsCapabilitiesFromExtensionDetails() { + long physNetId = 10L; + String providerName = "my-ext"; + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getId()).thenReturn(5L); + doReturn(ext).when(extensionsManager).getExtensionForPhysicalNetworkAndProvider(physNetId, providerName); + + when(extensionDetailsDao.listDetailsKeyPairs(5L)).thenReturn(Map.of( + ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, + "SourceNat,StaticNat")); + + Map> result = + extensionsManager.getNetworkCapabilitiesForProvider(physNetId, providerName); + assertNotNull(result); + assertTrue(result.containsKey(Network.Service.SourceNat)); + } + + @Test + public void getNetworkCapabilitiesForProviderReturnsEmptyMapForNullProvider() { + Map> result = + extensionsManager.getNetworkCapabilitiesForProvider(10L, null); + assertTrue(result.isEmpty()); + } + + // ----------------------------------------------------------------------- + // Tests for validateNetworkServicesSubset + // ----------------------------------------------------------------------- + + @Test + public void validateNetworkServicesSubsetDoesNothingForBlankServices() { + Extension ext = mock(Extension.class); + extensionsManager.validateNetworkServicesSubset(ext, ""); + // no exception expected + } + + @Test + public void validateNetworkServicesSubsetDoesNothingWhenAllowedIsEmpty() { + Extension ext = mock(Extension.class); + when(ext.getId()).thenReturn(1L); + when(extensionDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Collections.emptyMap()); + extensionsManager.validateNetworkServicesSubset(ext, "SourceNat"); + // no exception - when no services declared, accept any + } + + @Test(expected = InvalidParameterValueException.class) + public void validateNetworkServicesSubsetThrowsForUnsupportedService() { + Extension ext = mock(Extension.class); + when(ext.getId()).thenReturn(1L); + when(ext.getName()).thenReturn("my-ext"); + when(extensionDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Map.of( + ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, + "SourceNat,Firewall")); + extensionsManager.validateNetworkServicesSubset(ext, "StaticNat"); + } + + @Test + public void validateNetworkServicesSubsetDoesNothingForValidSubset() { + Extension ext = mock(Extension.class); + when(ext.getId()).thenReturn(1L); + when(extensionDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Map.of( + ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, + "SourceNat,Firewall,StaticNat")); + extensionsManager.validateNetworkServicesSubset(ext, "SourceNat,Firewall"); + // no exception expected + } + @Test public void runNetworkCustomActionFailsWhenProviderReturnsNull() { Network network = mock(Network.class); diff --git a/server/src/test/java/com/cloud/network/NetworkModelImplTest.java b/server/src/test/java/com/cloud/network/NetworkModelImplTest.java index b70dc74bdb9b..4a027b2ae2d8 100644 --- a/server/src/test/java/com/cloud/network/NetworkModelImplTest.java +++ b/server/src/test/java/com/cloud/network/NetworkModelImplTest.java @@ -18,6 +18,9 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -54,6 +57,9 @@ import com.cloud.network.dao.NetworkServiceMapDao; import com.cloud.network.dao.NetworkServiceMapVO; import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkServiceProviderDao; +import com.cloud.network.dao.PhysicalNetworkServiceProviderVO; import com.cloud.network.element.NetworkElement; import com.cloud.network.element.VpcVirtualRouterElement; import com.cloud.network.vpc.VpcVO; @@ -67,6 +73,7 @@ import com.cloud.vm.NicProfile; import com.cloud.vm.VirtualMachine; import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.framework.extensions.network.NetworkExtensionElement; @RunWith(MockitoJUnitRunner.class) public class NetworkModelImplTest { @@ -84,6 +91,15 @@ public class NetworkModelImplTest { @Mock private ExtensionHelper extensionHelper; + @Mock + private NetworkExtensionElement networkExtensionElement; + + @Mock + private PhysicalNetworkDao physicalNetworkDao; + + @Mock + private PhysicalNetworkServiceProviderDao physicalNetworkServiceProviderDao; + @Spy @InjectMocks private NetworkModelImpl networkModel = new NetworkModelImpl(); @@ -100,6 +116,9 @@ public void setUp() { networkModel._ntwkSrvcDao = networkServiceMapDao; networkModel._ntwkOfferingSrvcDao = networkOfferingServiceMapDao; ReflectionTestUtils.setField(networkModel, "extensionHelper", extensionHelper); + ReflectionTestUtils.setField(networkModel, "networkExtensionElement", networkExtensionElement); + ReflectionTestUtils.setField(networkModel, "_physicalNetworkDao", physicalNetworkDao); + ReflectionTestUtils.setField(networkModel, "_pNSPDao", physicalNetworkServiceProviderDao); Mockito.lenient().when(extensionHelper.isNetworkExtensionProvider(Mockito.anyString())).thenReturn(false); } @@ -278,4 +297,116 @@ public void getNicProfile_validInputs_returnsNicProfile() { assertNotNull(result); } + + // ----------------------------------------------------------------------- + // Tests for getElementImplementingProvider with extension provider + // ----------------------------------------------------------------------- + + @Test + public void getElementImplementingProviderReturnsExtensionElementForExtensionProvider() { + String providerName = "my-ext-provider"; + // Provider is not in the static map, so element would be null + ReflectionTestUtils.setField(networkModel, "networkElements", new ArrayList<>()); + when(extensionHelper.isNetworkExtensionProvider(providerName)).thenReturn(true); + NetworkExtensionElement mockElement = mock(NetworkExtensionElement.class); + when(networkExtensionElement.withProviderName(providerName)).thenReturn(mockElement); + + NetworkElement result = networkModel.getElementImplementingProvider(providerName); + // When the element is a NetworkExtensionElement (which is a NetworkElement), result should not be null + assertNotNull(result); + } + + @Test + public void getElementImplementingProviderReturnsNullForUnknownNonExtensionProvider() { + String providerName = "unknown-provider"; + ReflectionTestUtils.setField(networkModel, "networkElements", new ArrayList<>()); + when(extensionHelper.isNetworkExtensionProvider(providerName)).thenReturn(false); + + NetworkElement result = networkModel.getElementImplementingProvider(providerName); + assertNull(result); + } + + // ----------------------------------------------------------------------- + // Tests for resolveProvider + // ----------------------------------------------------------------------- + + @Test + public void resolveProviderReturnsKnownProvider() { + Network.Provider result = networkModel.resolveProvider(Network.Provider.VirtualRouter.getName()); + assertNotNull(result); + assertEquals(Network.Provider.VirtualRouter, result); + } + + @Test + public void resolveProviderReturnsTransientProviderForExtensionProvider() { + String extensionName = "my-ext-network-provider"; + when(extensionHelper.isNetworkExtensionProvider(extensionName)).thenReturn(true); + + Network.Provider result = networkModel.resolveProvider(extensionName); + assertNotNull(result); + assertEquals(extensionName, result.getName()); + } + + @Test + public void resolveProviderReturnsNullForUnknownNonExtensionProvider() { + String providerName = "totally-unknown"; + when(extensionHelper.isNetworkExtensionProvider(providerName)).thenReturn(false); + + Network.Provider result = networkModel.resolveProvider(providerName); + assertNull(result); + } + + // ----------------------------------------------------------------------- + // Tests for canElementEnableIndividualServicesByName + // ----------------------------------------------------------------------- + + @Test + public void canElementEnableIndividualServicesByNameReturnsFalseForNullProvider() { + assertFalse(networkModel.canElementEnableIndividualServicesByName(null)); + } + + @Test + public void canElementEnableIndividualServicesByNameReturnsFalseForUnknownProvider() { + when(extensionHelper.isNetworkExtensionProvider("unknown")).thenReturn(false); + assertFalse(networkModel.canElementEnableIndividualServicesByName("unknown")); + } + + // ----------------------------------------------------------------------- + // Tests for getExternalProviderCapabilities + // ----------------------------------------------------------------------- + + @Test + public void getExternalProviderCapabilitiesCallsExtensionHelper() { + Map> caps = new HashMap<>(); + when(extensionHelper.getNetworkCapabilitiesForProvider(10L, "my-ext")).thenReturn(caps); + + Map> result = + networkModel.getExternalProviderCapabilities(10L, "my-ext"); + assertEquals(caps, result); + } + + // ----------------------------------------------------------------------- + // Tests for isServiceProvidedByNsp (via listSupportedNetworkServiceProviders) + // ----------------------------------------------------------------------- + + @Test + public void listSupportedNetworkServiceProvidersIncludesExtensionBackedProviders() { + com.cloud.network.dao.PhysicalNetworkVO physNet = mock(com.cloud.network.dao.PhysicalNetworkVO.class); + when(physNet.getId()).thenReturn(1L); + when(physicalNetworkDao.listAll()).thenReturn(List.of(physNet)); + + PhysicalNetworkServiceProviderVO nsp = mock(PhysicalNetworkServiceProviderVO.class); + when(nsp.getProviderName()).thenReturn("my-ext"); + when(physicalNetworkServiceProviderDao.listBy(1L)).thenReturn(List.of(nsp)); + when(extensionHelper.isNetworkExtensionProvider("my-ext")).thenReturn(true); + + // networkElements is empty so no standard providers found + ReflectionTestUtils.setField(networkModel, "networkElements", new ArrayList<>()); + + // We call with null service to test the inclusion path (parameter is service name String) + List result = networkModel.listSupportedNetworkServiceProviders(null); + + boolean found = result.stream().anyMatch(p -> "my-ext".equalsIgnoreCase(p.getName())); + assertTrue("Extension-backed provider should be included", found); + } } diff --git a/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java b/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java index 51b2dad3decd..15cdb8f1a518 100644 --- a/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java +++ b/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java @@ -47,7 +47,9 @@ import org.apache.cloudstack.api.command.user.network.UpdateNetworkCmd; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; +import org.apache.cloudstack.extension.ExtensionResourceMap; import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; import org.apache.cloudstack.network.RoutedIpv4Manager; import org.junit.After; import org.junit.Assert; @@ -238,6 +240,9 @@ public class NetworkServiceImplTest { @Mock private NsxProviderDao nsxProviderDao; + @Mock + ExtensionsManager extensionsManager; + private static Date beforeDate; private static Date afterDate; @@ -322,6 +327,7 @@ public void setup() throws Exception { service.networkHelper = networkHelper; service._ipAddrMgr = ipAddressManagerMock; service.nsxProviderDao = nsxProviderDao; + ReflectionTestUtils.setField(service, "extensionsManager", extensionsManager); callContextMocked = Mockito.mockStatic(CallContext.class); CallContext callContextMock = Mockito.mock(CallContext.class); callContextMocked.when(CallContext::current).thenReturn(callContextMock); @@ -1330,4 +1336,67 @@ public void addProjectNetworksConditionToSearch_includesSpecificProjectWhenProje Mockito.verify(accountJoin).addAnd("type", SearchCriteria.Op.EQ, Account.Type.PROJECT); Mockito.verify(sc).addAnd("id", SearchCriteria.Op.SC, accountJoin); } + + // ----------------------------------------------------------------------- + // Tests for updatePhysicalNetwork extension external details handling + // ----------------------------------------------------------------------- + + @Test + public void updatePhysicalNetworkUpdatesExtensionResourceMapDetailsWhenDetailsProvided() { + Long physNetId = 100L; + PhysicalNetworkVO physNet = Mockito.mock(PhysicalNetworkVO.class); + Mockito.when(physicalNetworkDao.findById(physNetId)).thenReturn(physNet); + Mockito.when(physicalNetworkDao.update(Mockito.anyLong(), any())).thenReturn(true); + + Map externalDetails = Map.of("host", "10.0.0.1", "port", "443"); + + ExtensionResourceMap resourceMap = Mockito.mock(ExtensionResourceMap.class); + Mockito.when(resourceMap.getId()).thenReturn(50L); + Pair pair = + new Pair<>(true, resourceMap); + Mockito.when(extensionsManager.extensionResourceMapDetailsNeedUpdate(physNetId, + ExtensionResourceMap.ResourceType.PhysicalNetwork, externalDetails)) + .thenReturn(pair); + + service.updatePhysicalNetwork(physNetId, null, null, null, null, externalDetails); + + Mockito.verify(extensionsManager).updateExtensionResourceMapDetails(50L, externalDetails); + } + + @Test + public void updatePhysicalNetworkDoesNotUpdateExtensionWhenNoDetailsChange() { + Long physNetId = 101L; + PhysicalNetworkVO physNet = Mockito.mock(PhysicalNetworkVO.class); + Mockito.when(physicalNetworkDao.findById(physNetId)).thenReturn(physNet); + Mockito.when(physicalNetworkDao.update(Mockito.anyLong(), any())).thenReturn(true); + + Map externalDetails = Map.of("host", "10.0.0.2"); + + Pair pair = + new Pair<>(false, null); + Mockito.when(extensionsManager.extensionResourceMapDetailsNeedUpdate(physNetId, + ExtensionResourceMap.ResourceType.PhysicalNetwork, externalDetails)) + .thenReturn(pair); + + service.updatePhysicalNetwork(physNetId, null, null, null, null, externalDetails); + + Mockito.verify(extensionsManager, Mockito.never()).updateExtensionResourceMapDetails(Mockito.anyLong(), any()); + } + + @Test + public void updatePhysicalNetworkLogsWarningWhenExtensionUpdateFailsButDoesNotThrow() { + Long physNetId = 102L; + PhysicalNetworkVO physNet = Mockito.mock(PhysicalNetworkVO.class); + Mockito.when(physicalNetworkDao.findById(physNetId)).thenReturn(physNet); + Mockito.when(physicalNetworkDao.update(Mockito.anyLong(), any())).thenReturn(true); + + Map externalDetails = Map.of("host", "10.0.0.3"); + + Mockito.when(extensionsManager.extensionResourceMapDetailsNeedUpdate(physNetId, + ExtensionResourceMap.ResourceType.PhysicalNetwork, externalDetails)) + .thenThrow(new RuntimeException("Test exception")); + + // Should not throw + service.updatePhysicalNetwork(physNetId, null, null, null, null, externalDetails); + } } diff --git a/server/src/test/java/com/cloud/network/firewall/FirewallManagerImplTest.java b/server/src/test/java/com/cloud/network/firewall/FirewallManagerImplTest.java new file mode 100644 index 000000000000..f90b424857ca --- /dev/null +++ b/server/src/test/java/com/cloud/network/firewall/FirewallManagerImplTest.java @@ -0,0 +1,179 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.network.firewall; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.Network; +import com.cloud.network.NetworkModel; +import com.cloud.network.dao.NetworkServiceMapDao; +import com.cloud.network.element.FirewallServiceProvider; +import com.cloud.network.element.NetworkElement; +import com.cloud.network.element.PortForwardingServiceProvider; +import com.cloud.network.rules.FirewallRule; +import com.cloud.network.rules.PortForwardingRule; + +@RunWith(MockitoJUnitRunner.Silent.class) +public class FirewallManagerImplTest { + + interface MockFwElement extends NetworkElement, FirewallServiceProvider {} + interface MockPfElement extends NetworkElement, PortForwardingServiceProvider {} + + @Spy + @InjectMocks + private FirewallManagerImpl firewallManager; + + @Mock + private NetworkModel _networkModel; + + @Mock + private NetworkServiceMapDao networkServiceMapDao; + + @Before + public void setUp() { + ReflectionTestUtils.setField(firewallManager, "_firewallElements", Collections.emptyList()); + ReflectionTestUtils.setField(firewallManager, "_pfElements", Collections.emptyList()); + ReflectionTestUtils.setField(firewallManager, "_staticNatElements", Collections.emptyList()); + ReflectionTestUtils.setField(firewallManager, "_networkAclElements", Collections.emptyList()); + } + + // ----------------------------------------------------------------------- + // Tests for applyRules with extension-backed Firewall provider + // ----------------------------------------------------------------------- + + @Test + public void applyRulesFirewallHandledByExtensionProvider() throws ResourceUnavailableException { + Network network = mock(Network.class); + when(network.getId()).thenReturn(1L); + + FirewallRule rule = mock(FirewallRule.class); + List rules = List.of(rule); + + // No standard firewall elements handle it + String extProviderName = "my-ext-fw-provider"; + when(networkServiceMapDao.getProviderForServiceInNetwork(1L, Network.Service.Firewall)) + .thenReturn(extProviderName); + + // The element implementing the provider is both NetworkElement and FirewallServiceProvider + MockFwElement element = mock(MockFwElement.class); + when(element.applyFWRules(eq(network), any())).thenReturn(true); + when(_networkModel.getElementImplementingProvider(extProviderName)).thenReturn(element); + + boolean result = firewallManager.applyRules(network, FirewallRule.Purpose.Firewall, rules); + assertTrue(result); + verify(element).applyFWRules(eq(network), any()); + } + + @Test + public void applyRulesFirewallReturnsFalseWhenNoExtensionProviderFound() throws ResourceUnavailableException { + Network network = mock(Network.class); + when(network.getId()).thenReturn(2L); + + FirewallRule rule = mock(FirewallRule.class); + List rules = List.of(rule); + + // No standard provider and no extension provider found + when(networkServiceMapDao.getProviderForServiceInNetwork(2L, Network.Service.Firewall)) + .thenReturn(null); + + boolean result = firewallManager.applyRules(network, FirewallRule.Purpose.Firewall, rules); + assertFalse(result); + verify(_networkModel, never()).getElementImplementingProvider(any()); + } + + // ----------------------------------------------------------------------- + // Tests for applyRules with extension-backed PortForwarding provider + // ----------------------------------------------------------------------- + + @Test + public void applyRulesPortForwardingHandledByExtensionProvider() throws ResourceUnavailableException { + Network network = mock(Network.class); + when(network.getId()).thenReturn(3L); + + PortForwardingRule rule = mock(PortForwardingRule.class); + @SuppressWarnings("unchecked") + List rules = List.of(rule); + + String extProviderName = "my-ext-pf-provider"; + when(networkServiceMapDao.getProviderForServiceInNetwork(3L, Network.Service.PortForwarding)) + .thenReturn(extProviderName); + + MockPfElement element = mock(MockPfElement.class); + when(element.applyPFRules(eq(network), any())).thenReturn(true); + when(_networkModel.getElementImplementingProvider(extProviderName)).thenReturn(element); + + boolean result = firewallManager.applyRules(network, FirewallRule.Purpose.PortForwarding, rules); + assertTrue(result); + verify(element).applyPFRules(eq(network), any()); + } + + @Test + public void applyRulesPortForwardingReturnsFalseWhenNoExtensionProviderFound() throws ResourceUnavailableException { + Network network = mock(Network.class); + when(network.getId()).thenReturn(4L); + + PortForwardingRule rule = mock(PortForwardingRule.class); + List rules = List.of(rule); + + when(networkServiceMapDao.getProviderForServiceInNetwork(4L, Network.Service.PortForwarding)) + .thenReturn(null); + + boolean result = firewallManager.applyRules(network, FirewallRule.Purpose.PortForwarding, rules); + assertFalse(result); + } + + // ----------------------------------------------------------------------- + // Tests for StaticNat (handled by Firewall elements) + // ----------------------------------------------------------------------- + + @Test + public void applyRulesStaticNatReturnsFalseWhenNoProviderFound() throws ResourceUnavailableException { + Network network = mock(Network.class); + when(network.getId()).thenReturn(5L); + + FirewallRule rule = mock(FirewallRule.class); + List rules = List.of(rule); + + when(networkServiceMapDao.getProviderForServiceInNetwork(5L, Network.Service.Firewall)) + .thenReturn(null); + + boolean result = firewallManager.applyRules(network, FirewallRule.Purpose.StaticNat, rules); + assertFalse(result); + } +} + diff --git a/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java b/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java index 2cb61b59d68f..26f5500eadd3 100644 --- a/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/vpc/VpcManagerImplTest.java @@ -43,6 +43,7 @@ import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.NetworkDao; import com.cloud.network.element.NetworkElement; +import com.cloud.network.element.VpcProvider; import com.cloud.network.router.CommandSetupHelper; import com.cloud.network.router.NetworkHelper; import com.cloud.network.router.VirtualRouter; @@ -72,6 +73,8 @@ import org.apache.cloudstack.api.command.user.vpc.UpdateVPCCmd; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.network.Ipv4GuestSubnetNetworkMap; import org.apache.cloudstack.network.RoutedIpv4Manager; @@ -90,6 +93,7 @@ import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -608,4 +612,49 @@ public void testIsNetworkOnVpcEnabledConserveModeVpcNetworkConserveMode() { Assert.assertTrue(manager.isNetworkOnVpcEnabledConserveMode(network)); } + // ----------------------------------------------------------------------- + // Tests for getVpcElements with extension-backed NetworkOrchestrator + // ----------------------------------------------------------------------- + + @Test + public void getVpcElementsIncludesExtensionBackedVpcProvider() { + manager.setVpcElements(null); + + Mockito.when(networkModel.getElementImplementingProvider(Provider.VPCVirtualRouter.getName())).thenReturn(null); + Mockito.when(networkModel.getElementImplementingProvider(Provider.JuniperContrailVpcRouter.getName())).thenReturn(null); + + Extension ext = mock(Extension.class); + Mockito.when(ext.getName()).thenReturn("my-vpc-ext"); + + ExtensionHelper extHelper = mock(ExtensionHelper.class); + Mockito.when(extHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) + .thenReturn(List.of(ext)); + manager.extensionHelper = extHelper; + + // The element for the extension also implements VpcProvider + VpcProvider vpcProviderElement = mock(VpcProvider.class); + Mockito.when(networkModel.getElementImplementingProvider("my-vpc-ext")).thenReturn((NetworkElement) vpcProviderElement); + + List result = manager.getVpcElements(); + Assert.assertNotNull(result); + Assert.assertTrue(result.contains(vpcProviderElement)); + } + + @Test + public void getVpcElementsReturnsEmptyListWhenNoStaticNorExtensionProviders() { + manager.setVpcElements(null); + + Mockito.when(networkModel.getElementImplementingProvider(Provider.VPCVirtualRouter.getName())).thenReturn(null); + Mockito.when(networkModel.getElementImplementingProvider(Provider.JuniperContrailVpcRouter.getName())).thenReturn(null); + + ExtensionHelper extHelper = mock(ExtensionHelper.class); + Mockito.when(extHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) + .thenReturn(Collections.emptyList()); + manager.extensionHelper = extHelper; + + List result = manager.getVpcElements(); + Assert.assertNotNull(result); + Assert.assertTrue(result.isEmpty()); + } + } From 02e415da26380e025ec6c1d08790d2e7b55909e4 Mon Sep 17 00:00:00 2001 From: Wei Zhou Date: Wed, 15 Apr 2026 15:39:20 +0200 Subject: [PATCH 4/4] gha: fix EOF again --- .../cloudstack/extension/CustomActionResultResponseTest.java | 1 - .../java/com/cloud/network/firewall/FirewallManagerImplTest.java | 1 - 2 files changed, 2 deletions(-) diff --git a/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java b/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java index dfb4e4092207..9b7346f332cb 100644 --- a/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java +++ b/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java @@ -90,4 +90,3 @@ public void setAndGetResultWithNullResult() { assertNull(response.getResult()); } } - diff --git a/server/src/test/java/com/cloud/network/firewall/FirewallManagerImplTest.java b/server/src/test/java/com/cloud/network/firewall/FirewallManagerImplTest.java index f90b424857ca..f1f653363a4a 100644 --- a/server/src/test/java/com/cloud/network/firewall/FirewallManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/firewall/FirewallManagerImplTest.java @@ -176,4 +176,3 @@ public void applyRulesStaticNatReturnsFalseWhenNoProviderFound() throws Resource assertFalse(result); } } -