Merge "Detect DevStack on remote nodes over SSH"

This commit is contained in:
Zuul authored and Gerrit Code Review committed 2026-10-02 09:16:06 +00:00
commit a23e891229
2 files changed
+130 -25

No files matched your search

@@ -54,20 +54,25 @@ def is_regex_in_file(regex, filename, re_flags=0, fail_no_file=True):
return result
def is_local_service(service_name):
"""Returns if local service exists"""
return bool(
shell.execute(
"systemctl list-unit-files --type service | "
f"grep '{service_name}'",
check=False
).stdout.strip())
def is_service(service_name, ssh_client=None):
"""Return whether a systemd service unit exists.
Runs the unit-file lookup locally by default, or over SSH when an
ssh_client (paramiko-based) is provided. The remote variant is useful
when Tempest is not co-located with the deployment (e.g. it runs on a
separate node and reaches the control plane over SSH).
"""
cmd = ("systemctl list-unit-files --type service | "
f"grep '{service_name}'")
if ssh_client is not None:
return bool(ssh_client.exec_command(cmd + "; true").strip())
return bool(shell.execute(cmd, check=False).stdout.strip())
def is_devstack_wsgi():
"""Returns if wsgi according to neutron api service name when used"""
# TODO(rxiao): remove this check when/if API check exists
return is_local_service('devstack@neutron-api')
return is_service('devstack@neutron-api')
def _parse_config_content(content):
@@ -59,13 +59,84 @@ ConfigOption = collections.namedtuple(
class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase):
credentials = ['primary', 'admin']
# Initial DevStack detection, evaluated locally at class definition time.
# This is authoritative when Tempest is co-located with the deployment.
# When Tempest runs on a separate node (configured_hosts set and no proxy
# host), these values are re-checked over SSH in
# _detect_any_node_devstack() during resource_setup().
if 'is_devstack' not in locals():
is_devstack = local_utils.is_local_service("devstack@")
is_devstack = local_utils.is_service("devstack@")
is_devstack_wsgi = is_devstack and local_utils.is_devstack_wsgi()
# SSH client that run_on_master_controller uses to reach the control
# plane when Tempest is not co-located with a DevStack deployment
# (configured_hosts set and no proxy host, e.g. the EVPN spine). It is
# populated during discover_nodes(). When None, run_on_master_controller
# runs commands locally (co-located DevStack) or via the proxy host
# (podified).
master_controller_client = None
@classmethod
def _detect_any_node_devstack(cls):
"""Refine DevStack detection by probing a remote node when needed.
The class-body check runs ``is_service("devstack@")`` on the
machine executing Tempest, which is authoritative when Tempest is
co-located with the deployment (DevStack all-in-one) or when it runs
on a podified Tempest pod (no local ``devstack@`` units).
It is wrong only on a remote-Tempest DevStack topology, e.g. the
EVPN spine: a DevStack multinode setup where Tempest runs off-box
and reaches the nodes only over SSH. Such setups provide
``configured_hosts`` and have no proxy host (``proxy_host_address``
is podified-only), so in that case probe the configured hosts over
SSH instead.
"""
# Skip the remote probe unless this is a remote-Tempest DevStack
# topology: already DevStack (co-located), no hosts to probe, or a
# proxy host is set (podified).
if (cls.is_devstack or
not WB_CONF.configured_hosts or
WB_CONF.proxy_host_address):
return
# Tempest runs on a separate DevStack node, so assume DevStack while
# building the node SSH clients (process_nodes_from_config selects
# keys based on is_devstack and honors per-host key/user overrides),
# then confirm by probing the nodes. Any DevStack node (controller or
# compute) exposes some "devstack@" systemd unit, and we do not yet
# know each node's role, so probe them all and treat the deployment
# as DevStack if any node matches.
cls.is_devstack = True
clients = [node['client']
for node in cls.process_nodes_from_config()]
cls.is_devstack = cls._any_node_has_service(clients, "devstack@")
cls.is_devstack_wsgi = cls.is_devstack and cls._any_node_has_service(
clients, "devstack@neutron-api")
LOG.info("Remote DevStack detection over %d configured host(s): "
"is_devstack=%s", len(clients), cls.is_devstack)
@staticmethod
def _any_node_has_service(clients, service_name):
"""Return True if any node exposes the systemd service.
SSH/command failures against a node are logged and treated as
"service not found" so that detection never raises.
"""
for client in clients:
try:
if local_utils.is_service(service_name, client):
return True
except Exception:
LOG.warning(
"Failed to probe node %s for service '%s'; treating it "
"as absent", getattr(client, 'host', '?'), service_name,
exc_info=True)
return False
@classmethod
def resource_setup(cls):
super(BaseTempestWhiteboxTestCase, cls).resource_setup()
cls._detect_any_node_devstack()
uri = (
CONF.identity.uri_v3 if CONF.identity.uri_v3
else CONF.identity.uri)
@@ -155,10 +226,16 @@ class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase):
def run_on_master_controller(cls, cmd, check=False, timeout=10):
LOG.debug("Command: %s", cmd)
if cls.is_devstack:
output, errors = local_utils.run_local_cmd(
cmd, timeout=timeout, check=check)
LOG.debug("Stderr: %s", errors.decode())
output = output.decode()
if cls.master_controller_client:
# DevStack without co-location (e.g. the EVPN spine): the
# control plane is reachable only over SSH.
output = cls.master_controller_client.exec_command(cmd)
else:
# Tempest is co-located with the DevStack controller.
output, errors = local_utils.run_local_cmd(
cmd, timeout=timeout, check=check)
LOG.debug("Stderr: %s", errors.decode())
output = output.decode()
else:
output = cls.proxy_host_client.exec_command(cmd)
LOG.debug("Output: %s", output)
@@ -336,20 +413,14 @@ class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase):
return nodes
@classmethod
def process_nodes_from_config(cls):
"""Process configured_hosts oslo configuration, create nodes_data.
def _load_configured_hosts(cls):
"""Parse the configured_hosts option into a list of host entries.
Parses configured_hosts, applies defaults, and creates SSH clients.
Returns list of dicts with: ip, user, key_file, hostname (optional),
ssh client.
Each entry is either an IP string or a dict with at least an 'ip'
key. Raises ValueError if the option is not a valid list literal.
"""
nodes_data = []
# Load safely nested objects definition for hosts from string
try:
loaded_hosts_list = ast.literal_eval(
WB_CONF.configured_hosts)
loaded_hosts_list = ast.literal_eval(WB_CONF.configured_hosts)
except (ValueError, SyntaxError) as e:
LOG.exception("Failed to parse configured_hosts")
raise ValueError(
@@ -360,6 +431,20 @@ class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase):
raise ValueError(
f"configured_hosts must be a list or tuple, "
f"got {type(loaded_hosts_list).__name__}")
return loaded_hosts_list
@classmethod
def process_nodes_from_config(cls):
"""Process configured_hosts oslo configuration, create nodes_data.
Parses configured_hosts, applies defaults, and creates SSH clients.
Returns list of dicts with: ip, user, key_file, hostname (optional),
ssh client.
"""
nodes_data = []
loaded_hosts_list = cls._load_configured_hosts()
# Log discovery method
LOG.info("Discovering nodes from configured_hosts configuration "
@@ -593,6 +678,21 @@ class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase):
LOG.info(info_msg, *node_counts)
LOG.debug(pprint.pformat(cls.nodes))
# Route run_on_master_controller through the discovered controller
# over SSH on DevStack. This is required when Tempest is not
# co-located with the control plane (e.g. the EVPN spine) and is
# harmless otherwise, since discover_nodes already reached these
# nodes over SSH.
if cls.is_devstack:
for node in cls.nodes:
if node['is_controller']:
cls.master_controller_client = node['client']
break
else:
LOG.warning(
"No controller discovered; run_on_master_controller "
"falls back to local execution")
@classmethod
def get_standalone_networkers(cls):
_networkers = [node['name'] for node in cls.nodes