diff --git a/api/src/main/java/com/cloud/network/Network.java b/api/src/main/java/com/cloud/network/Network.java index be8a45cbccb..2f0bcdd5ef9 100644 --- a/api/src/main/java/com/cloud/network/Network.java +++ b/api/src/main/java/com/cloud/network/Network.java @@ -116,6 +116,7 @@ public interface Network extends ControlledEntity, StateObject, I public static final Service NetworkACL = new Service("NetworkACL", Capability.SupportedProtocols); public static final Service Connectivity = new Service("Connectivity", Capability.DistributedRouter, Capability.RegionLevelVpc, Capability.StretchedL2Subnet, Capability.NoVlan, Capability.PublicAccess); + public static final Service CustomAction = new Service("CustomAction"); private final String name; private final Capability[] caps; 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 605cc8b6a79..245faa762a2 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java @@ -49,7 +49,8 @@ import com.google.gson.reflect.TypeToken; public interface ExtensionCustomAction extends InternalIdentity, Identity { enum ResourceType { VirtualMachine(com.cloud.vm.VirtualMachine.class), - Network(com.cloud.network.Network.class); + Network(com.cloud.network.Network.class), + Vpc(com.cloud.network.vpc.Vpc.class); private final Class clazz; 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 3f8754a168b..1c281c15f28 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java +++ b/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java @@ -20,13 +20,15 @@ package org.apache.cloudstack.extension; import java.util.Map; import com.cloud.network.Network; +import com.cloud.network.vpc.Vpc; /** * Implemented by network elements that support running custom actions on a - * managed network (e.g. NetworkExtensionElement). + * managed network or VPC (e.g. NetworkExtensionElement). * *

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

+ * {@code runCustomAction} requests whose resource type is {@code Network} + * or {@code Vpc}.

*/ public interface NetworkCustomActionProvider { @@ -39,6 +41,15 @@ public interface NetworkCustomActionProvider { */ boolean canHandleCustomAction(Network network); + /** + * Returns {@code true} if this provider can handle custom actions for + * the given VPC. + * + * @param vpc the target VPC + * @return {@code true} if this provider can handle the VPC + */ + boolean canHandleVpcCustomAction(Vpc vpc); + /** * Runs a named custom action against the external network device that * manages the given network. @@ -49,4 +60,15 @@ public interface NetworkCustomActionProvider { * @return output from the action script, or {@code null} on failure */ String runCustomAction(Network network, String actionName, Map parameters); + + /** + * Runs a named custom action against the external network device that + * manages the given VPC. + * + * @param vpc the CloudStack VPC on which to run the action + * @param actionName the action name + * @param parameters optional parameters supplied by the caller + * @return output from the action script, or {@code null} on failure + */ + String runCustomAction(Vpc vpc, String actionName, Map parameters); } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkServiceProviderVO.java b/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkServiceProviderVO.java index 9557c7465bf..217ee8ebc66 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkServiceProviderVO.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkServiceProviderVO.java @@ -97,6 +97,9 @@ public class PhysicalNetworkServiceProviderVO implements PhysicalNetworkServiceP @Column(name = "networkacl_service_provided") boolean networkAclServiceProvided; + @Column(name = "custom_action_service_provided") + boolean customActionServiceProvided; + @Column(name = GenericDao.REMOVED_COLUMN) Date removed; @@ -278,6 +281,7 @@ public class PhysicalNetworkServiceProviderVO implements PhysicalNetworkServiceP this.setUserdataServiceProvided(services.contains(Service.UserData)); this.setSecuritygroupServiceProvided(services.contains(Service.SecurityGroup)); this.setNetworkAclServiceProvided(services.contains(Service.NetworkACL)); + this.setCustomActionServiceProvided(services.contains(Service.CustomAction)); } @Override @@ -316,6 +320,9 @@ public class PhysicalNetworkServiceProviderVO implements PhysicalNetworkServiceP if (this.isSecuritygroupServiceProvided()) { services.add(Service.SecurityGroup); } + if (this.isCustomActionServiceProvided()) { + services.add(Service.CustomAction); + } return services; } @@ -327,4 +334,12 @@ public class PhysicalNetworkServiceProviderVO implements PhysicalNetworkServiceP public void setNetworkAclServiceProvided(boolean networkAclServiceProvided) { this.networkAclServiceProvided = networkAclServiceProvided; } + + public boolean isCustomActionServiceProvided() { + return customActionServiceProvided; + } + + public void setCustomActionServiceProvided(boolean customActionServiceProvided) { + this.customActionServiceProvided = customActionServiceProvided; + } } 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 81a86f68d49..b3f56cf5d69 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 @@ -135,3 +135,6 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc', 'keep_mac_address_on_public_ni -- 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)'); + +-- Add CustomAction service support to physical_network_service_providers +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.physical_network_service_providers', 'custom_action_service_provided', 'tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT "Is Custom Action service provided"'); 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 1c9efdaca78..08f375a3922 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,7 +70,7 @@ 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") + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, description = "Type of the extension (e.g. Orchestrator, NetworkOrchestrator)") private String type; @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, @@ -78,7 +78,7 @@ public class ListExtensionsCmd extends BaseListCmd { 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") + description = "Type of the resource (e.g. Cluster, PhysicalNetwork)") private String resourceType; ///////////////////////////////////////////////////// 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 734c98956fa..70b5e07d414 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 @@ -141,6 +141,8 @@ 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.network.vpc.Vpc; +import com.cloud.network.vpc.dao.VpcServiceMapDao; import com.cloud.org.Cluster; import com.cloud.serializer.GsonHelper; import com.cloud.storage.dao.VMTemplateDao; @@ -242,6 +244,9 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana @Inject NetworkServiceMapDao networkServiceMapDao; + @Inject + VpcServiceMapDao vpcServiceMapDao; + @Inject NetworkModel networkModel; @@ -472,6 +477,19 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana return null; } return extensionDao.findById(maps.get(0).getExtensionId()); + } else if (resourceType == ExtensionCustomAction.ResourceType.Vpc) { + com.cloud.network.vpc.Vpc vpc = (com.cloud.network.vpc.Vpc) object; + // Find extension via the VPC's tier networks + List tierNetworks = networkDao.listByVpc(vpc.getId()); + if (CollectionUtils.isNotEmpty(tierNetworks)) { + for (NetworkVO tierNetwork : tierNetworks) { + Extension ext = getExtensionFromResource(ExtensionCustomAction.ResourceType.Network, tierNetwork.getUuid()); + if (ext != null) { + return ext; + } + } + } + return null; } return null; } @@ -1103,19 +1121,13 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana if (Boolean.TRUE.equals(cleanupDetails)) { extensionResourceMapDetailsDao.removeDetails(targetMapping.getId()); - } else { + } else if (MapUtils.isNotEmpty(details)) { List detailsList = buildExtensionResourceDetailsArray(targetMapping.getId(), details); - if (CollectionUtils.isNotEmpty(detailsList)) { - appendHiddenExtensionResourceDetails(targetMapping.getId(), 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()); - } + extensionResourceMapDetailsDao.saveDetails(detailsList); } return extensionDao.findById(extensionId); @@ -1222,7 +1234,7 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana + "with services {}", extension.getName(), physicalNetwork.getId(), services); } - return extensionMap; + return savedExtensionMap; }); } @@ -1335,6 +1347,7 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana nsp.setVpnServiceProvided(services.contains("Vpn")); nsp.setSecuritygroupServiceProvided(services.contains("SecurityGroup")); nsp.setNetworkAclServiceProvided(services.contains("NetworkACL")); + nsp.setCustomActionServiceProvided(services.contains("CustomAction")); } /** Keys that are always stored with display=false (sensitive). */ @@ -1463,7 +1476,7 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana 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)", + + "Provider is used by %d existing network service(s)", ext.getName(), physNetId, networksUsingProvider.size())); } } @@ -1953,6 +1966,10 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana // Network custom action: dispatched directly to NetworkCustomActionProvider (no agent) Network network = (Network) entity; return runNetworkCustomAction(network, customActionVO, extensionVO, actionResourceType, cmdParameters); + } else if (entity instanceof Vpc) { + // VPC custom action: find a tier network and dispatch to the same NetworkCustomActionProvider + Vpc vpc = (Vpc) entity; + return runVpcCustomAction(vpc, customActionVO, extensionVO, actionResourceType, cmdParameters); } if (clusterId == null || hostId == null) { @@ -2061,9 +2078,9 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana parameters = ExtensionCustomAction.Parameter.validateParameterValues(actionParameters, cmdParameters); } - // Find the provider name for this network (try each service until we find one) + // Find the provider name for this network (try CustomAction first, then other services) String providerName = null; - for (Service service : new Service[]{Service.SourceNat, Service.StaticNat, + for (Service service : new Service[]{Service.CustomAction, Service.SourceNat, Service.StaticNat, Service.PortForwarding, Service.Firewall, Service.Gateway}) { providerName = networkServiceMapDao.getProviderForServiceInNetwork(network.getId(), service); if (StringUtils.isNotBlank(providerName)) { @@ -2115,6 +2132,91 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana return response; } + /** + * Executes a custom action for a VPC resource by finding the VPC's + * extension provider and dispatching directly to it (no tier network lookup). + */ + protected CustomActionResultResponse runVpcCustomAction(Vpc vpc, + 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, vpc)); + + // 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 VPC + String providerName = null; + for (Service service : new Service[]{Service.CustomAction, Service.SourceNat, Service.StaticNat, + Service.PortForwarding, Service.NetworkACL, Service.Gateway}) { + providerName = vpcServiceMapDao.getProviderForServiceInVpc(vpc.getId(), service); + if (StringUtils.isNotBlank(providerName)) { + break; + } + } + if (StringUtils.isBlank(providerName)) { + logger.error("No VPC service provider found for VPC {}", vpc.getId()); + result.put(ApiConstants.DETAILS, "No VPC service provider found for this VPC"); + 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 VPC {}", providerName, vpc.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 VPC 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.canHandleVpcCustomAction(vpc)) { + throw new CloudRuntimeException("Provider '" + providerName + "' cannot handle custom action for this VPC"); + } + logger.info("Running VPC custom action '{}' on VPC {} via {} (provider: {})", + actionName, vpc.getId(), element.getClass().getSimpleName(), providerName); + String output = provider.runCustomAction(vpc, actionName, parameters); + boolean success = output != null; + response.setSuccess(success); + result.put(ApiConstants.MESSAGE, getActionMessage(success, customActionVO, extensionVO, actionResourceType, vpc)); + result.put(ApiConstants.DETAILS, success ? output : "Action failed — check management server logs for details"); + } catch (Exception e) { + logger.error("VPC 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(), 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 index ead27b0450f..3ad4a661c5e 100644 --- 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 @@ -979,6 +979,7 @@ public class NetworkExtensionElement extends AdapterBase implements File payloadFile = null; try { payloadFile = File.createTempFile("cs-extnet-" + command + "-", ".payload"); + logger.debug("Writing payload {} to payload file {}", payload, payloadFile); Files.writeString(payloadFile.toPath(), payload != null ? payload : "", StandardCharsets.UTF_8); List cmdArgs = new ArrayList<>(); @@ -1083,7 +1084,7 @@ public class NetworkExtensionElement extends AdapterBase implements @Override public boolean canHandleCustomAction(Network network) { - return canHandle(network, null); + return canHandle(network, Service.CustomAction); } /** @@ -1109,6 +1110,7 @@ public class NetworkExtensionElement extends AdapterBase implements cmdLine.add("custom-action"); cmdLine.add("--network-id"); cmdLine.add(String.valueOf(network.getId())); + cmdLine.addAll(getVpcIdArgs(network)); cmdLine.add("--action"); cmdLine.add(actionName); cmdLine.add(ARG_ACTION_PARAMS); @@ -1144,6 +1146,69 @@ public class NetworkExtensionElement extends AdapterBase implements } } + @Override + public boolean canHandleVpcCustomAction(Vpc vpc) { + return resolveExtensionForVpc(vpc) != null; + } + + /** + * Runs a custom action on the external network device for a VPC. + * The script receives {@code --vpc-id} (no {@code --network-id}). + */ + @Override + public String runCustomAction(Vpc vpc, String actionName, Map parameters) { + Pair physNetAndExt = resolveExtensionForVpc(vpc); + if (physNetAndExt == null) { + throw new CloudRuntimeException("No extension found for VPC " + vpc.getId()); + } + Long physicalNetworkId = physNetAndExt.first(); + Extension extension = physNetAndExt.second(); + File scriptFile = resolveScriptFileForVpc(physicalNetworkId, extension); + + String physicalNetworkDetailsJson = buildPhysicalNetworkDetailsJson(physicalNetworkId, extension); + String vpcExtDetailsJson = getVpcExtensionDetailsJson(vpc.getId()); + String actionParamsJson = buildActionParamsJson(parameters); + + List cmdLine = new ArrayList<>(); + cmdLine.add(scriptFile.getAbsolutePath()); + cmdLine.add("custom-action"); + cmdLine.add("--vpc-id"); + cmdLine.add(String.valueOf(vpc.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(vpcExtDetailsJson); + + logger.info("Running custom action '{}' on VPC {} (extension: {}, params: {} key(s))", + actionName, vpc.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 VPC custom action script: {}", String.join(" ", cmdLine)); + + if (exitCode != 0) { + logger.error("VPC custom action '{}' failed (exit {}): {}", actionName, exitCode, outputStr); + return null; + } + logger.info("VPC custom action '{}' completed successfully", actionName); + return outputStr.isEmpty() ? "OK" : outputStr; + } catch (Exception e) { + logger.error("Failed to execute VPC custom action '{}': {}", actionName, e.getMessage(), e); + throw new CloudRuntimeException("Failed to execute VPC custom action: " + actionName, e); + } + } + /** * Serialises custom-action parameters to a compact JSON object string. * Returns {@code {}} for null or empty maps. 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 7356bffb5a9..77feb1bad62 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 @@ -2569,6 +2569,7 @@ public class ExtensionsManagerImplTest { when(extensionDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Collections.emptyMap()); ExtensionResourceMapVO savedMap = mock(ExtensionResourceMapVO.class); + when(savedMap.getExtensionId()).thenReturn(1L); when(extensionResourceMapDao.persist(any())).thenReturn(savedMap); when(physicalNetworkServiceProviderDao.findByServiceProvider(42L, "my-ext")).thenReturn(null); diff --git a/setup/db/create-schema.sql b/setup/db/create-schema.sql index 10141185eb4..d47f3009b8f 100755 --- a/setup/db/create-schema.sql +++ b/setup/db/create-schema.sql @@ -2120,6 +2120,7 @@ CREATE TABLE `cloud`.`physical_network_service_providers` ( `user_data_service_provided` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT 'Is UserData service provided', `security_group_service_provided` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT 'Is SG service provided', `networkacl_service_provided` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT 'Is Network ACL service provided', + `custom_action_service_provided` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT 'Is Custom Action service provided', `removed` datetime COMMENT 'date removed if not null', PRIMARY KEY (`id`), CONSTRAINT `fk_pnetwork_service_providers__physical_network_id` FOREIGN KEY (`physical_network_id`) REFERENCES `physical_network`(`id`) ON DELETE CASCADE, diff --git a/test/integration/smoke/test_network_extension_namespace.py b/test/integration/smoke/test_network_extension_namespace.py index f4c4e9da9cc..be3ed29163f 100644 --- a/test/integration/smoke/test_network_extension_namespace.py +++ b/test/integration/smoke/test_network_extension_namespace.py @@ -97,7 +97,7 @@ ENTRY_POINT_SCRIPT_LOCAL = os.path.join(_SCRIPT_CACHE_DIR, ENTRY_POINT_FILENAME) # Tests select a subset when creating NetworkOfferings. NETWORK_SERVICES = ( "Dhcp,Dns,UserData," - "SourceNat,StaticNat,PortForwarding,Firewall,Lb,NetworkACL" + "SourceNat,StaticNat,PortForwarding,Firewall,Lb,NetworkACL,CustomAction" ) # Per-service capabilities JSON object (no "services" wrapper). @@ -143,6 +143,9 @@ NETWORK_SERVICE_CAPABILITIES_JSON = json.dumps({ }, "NetworkACL": { "SupportedProtocols": "tcp,udp,icmp" + }, + "CustomAction": { + "Supported": "true" } }) @@ -385,8 +388,9 @@ class TestNetworkExtensionNamespace(cloudstackTestCase): (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_08 — custom-action smoke for Policy-Based Routing (PBR) actions on isolated network test_09 — VPC source NAT IP update without VPC restart + test_10 — custom-action smoke for Policy-Based Routing (PBR) actions on VPC tier network """ @staticmethod @@ -2167,7 +2171,7 @@ class TestNetworkExtensionNamespace(cloudstackTestCase): """ self._check_kvm_host_prerequisites(['ip', 'arping', 'dnsmasq', 'haproxy']) - svc = "SourceNat,PortForwarding,Dhcp,Dns,UserData" + svc = "SourceNat,PortForwarding,Dhcp,Dns,UserData,CustomAction" nw_offering, _ext_name = self._setup_extension_nsp_offering( "extnet-pbr", supported_services=svc) _account, network, vm = self._create_account_network_vm( @@ -2296,11 +2300,20 @@ class TestNetworkExtensionNamespace(cloudstackTestCase): 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() + try: + vm.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm] + except Exception: + pass + try: + network.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != network] + except Exception: + pass + try: + self._teardown_extension() + except Exception: + pass @attr(tags=["advanced", "smoke"], required_hardware="true") def test_09_vpc_source_nat_ip_update(self): @@ -2339,6 +2352,7 @@ class TestNetworkExtensionNamespace(cloudstackTestCase): }) 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_prov = {s.strip(): ext_name for s in svc.split(',')} @@ -2350,6 +2364,7 @@ class TestNetworkExtensionNamespace(cloudstackTestCase): }) self.cleanup.append(vpc_offering) vpc_offering.update(self.apiclient, state='Enabled') + self.logger.info("VPC offering '%s' enabled", vpc_offering.name) account = Account.create( self.apiclient, @@ -2371,6 +2386,7 @@ class TestNetworkExtensionNamespace(cloudstackTestCase): domainid=account.domainid ) self.cleanup.insert(0, vpc) + self.logger.info("VPC created: %s (%s)", vpc.name, vpc.id) tier = Network.create( self.apiclient, @@ -2586,3 +2602,241 @@ class TestNetworkExtensionNamespace(cloudstackTestCase): self._teardown_extension() except Exception: pass + + @attr(tags=["advanced", "smoke"], required_hardware="true") + def test_10_vpc_custom_action_policy_based_routing(self): + """Custom-action smoke test for PBR lifecycle helpers on a VPC. + + Same as test_08 but exercised against a VPC instead of + an isolated network. Verifies that network custom actions work + correctly in a VPC context: + - routing tables + - routes per table + - policy rules + """ + self._check_kvm_host_prerequisites(['ip', 'arping', 'dnsmasq', 'haproxy']) + + svc = "SourceNat,PortForwarding,Dhcp,Dns,UserData,NetworkACL,CustomAction" + _nw_offering, ext_name = self._setup_extension_nsp_offering( + "extnet-vpc-pbr", supported_services=svc, for_vpc=True) + + # ---- 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-PBR-%s" % random_gen(), + "displaytext": "ExtNet VPC tier offering for PBR", + "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-PBR-%s" % random_gen(), + "displaytext": "ExtNet VPC offering for PBR", + "supportedservices": svc, + "serviceProviderList": _vpc_prov, + }) + self.cleanup.append(vpc_offering) + vpc_offering.update(self.apiclient, state='Enabled') + + suffix = random_gen() + account = Account.create( + self.apiclient, + self.services["account"], + admin=True, + domainid=self.domain.id + ) + self.cleanup.append(account) + + vpc = VPC.create( + self.apiclient, + {"name": "extnet-vpc-pbr-%s" % suffix, + "displaytext": "ExtNet VPC PBR %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) + + tier = Network.create( + self.apiclient, + {"name": "tier-pbr-%s" % suffix, + "displaytext": "Tier PBR %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, tier) + + svc_offering = ServiceOffering.list(self.apiclient, issystem=False)[0] + vm = VirtualMachine.create( + self.apiclient, + {"displayname": "vm-pbr-%s" % suffix, + "name": "vm-pbr-%s" % suffix, + "zoneid": self.zone.id}, + accountid=account.name, + domainid=account.domainid, + serviceofferingid=svc_offering.id, + templateid=self.template.id, + networkids=[tier.id] + ) + self.cleanup.insert(0, vm) + + 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="VPC PBR smoke: %s" % name, + resourcetype='Vpc', + 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=vpc.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=vpc.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=vpc.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=vpc.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=vpc.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=vpc.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=vpc.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=vpc.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=vpc.id, + parameters=[{"table-name": table_name}], + ) + self.assertTrue(getattr(out, 'success', False), "pbr-delete-table should succeed") + + self.logger.info("test_10 PASSED") + finally: + for action in actions: + try: + action.delete(self.apiclient) + except Exception: + pass + try: + vm.delete(self.apiclient, expunge=True) + self.cleanup = [o for o in self.cleanup if o != vm] + except Exception: + pass + try: + tier.delete(self.apiclient) + self.cleanup = [o for o in self.cleanup if o != tier] + except Exception: + pass + try: + 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/src/config/section/network.js b/ui/src/config/section/network.js index 3e1f965f332..8a9b1c5c331 100644 --- a/ui/src/config/section/network.js +++ b/ui/src/config/section/network.js @@ -118,6 +118,14 @@ export default { name: 'network.permissions', component: shallowRef(defineAsyncComponent(() => import('@/views/network/NetworkPermissions.vue'))), show: (record, route, user) => { return 'listNetworkPermissions' in store.getters.apis && record.acltype === 'Account' && !('vpcid' in record) && (['Admin', 'DomainAdmin'].includes(user.roletype) || record.account === user.account) && !record.projectid } + }, { + name: 'custom.actions', + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/RunCustomAction.vue'))), + show: (record) => { + return 'runCustomAction' in store.getters.apis && + 'listCustomActions' in store.getters.apis && + record.service && record.service.some(s => s.name === 'CustomAction') + } }, { name: 'events', @@ -215,8 +223,7 @@ export default { 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')) + record.service && record.service.some(s => s.name === 'CustomAction') }, popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/extension/RunCustomAction.vue'))) diff --git a/ui/src/views/network/VpcTab.vue b/ui/src/views/network/VpcTab.vue index 12e21cd8ec9..1a73342d576 100644 --- a/ui/src/views/network/VpcTab.vue +++ b/ui/src/views/network/VpcTab.vue @@ -405,6 +405,9 @@ + + + @@ -433,6 +436,7 @@ import AnnotationsTab from '@/components/view/AnnotationsTab' import ResourceIcon from '@/components/view/ResourceIcon' import BgpPeersTab from '@/views/infra/zone/BgpPeersTab.vue' import StaticRoutesTab from './StaticRoutesTab' +import RunCustomAction from '@/views/extension/RunCustomAction' export default { name: 'VpcTab', @@ -445,6 +449,7 @@ export default { VpcTiersTab, VnfAppliancesTab, StaticRoutesTab, + RunCustomAction, EventsTab, AnnotationsTab, ResourceIcon diff --git a/ui/src/views/offering/AddNetworkOffering.vue b/ui/src/views/offering/AddNetworkOffering.vue index 366d4639f60..6f69e70e350 100644 --- a/ui/src/views/offering/AddNetworkOffering.vue +++ b/ui/src/views/offering/AddNetworkOffering.vue @@ -1079,7 +1079,7 @@ export default { ...(!this.forVpc && { Firewall: this.Netris }) } } else if (this.isExternalNetworkProvider) { - // Extension-backed provider: services come from the extension's network.service.capabilities. + // Extension-backed provider: services come from the extension's network.services detail. // this.provider is the extension name (= NSP name) const extProviderObj = { name: this.provider, @@ -1087,7 +1087,7 @@ export default { enabled: true } const svcMap = { Dhcp: this.VR, Dns: this.VR, UserData: this.VR } - // Infer services from the selected extension's network.service.capabilities detail + // Infer services from the selected extension's network.services detail const extDef = this.availableExtensionProviders.find(e => e.name === this.provider) const services = this._getExtensionServices(extDef) if (services.length > 0) { @@ -1110,13 +1110,13 @@ export default { this.fetchSupportedServiceData() }, _getExtensionServices (extDef) { - if (!extDef || !extDef.details || !extDef.details['network.service.capabilities']) return [] - try { - const caps = JSON.parse(extDef.details['network.service.capabilities']) - return (caps && caps.services) ? caps.services : [] - } catch (e) { - return [] + if (!extDef || !extDef.details) return [] + + const servicesCsv = extDef.details['network.services'] + if (servicesCsv && typeof servicesCsv === 'string') { + return servicesCsv.split(',').map(x => x.trim()).filter(x => x.length > 0) } + return [] }, handleForNetworkModeChange (networkMode) { this.networkmode = networkMode diff --git a/ui/src/views/offering/AddVpcOffering.vue b/ui/src/views/offering/AddVpcOffering.vue index ef8235412ec..2831c84dbec 100644 --- a/ui/src/views/offering/AddVpcOffering.vue +++ b/ui/src/views/offering/AddVpcOffering.vue @@ -686,18 +686,6 @@ export default { return [] } - const capsJson = extDef.details['network.service.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) @@ -725,7 +713,7 @@ export default { 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' + 'Gateway', 'Lb', 'StaticNat', 'SourceNat', 'NetworkACL', 'PortForwarding', 'Vpn', 'CustomAction' ]) services.forEach(service => {