network extension: add service CustomAction

This commit is contained in:
Wei Zhou 2026-04-16 18:34:02 +02:00
parent 5a3fdc0485
commit eb300ca1e7
15 changed files with 515 additions and 50 deletions

View File

@ -116,6 +116,7 @@ public interface Network extends ControlledEntity, StateObject<Network.State>, 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;

View File

@ -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;

View File

@ -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).
*
* <p>This interface is looked up by {@code ExtensionsManagerImpl} to dispatch
* {@code runCustomAction} requests whose resource type is {@code Network}.</p>
* {@code runCustomAction} requests whose resource type is {@code Network}
* or {@code Vpc}.</p>
*/
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<String, Object> 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<String, Object> parameters);
}

View File

@ -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;
}
}

View File

@ -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"');

View File

@ -70,7 +70,7 @@ public class ListExtensionsCmd extends BaseListCmd {
+ " When no parameters are passed, all the details are returned.")
private List<String> 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;
/////////////////////////////////////////////////////

View File

@ -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<NetworkVO> 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<ExtensionResourceMapDetailsVO> 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<String, String> cmdParameters) {
final String actionName = customActionVO.getName();
CustomActionResultResponse response = new CustomActionResultResponse();
response.setId(customActionVO.getUuid());
response.setName(actionName);
response.setObjectName("customactionresult");
Map<String, String> result = new HashMap<>();
response.setSuccess(false);
result.put(ApiConstants.MESSAGE, getActionMessage(false, customActionVO, extensionVO, actionResourceType, vpc));
// Resolve action parameters
List<ExtensionCustomAction.Parameter> actionParameters = null;
Pair<Map<String, String>, Map<String, String>> allDetails =
extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(customActionVO.getId());
if (allDetails.second().containsKey(ApiConstants.PARAMETERS)) {
actionParameters = ExtensionCustomAction.Parameter.toListFromJson(
allDetails.second().get(ApiConstants.PARAMETERS));
}
Map<String, Object> 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(),

View File

@ -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<String> 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<String, Object> parameters) {
Pair<Long, Extension> 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<String> 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.

View File

@ -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);

View File

@ -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,

View File

@ -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

View File

@ -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')))

View File

@ -405,6 +405,9 @@
<a-tab-pane :tab="$t('label.vnf.appliances')" key="vnf" v-if="'deployVnfAppliance' in $store.getters.apis">
<VnfAppliancesTab :resource="resource" :loading="loading" />
</a-tab-pane>
<a-tab-pane :tab="$t('label.custom.actions')" key="customactions" v-if="'runCustomAction' in $store.getters.apis && 'listCustomActions' in $store.getters.apis && resource.service && resource.service.some(s => s.name === 'CustomAction')">
<RunCustomAction :resource="resource" />
</a-tab-pane>
<a-tab-pane :tab="$t('label.events')" key="events" v-if="'listEvents' in $store.getters.apis">
<events-tab :resource="resource" resourceType="Vpc" :loading="loading" />
</a-tab-pane>
@ -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

View File

@ -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

View File

@ -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 => {