mirror of
https://github.com/apache/cloudstack
synced 2026-08-02 05:26:35 +00:00
This PR introduces and wires a new extension model for external network orchestration in CloudStack, centered on a new extension type: NetworkOrchestrator. It extends the extension lifecycle from cluster-only registration to physical-network registration, adds API support for updating registered extension metadata, and enables automatic offering creation (network and VPC) based on provider-declared supported services and capabilities. It also adds smoke coverage (KVM-only) using a Linux network namespace based implementation. Doc PR: apache/cloudstack-documentation#649 What's new 1) New extension type: NetworkOrchestrator Adds support for creating extensions of type NetworkOrchestrator. Intended to back CloudStack network/VPC operations via an external orchestrator/provider. 2) Register extension with PhysicalNetwork (in addition to Cluster) Extensions can now be registered against a PhysicalNetwork resource. This enables network service provider behavior at physical-network scope, not only cluster scope. 3) Physical network registration details support Registered extension details for PhysicalNetwork are handled similarly to cluster registration details. Supports storing/updating external access metadata (credentials/endpoints/config details). 4) New API: update registered extension Adds API support to update extension registration metadata after registration. Useful for rotating credentials, updating endpoints, and changing external connection properties without re-registering. 5) Offering automation from external provider capabilities Network/VPC offerings can be created with the external network provider using: provider supportedservices per-service service capabilities This allows CloudStack offerings to align with what the external provider actually supports. 6) Network support via generated offerings Using offerings backed by the external provider, networks can be created and operated with supported services/capabilities. Supported operations include (based on provider capabilities): Source NAT Static NAT Port Forwarding Firewall Load Balancing DHCP DNS UserData 7) VPC support via generated offerings Using VPC offerings backed by the external provider, VPCs and tiers can be created and operated with supported services/capabilities. Supported operations include (based on provider capabilities): VPC tier creation/implementation Source NAT in VPC context Static NAT / Port Forwarding / LB on VPC tiers Network ACL association and ACL rule apply paths Related lifecycle/restart/reapply operations 8) Linux network namespace based external implementation Adds/uses a network extension implementation based on Linux network namespaces. Reference implementation: https://github.com/apache/cloudstack-extensions/tree/network-namespace/Network-Namespace 9) Smoke test coverage (KVM-only) Adds smoke tests using the namespace-based extension implementation. Scope includes provider lifecycle, offering creation, network/VPC flows, and key network services. Applicable hypervisor for this smoke suite: KVM.
3309 lines
141 KiB
Python
3309 lines
141 KiB
Python
# 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,
|
||
createGuestNetworkIpv6Prefix,
|
||
listGuestNetworkIpv6Prefixes,
|
||
deleteGuestNetworkIpv6Prefix)
|
||
from marvin.lib.base import (Account,
|
||
Configurations,
|
||
Extension,
|
||
ExtensionCustomAction,
|
||
Host,
|
||
LoadBalancerRule,
|
||
Network,
|
||
NetworkACL,
|
||
NetworkACLList,
|
||
NetworkOffering,
|
||
NATRule,
|
||
NIC,
|
||
PublicIPAddress,
|
||
PublicIpRange,
|
||
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,CustomAction"
|
||
)
|
||
|
||
# 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"
|
||
},
|
||
"CustomAction": {
|
||
"Supported": "true"
|
||
}
|
||
})
|
||
|
||
ISOLATED_NETWORK_SERVICES = (
|
||
"Dhcp,Dns,UserData,"
|
||
"SourceNat,StaticNat,PortForwarding,Firewall,Lb,CustomAction"
|
||
)
|
||
|
||
VPC_NETWORK_SERVICES = (
|
||
"Dhcp,Dns,UserData,"
|
||
"SourceNat,StaticNat,PortForwarding,Lb,NetworkACL,CustomAction"
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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/<name>/<name>.sh
|
||
KVM host (wrapper): /etc/cloudstack/extensions/<name>/<name>-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 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
|
||
test_11 โ IPv6 dualstack isolated network: namespace IPv6 address + radvd
|
||
"""
|
||
|
||
@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 ''
|
||
|
||
def _run_custom_action_logged(self, action, resourceid, parameters=None):
|
||
"""Run a custom action and log the request/response for smoke debugging."""
|
||
action_name = getattr(action, 'name', None) or getattr(action, 'customactionname', None) or str(action)
|
||
action_id = getattr(action, 'id', None)
|
||
self.logger.info(
|
||
"runCustomAction command: action=%s id=%s resourceid=%s parameters=%s",
|
||
action_name,
|
||
action_id,
|
||
resourceid,
|
||
parameters if parameters is not None else []
|
||
)
|
||
if parameters is None:
|
||
out = action.run(self.apiclient, resourceid=resourceid)
|
||
else:
|
||
out = action.run(self.apiclient, resourceid=resourceid, parameters=parameters)
|
||
result = getattr(out, 'result', None)
|
||
message = ''
|
||
if isinstance(result, dict):
|
||
message = result.get('message', '') or ''
|
||
elif hasattr(result, '__dict__'):
|
||
message = getattr(result, 'message', '') or ''
|
||
self.logger.info(
|
||
"runCustomAction output: action=%s success=%s message=%s details=%s raw=%s",
|
||
action_name,
|
||
getattr(out, 'success', None),
|
||
message,
|
||
self._custom_action_details(out),
|
||
out
|
||
)
|
||
return out
|
||
|
||
@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/<name>/<name>.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/<name>/<name>-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/<name>/<name>.sh
|
||
# kvm: /etc/cloudstack/extensions/<name>/<name>-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
|
||
offering_params["specifyVlan"] = 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,
|
||
"vlan": "vlan://%d" % (3000 + 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 โ assert SSH blocked (no FW rule)
|
||
โ create FW rule (TCP/22) โ assert SSH works
|
||
โ remove FW rule โ assert SSH blocked (static NAT still active)
|
||
โ re-add FW rule โ 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 = ISOLATED_NETWORK_SERVICES
|
||
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)
|
||
self._assert_vm_ssh_not_accessible(
|
||
snat_ip, 22,
|
||
"SSH via %s:22 should fail before firewall rule is created" % snat_ip)
|
||
|
||
# Explicit FW rule required โ static NAT does not auto-create one
|
||
snat_fw_rule_id = 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)
|
||
|
||
# Remove the FW rule while static NAT is still active โ VM must be blocked
|
||
self._delete_firewall_rule(snat_fw_rule_id)
|
||
self._assert_vm_ssh_not_accessible(
|
||
snat_ip, 22,
|
||
"SSH via %s:22 should fail after firewall rule removed (static NAT still active)"
|
||
% snat_ip)
|
||
self.logger.info("Verified: SSH blocked after FW rule removed (static NAT active)")
|
||
|
||
# Re-add the FW rule โ VM must be reachable again
|
||
self._create_firewall_rule_for_ssh(snat_ip_id)
|
||
self._assert_vm_ssh_accessible(
|
||
snat_ip, 22,
|
||
"SSH via %s:22 should succeed after firewall rule re-added" % snat_ip)
|
||
self.logger.info("Verified: SSH restored after FW rule re-added")
|
||
|
||
# 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) ----
|
||
_nw_offering, ext_name = self._setup_extension_nsp_offering(
|
||
"extnet-vpc", for_vpc=True)
|
||
|
||
# ---- VPC tier network offering (useVpc=on) ----
|
||
vpc_tier_svc = VPC_NETWORK_SERVICES
|
||
_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 = VPC_NETWORK_SERVICES
|
||
_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) ----
|
||
_nw_offering, ext_name = self._setup_extension_nsp_offering(
|
||
"extnet-acl", for_vpc=True)
|
||
|
||
# ---- VPC tier network offering (useVpc=on, with NetworkACL support) ----
|
||
vpc_tier_svc = VPC_NETWORK_SERVICES
|
||
_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 = VPC_NETWORK_SERVICES
|
||
_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 and o != acl1 and o != acl2]
|
||
|
||
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 = ISOLATED_NETWORK_SERVICES
|
||
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 = self._run_custom_action_logged(
|
||
act_create_table,
|
||
resourceid=network.id,
|
||
parameters=[{"table-id": "100", "table-name": table_name}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-create-table should succeed")
|
||
|
||
out = self._run_custom_action_logged(act_list_tables, 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 = self._run_custom_action_logged(
|
||
act_add_route,
|
||
resourceid=network.id,
|
||
parameters=[{"table": table_name, "route": "blackhole %s" % route_cidr}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-add-route should succeed")
|
||
|
||
out = self._run_custom_action_logged(
|
||
act_list_routes,
|
||
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 = self._run_custom_action_logged(
|
||
act_add_rule,
|
||
resourceid=network.id,
|
||
parameters=[{"table": table_name, "rule": "to %s" % route_cidr}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-add-rule should succeed")
|
||
|
||
out = self._run_custom_action_logged(
|
||
act_list_rules,
|
||
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 = self._run_custom_action_logged(
|
||
act_delete_rule,
|
||
resourceid=network.id,
|
||
parameters=[{"table": table_name, "rule": "to %s" % route_cidr}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-delete-rule should succeed")
|
||
|
||
out = self._run_custom_action_logged(
|
||
act_delete_route,
|
||
resourceid=network.id,
|
||
parameters=[{"table": table_name, "route": "blackhole %s" % route_cidr}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-delete-route should succeed")
|
||
|
||
out = self._run_custom_action_logged(
|
||
act_delete_table,
|
||
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
|
||
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):
|
||
"""Update VPC source NAT IP and verify old/new source NAT flags flip correctly."""
|
||
self._check_kvm_host_prerequisites(['arping'])
|
||
|
||
svc = VPC_NETWORK_SERVICES
|
||
_nw_offering, ext_name = self._setup_extension_nsp_offering(
|
||
"extnet-vpc-snat-update", 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')
|
||
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(',')}
|
||
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')
|
||
self.logger.info("VPC offering '%s' enabled", vpc_offering.name)
|
||
|
||
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)
|
||
self.logger.info("VPC created: %s (%s)", vpc.name, vpc.id)
|
||
|
||
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
|
||
|
||
@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 = VPC_NETWORK_SERVICES
|
||
_nw_offering, ext_name = self._setup_extension_nsp_offering(
|
||
"extnet-vpc-pbr", 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 = self._run_custom_action_logged(
|
||
act_create_table,
|
||
resourceid=vpc.id,
|
||
parameters=[{"table-id": "100", "table-name": table_name}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-create-table should succeed")
|
||
|
||
out = self._run_custom_action_logged(act_list_tables, 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 = self._run_custom_action_logged(
|
||
act_add_route,
|
||
resourceid=vpc.id,
|
||
parameters=[{"table": table_name, "route": "blackhole %s" % route_cidr}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-add-route should succeed")
|
||
|
||
out = self._run_custom_action_logged(
|
||
act_list_routes,
|
||
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 = self._run_custom_action_logged(
|
||
act_add_rule,
|
||
resourceid=vpc.id,
|
||
parameters=[{"table": table_name, "rule": "to %s" % route_cidr}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-add-rule should succeed")
|
||
|
||
out = self._run_custom_action_logged(
|
||
act_list_rules,
|
||
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 = self._run_custom_action_logged(
|
||
act_delete_rule,
|
||
resourceid=vpc.id,
|
||
parameters=[{"table": table_name, "rule": "to %s" % route_cidr}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-delete-rule should succeed")
|
||
|
||
out = self._run_custom_action_logged(
|
||
act_delete_route,
|
||
resourceid=vpc.id,
|
||
parameters=[{"table": table_name, "route": "blackhole %s" % route_cidr}],
|
||
)
|
||
self.assertTrue(getattr(out, 'success', False), "pbr-delete-route should succeed")
|
||
|
||
out = self._run_custom_action_logged(
|
||
act_delete_table,
|
||
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
|
||
|
||
# ------------------------------------------------------------------
|
||
# IPv6 helper: ensure a guest IPv6 prefix exists for the zone
|
||
# ------------------------------------------------------------------
|
||
|
||
def _ensure_guest_ipv6_prefix(self):
|
||
"""Return an existing or newly created guest IPv6 prefix for the zone.
|
||
|
||
Uses ``services["guestip6prefix"]["prefix"]`` when available; falls
|
||
back to ``fd17:ac56:1234:1000::/52``. Returns ``(prefix_obj, created)``
|
||
where *created* is True when this call created the prefix (the caller
|
||
is responsible for deleting it on teardown).
|
||
"""
|
||
cmd = listGuestNetworkIpv6Prefixes.listGuestNetworkIpv6PrefixesCmd()
|
||
cmd.zoneid = self.zone.id
|
||
existing = self.apiclient.listGuestNetworkIpv6Prefixes(cmd)
|
||
if isinstance(existing, list) and existing:
|
||
self.logger.info("Found existing guest IPv6 prefix: %s",
|
||
existing[0].prefix)
|
||
return existing[0], False
|
||
|
||
prefix_str = (self.services
|
||
.get("guestip6prefix", {})
|
||
.get("prefix", "fd17:ac56:1234:1000::/52"))
|
||
cmd = createGuestNetworkIpv6Prefix.createGuestNetworkIpv6PrefixCmd()
|
||
cmd.zoneid = self.zone.id
|
||
cmd.prefix = prefix_str
|
||
prefix_obj = self.apiclient.createGuestNetworkIpv6Prefix(cmd)
|
||
self.logger.info("Created guest IPv6 prefix: %s", prefix_str)
|
||
return prefix_obj, True
|
||
|
||
# ------------------------------------------------------------------
|
||
# IPv6 helper: ensure a public IPv6 IP range exists with a VLAN
|
||
# ------------------------------------------------------------------
|
||
|
||
def _ensure_public_ipv6_range(self):
|
||
"""Return an existing or newly created public IPv6 IP range for the zone.
|
||
|
||
CloudStack requires a public IP range that has both IPv4 and IPv6
|
||
entries sharing the same VLAN for dualstack isolated networks to work.
|
||
This helper:
|
||
1. Returns immediately if an existing range already has ``ip6cidr``
|
||
and ``ip6gateway`` set.
|
||
2. Otherwise extracts the VLAN from the first IPv4 public range and
|
||
creates a new IPv6 range using ``services["publicip6range"]``.
|
||
|
||
Returns ``(range_obj, created)`` where *created* is True when this call
|
||
created the range (the caller is responsible for deleting it on
|
||
teardown).
|
||
"""
|
||
ip_ranges = PublicIpRange.list(self.apiclient, zoneid=self.zone.id)
|
||
ipv4_range_vlan = None
|
||
if isinstance(ip_ranges, list):
|
||
for ip_range in ip_ranges:
|
||
if getattr(ip_range, 'ip6cidr', None) and \
|
||
getattr(ip_range, 'ip6gateway', None):
|
||
self.logger.info(
|
||
"Found existing public IPv6 range: cidr=%s",
|
||
ip_range.ip6cidr)
|
||
return ip_range, False
|
||
if getattr(ip_range, 'netmask', None) and \
|
||
getattr(ip_range, 'gateway', None):
|
||
vlan = getattr(ip_range, 'vlan', None) or ''
|
||
if ipv4_range_vlan is None and vlan.startswith("vlan://"):
|
||
vlan_id = vlan.replace("vlan://", "")
|
||
if vlan_id == "untagged":
|
||
ipv4_range_vlan = None
|
||
else:
|
||
try:
|
||
ipv4_range_vlan = int(vlan_id)
|
||
except ValueError:
|
||
ipv4_range_vlan = None
|
||
|
||
ip6range_services = dict(self.services.get("publicip6range", {}))
|
||
ip6range_services["zoneid"] = self.zone.id
|
||
ip6range_services["vlan"] = ipv4_range_vlan
|
||
ip6range_obj = PublicIpRange.create(self.apiclient, ip6range_services)
|
||
self.logger.info(
|
||
"Created public IPv6 range: cidr=%s vlan=%s",
|
||
ip6range_services.get("ip6cidr"), ipv4_range_vlan)
|
||
return ip6range_obj, True
|
||
|
||
# ------------------------------------------------------------------
|
||
# Test 11: IPv6 dualstack isolated network
|
||
# ------------------------------------------------------------------
|
||
|
||
@attr(tags=["advanced", "smoke"], required_hardware="true")
|
||
def test_11_ipv6_dualstack_isolated_network(self):
|
||
"""IPv6 dualstack isolated network: namespace address and radvd.
|
||
|
||
Creates an isolated network with ``internetprotocol=dualstack`` backed
|
||
by the network-namespace extension and verifies end-to-end IPv6
|
||
provisioning:
|
||
|
||
A. API attribute verification
|
||
network receives ``ip6cidr`` and ``ip6gateway``
|
||
VM NIC receives ``ip6address`` (EUI-64 via SLAAC)
|
||
B. KVM namespace verification
|
||
IPv6 gateway address is present on the guest veth inside the
|
||
namespace
|
||
radvd is running inside the namespace with a valid config
|
||
(``AdvSendAdvert on``, ``AdvAutonomous on``, correct prefix)
|
||
|
||
Prerequisites
|
||
-------------
|
||
* KVM hosts reachable and have ``dnsmasq``, ``arping``, ``radvd``
|
||
installed.
|
||
* Zone has (or can have) a guest IPv6 prefix configured.
|
||
* Global setting ``ipv6.offering.enabled`` can be set to ``true``.
|
||
"""
|
||
self._check_kvm_host_prerequisites(['arping', 'dnsmasq', 'radvd'])
|
||
|
||
# ---- Preserve and enable ipv6.offering.enabled ----
|
||
initial_ipv6_cfg = None
|
||
try:
|
||
cfg_list = Configurations.list(self.apiclient,
|
||
name="ipv6.offering.enabled")
|
||
if cfg_list:
|
||
initial_ipv6_cfg = cfg_list[0].value
|
||
Configurations.update(self.apiclient, "ipv6.offering.enabled",
|
||
"true")
|
||
self.logger.info("ipv6.offering.enabled set to true "
|
||
"(was: %s)", initial_ipv6_cfg)
|
||
except Exception as e:
|
||
self.skipTest("Cannot enable ipv6.offering.enabled: %s" % e)
|
||
|
||
# ---- Ensure a guest IPv6 prefix exists ----
|
||
ip6prefix_obj = None
|
||
ip6prefix_created = False
|
||
try:
|
||
ip6prefix_obj, ip6prefix_created = self._ensure_guest_ipv6_prefix()
|
||
except Exception as e:
|
||
if initial_ipv6_cfg is not None:
|
||
try:
|
||
Configurations.update(self.apiclient,
|
||
"ipv6.offering.enabled",
|
||
initial_ipv6_cfg)
|
||
except Exception:
|
||
pass
|
||
self.skipTest("Cannot set up guest IPv6 prefix: %s" % e)
|
||
|
||
# ---- Ensure a public IPv6 range exists (same VLAN as IPv4) ----
|
||
ip6pubrange_obj = None
|
||
ip6pubrange_created = False
|
||
try:
|
||
ip6pubrange_obj, ip6pubrange_created = \
|
||
self._ensure_public_ipv6_range()
|
||
except Exception as e:
|
||
if initial_ipv6_cfg is not None:
|
||
try:
|
||
Configurations.update(self.apiclient,
|
||
"ipv6.offering.enabled",
|
||
initial_ipv6_cfg)
|
||
except Exception:
|
||
pass
|
||
if ip6prefix_created and ip6prefix_obj:
|
||
try:
|
||
del_cmd = (deleteGuestNetworkIpv6Prefix
|
||
.deleteGuestNetworkIpv6PrefixCmd())
|
||
del_cmd.id = ip6prefix_obj.id
|
||
self.apiclient.deleteGuestNetworkIpv6Prefix(del_cmd)
|
||
except Exception:
|
||
pass
|
||
self.skipTest("Cannot set up public IPv6 range: %s" % e)
|
||
|
||
svc = ISOLATED_NETWORK_SERVICES
|
||
|
||
try:
|
||
# ---- Extension + NSP (no offering here; we create a dualstack one) ----
|
||
_, ext_name = self._setup_extension_nsp_offering(
|
||
"extnet-ip6iso", supported_services=svc, for_vpc=True)
|
||
|
||
# ---- Dualstack network offering ----
|
||
_provider_map = {s.strip(): ext_name for s in svc.split(',')}
|
||
ds_offering_params = {
|
||
"name": "ExtNet-IP6-Offering-%s" % random_gen(),
|
||
"displaytext": "ExtNet IPv6 dualstack offering",
|
||
"guestiptype": "Isolated",
|
||
"traffictype": "GUEST",
|
||
"internetprotocol": "dualstack",
|
||
"supportedservices": svc,
|
||
"serviceProviderList": _provider_map,
|
||
"serviceCapabilityList": {
|
||
"SourceNat": {"SupportedSourceNatTypes": "peraccount"},
|
||
},
|
||
}
|
||
nw_offering = NetworkOffering.create(self.apiclient, ds_offering_params)
|
||
self.cleanup.append(nw_offering)
|
||
nw_offering.update(self.apiclient, state='Enabled')
|
||
self.logger.info("Dualstack offering '%s' enabled", nw_offering.name)
|
||
|
||
# ---- Account + network + VM ----
|
||
account, network, vm = self._create_account_network_vm(
|
||
nw_offering, name_suffix="ip6iso")
|
||
|
||
# ==========================================================
|
||
# A. API: verify network and VM NIC get IPv6 attributes
|
||
# ==========================================================
|
||
self.logger.info("--- Sub-test A: API IPv6 attribute verification ---")
|
||
|
||
nw_list = Network.list(self.apiclient, id=network.id, listall=True)
|
||
self.assertIsInstance(nw_list, list,
|
||
"Network.list returned no list")
|
||
self.assertGreater(len(nw_list), 0, "Network not found after creation")
|
||
nw = nw_list[0]
|
||
|
||
self.assertIsNotNone(nw.ip6cidr,
|
||
"Network %s should have ip6cidr (dualstack offering)" % network.id)
|
||
self.assertIsNotNone(nw.ip6gateway,
|
||
"Network %s should have ip6gateway (dualstack offering)" % network.id)
|
||
ip6cidr = nw.ip6cidr
|
||
ip6gateway = nw.ip6gateway
|
||
self.logger.info("Network IPv6: gateway=%s cidr=%s",
|
||
ip6gateway, ip6cidr)
|
||
|
||
nics = NIC.list(self.apiclient,
|
||
virtualmachineid=vm.id,
|
||
networkid=network.id)
|
||
self.assertIsInstance(nics, list, "NIC.list returned no list")
|
||
self.assertGreater(len(nics), 0, "VM NIC not found")
|
||
vm_ip6 = getattr(nics[0], 'ip6address', None)
|
||
self.assertIsNotNone(vm_ip6,
|
||
"VM NIC should have ip6address (SLAAC) after deploy")
|
||
self.logger.info("VM NIC IPv6: %s", vm_ip6)
|
||
self.logger.info("Sub-test A PASSED")
|
||
|
||
# ==========================================================
|
||
# B. KVM namespace: IPv6 address on veth + radvd running
|
||
# ==========================================================
|
||
self.logger.info("--- Sub-test B: KVM namespace IPv6 verification ---")
|
||
|
||
# The network namespace lives on the KVM host chosen by
|
||
# ensure-network-device, which is NOT necessarily the same host that
|
||
# runs the VM. Search every configured KVM host for the ip6-gateway
|
||
# state file written by config-dhcp-subnet.
|
||
self.assertTrue(self.kvm_host_configs,
|
||
"No KVM hosts configured โ cannot verify namespace state")
|
||
|
||
prefix_size = ip6cidr.split('/')[-1]
|
||
|
||
kvm_ssh = None
|
||
kvm_host_ip = None
|
||
found_path = None
|
||
discover_cmd = (
|
||
"for f in /var/lib/cloudstack/*/network-*/ip6-gateway; do "
|
||
"[ -f \"$f\" ] && grep -qF '%(gw)s' \"$f\" "
|
||
"&& echo \"$f\" && break; done" % {"gw": ip6gateway}
|
||
)
|
||
for h in self.kvm_host_configs:
|
||
h_ip = h.get('ip', '')
|
||
h_user = h.get('username', 'root')
|
||
h_pass = h.get('password', '')
|
||
if not h_ip:
|
||
continue
|
||
try:
|
||
ssh = SshClient(h_ip, 22, h_user, h_pass)
|
||
out = ssh.execute(discover_cmd)
|
||
fp = next((l.strip() for l in out if l.strip()), None)
|
||
if fp:
|
||
kvm_ssh = ssh
|
||
kvm_host_ip = h_ip
|
||
found_path = fp
|
||
self.logger.info(
|
||
"Found ip6-gateway state on KVM host %s: %s",
|
||
h_ip, fp)
|
||
break
|
||
except Exception as e:
|
||
self.logger.warning(
|
||
"Could not search KVM host %s: %s", h_ip, e)
|
||
|
||
self.assertIsNotNone(
|
||
found_path,
|
||
"No ip6-gateway state file containing %s found on any "
|
||
"configured KVM host (searched: %s)"
|
||
% (ip6gateway,
|
||
", ".join(h.get('ip', '?')
|
||
for h in self.kvm_host_configs)))
|
||
|
||
state_dir = found_path.rsplit('/ip6-gateway', 1)[0]
|
||
ns_id = state_dir.split('/')[-1].replace('network-', '')
|
||
ns_name = "cs-net-" + ns_id
|
||
radvd_conf_path = "%s/radvd/radvd.conf" % state_dir
|
||
radvd_pid_path = "%s/radvd/radvd.pid" % state_dir
|
||
self.logger.info("Discovered state_dir=%s namespace=%s",
|
||
state_dir, ns_name)
|
||
|
||
# B1. Namespace exists
|
||
ns_out = kvm_ssh.execute(
|
||
"ip netns list 2>/dev/null | grep -c '^%s' || echo 0" % ns_name)
|
||
self.assertTrue(
|
||
any(line.strip() not in ('', '0') for line in ns_out),
|
||
"Namespace '%s' not found on KVM host %s" % (ns_name, kvm_host_ip))
|
||
self.logger.info("B1 PASSED: namespace '%s' exists", ns_name)
|
||
|
||
# B2. IPv6 gateway address is present on the guest veth
|
||
ip6_out = kvm_ssh.execute(
|
||
"ip netns exec '%s' ip -6 addr show 2>/dev/null"
|
||
" | grep '%s' || true" % (ns_name, ip6gateway))
|
||
self.assertTrue(
|
||
any(ip6gateway in line for line in ip6_out),
|
||
"IPv6 gateway %s not found on any interface in namespace '%s'"
|
||
% (ip6gateway, ns_name))
|
||
self.logger.info("B2 PASSED: IPv6 gateway %s present in namespace",
|
||
ip6gateway)
|
||
|
||
# B3. radvd.conf exists and is non-empty
|
||
conf_check = kvm_ssh.execute(
|
||
"test -s '%s' && echo RADVD_CONF_OK"
|
||
" || echo RADVD_CONF_MISSING" % radvd_conf_path)
|
||
self.assertTrue(
|
||
any("RADVD_CONF_OK" in line for line in conf_check),
|
||
"radvd.conf not found or empty at %s" % radvd_conf_path)
|
||
self.logger.info("B3 PASSED: radvd.conf present at %s",
|
||
radvd_conf_path)
|
||
|
||
# B4. radvd.conf has correct directives and prefix size
|
||
conf_lines = kvm_ssh.execute("cat '%s' 2>/dev/null" % radvd_conf_path)
|
||
conf_text = "\n".join(conf_lines)
|
||
self.assertIn("AdvSendAdvert on", conf_text,
|
||
"radvd.conf missing 'AdvSendAdvert on'")
|
||
self.assertIn("AdvAutonomous on", conf_text,
|
||
"radvd.conf missing 'AdvAutonomous on'")
|
||
self.assertIn("/" + prefix_size, conf_text,
|
||
"radvd.conf missing prefix size '/%s'" % prefix_size)
|
||
self.logger.info(
|
||
"B4 PASSED: radvd.conf directives correct "
|
||
"(AdvSendAdvert on, AdvAutonomous on, /%s)", prefix_size)
|
||
|
||
# B5. radvd process is running inside the namespace
|
||
radvd_run = kvm_ssh.execute(
|
||
"pid=$(cat '%(pid)s' 2>/dev/null) && [ -n \"$pid\" ] && "
|
||
"ip netns exec '%(ns)s' ls /proc/$pid/status >/dev/null 2>&1 && "
|
||
"echo RADVD_RUNNING || echo RADVD_STOPPED"
|
||
% {"pid": radvd_pid_path, "ns": ns_name})
|
||
self.assertTrue(
|
||
any("RADVD_RUNNING" in line for line in radvd_run),
|
||
"radvd not running inside namespace '%s' (pid file: %s)"
|
||
% (ns_name, radvd_pid_path))
|
||
self.logger.info("B5 PASSED: radvd is running inside namespace '%s'",
|
||
ns_name)
|
||
|
||
self.logger.info(
|
||
"Sub-test B PASSED: ns=%s gw=%s radvd=running",
|
||
ns_name, ip6gateway)
|
||
|
||
finally:
|
||
# Restore ipv6.offering.enabled
|
||
if initial_ipv6_cfg is not None:
|
||
try:
|
||
Configurations.update(self.apiclient,
|
||
"ipv6.offering.enabled",
|
||
initial_ipv6_cfg)
|
||
self.logger.info("Restored ipv6.offering.enabled to %s",
|
||
initial_ipv6_cfg)
|
||
except Exception as e:
|
||
self.logger.warning(
|
||
"Could not restore ipv6.offering.enabled: %s", e)
|
||
|
||
# Remove public IPv6 range if this test created it
|
||
if ip6pubrange_created and ip6pubrange_obj:
|
||
try:
|
||
ip6pubrange_obj.delete(self.apiclient)
|
||
self.logger.info("Deleted public IPv6 range %s",
|
||
getattr(ip6pubrange_obj, 'ip6cidr', ''))
|
||
except Exception as e:
|
||
self.logger.warning(
|
||
"Could not delete public IPv6 range: %s", e)
|
||
|
||
# Remove IPv6 prefix if this test created it
|
||
if ip6prefix_created and ip6prefix_obj:
|
||
try:
|
||
del_cmd = (deleteGuestNetworkIpv6Prefix
|
||
.deleteGuestNetworkIpv6PrefixCmd())
|
||
del_cmd.id = ip6prefix_obj.id
|
||
self.apiclient.deleteGuestNetworkIpv6Prefix(del_cmd)
|
||
self.logger.info("Deleted guest IPv6 prefix %s",
|
||
ip6prefix_obj.prefix)
|
||
except Exception as e:
|
||
self.logger.warning(
|
||
"Could not delete guest IPv6 prefix: %s", e)
|
||
|
||
# ---- Explicit ordered cleanup ----
|
||
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
|
||
|
||
self.logger.info("test_11 PASSED")
|