diff --git a/spec/plans/guest-topology.fmf b/spec/plans/guest-topology.fmf index 1a31be13a5..2a5888ee64 100644 --- a/spec/plans/guest-topology.fmf +++ b/spec/plans/guest-topology.fmf @@ -20,6 +20,14 @@ description: | The shell-friendly file contains arrays, therefore it's compatible with Bash 4.x and newer. + .. note:: + + The ``hardware`` key, describing the actual HW configuration of the + guest, as understood by tmt, is using + :ref:`hardware specification ` + and as such is still a work in progress. Not all hardware properties + might be available yet. + .. note:: The shell-friendly file is easy to ingest for a shell-based tests, @@ -34,6 +42,7 @@ description: | name: ... role: ... hostname: ... + hardware: ... # List of names of all provisioned guests. guest-names: @@ -48,10 +57,12 @@ description: | name: guest1 role: ... hostname: ... + hardware: ... guest2: name: guest2 role: ... hostname: ... + hardware: ... ... # List of all known roles. @@ -102,6 +113,9 @@ description: | TMT_ROLES[role2]="guestN ..." ... + .. versionadded:: 1.33 + Guest HW exposed via ``hardware`` key. + example: - | # A trivial pseudo-test script diff --git a/tmt/hardware.py b/tmt/hardware.py index 571e342d24..8750d3e71d 100644 --- a/tmt/hardware.py +++ b/tmt/hardware.py @@ -1400,6 +1400,26 @@ def parse_hw_requirements(spec: Spec) -> BaseConstraint: return _parse_block(spec) +def simplify_actual_hardware(hardware: BaseConstraint) -> Spec: + as_spec: Spec = {} + + def _simplify_constraint(constraint: BaseConstraint) -> Spec: + constraint_spec = constraint.to_spec() + + assert isinstance(constraint_spec, dict) + + return cast(dict[str, str], constraint_spec) + + if isinstance(hardware, And): + for constraint in hardware.constraints: + as_spec.update(_simplify_constraint(constraint)) + + else: + as_spec.update(_simplify_constraint(hardware)) + + return as_spec + + @dataclasses.dataclass class Hardware(SpecBasedContainer[Spec, Spec]): constraint: Optional[BaseConstraint] diff --git a/tmt/steps/__init__.py b/tmt/steps/__init__.py index 1383248468..b29d2aebf1 100644 --- a/tmt/steps/__init__.py +++ b/tmt/steps/__init__.py @@ -29,6 +29,7 @@ from click.core import ParameterSource import tmt.export +import tmt.hardware import tmt.log import tmt.options import tmt.queue @@ -1916,11 +1917,13 @@ class GuestTopology(SerializableContainer): name: str role: Optional[str] hostname: Optional[str] + hardware: Optional[tmt.hardware.Spec] def __init__(self, guest: 'Guest') -> None: self.name = guest.name self.role = guest.role self.hostname = guest.topology_address + self.hardware = tmt.hardware.simplify_actual_hardware(guest.actual_hardware) @dataclasses.dataclass(init=False) @@ -2110,6 +2113,49 @@ def push( return environment + @classmethod + def inject( + cls, + *, + environment: Environment, + guests: list['Guest'], + guest: 'Guest', + dirpath: Path, + filename_base: Optional[Path] = None, + logger: tmt.log.Logger) -> 'Topology': + """ + Create, save and push topology to a given guest. + + .. note:: + + A helper for simplified use from plugins. It delivers exactly what + :py:meth:`save` and :py:meth:`push` would do, but is easier to use + for a common plugin developer. + + :param environment: environment to update with topology variables. + :param guests: list of all provisioned guests. + :param guest: a guest on which the plugin would run its actions. + :param dirpath: a directory to save the topology into. + :param filename_base: if set, it would be used as a base for filenames, + correct suffixes would be added. + :param logger: logger to use for logging. + :returns: instantiated topology container. + """ + + topology = cls(guests) + topology.guest = GuestTopology(guest) + + environment.update( + topology.push( + dirpath=dirpath, + guest=guest, + filename_base=filename_base, + logger=logger + ) + ) + + return topology + @dataclasses.dataclass class ActionTask(tmt.queue.GuestlessTask[None]): diff --git a/tmt/steps/execute/__init__.py b/tmt/steps/execute/__init__.py index 43c34429dc..8e077f770f 100644 --- a/tmt/steps/execute/__init__.py +++ b/tmt/steps/execute/__init__.py @@ -25,7 +25,7 @@ from tmt.steps import Action, ActionTask, PhaseQueue, PluginTask, Step from tmt.steps.discover import Discover, DiscoverPlugin, DiscoverStepData from tmt.steps.provision import Guest -from tmt.utils import Path, ShellScript, Stopwatch, cached_property, field +from tmt.utils import Environment, Path, ShellScript, Stopwatch, cached_property, field if TYPE_CHECKING: import tmt.cli @@ -271,6 +271,22 @@ def is_guest_healthy(self) -> bool: return True + def inject_topology(self, environment: Environment) -> tmt.steps.Topology: + """ + Collect and install the guest topology and make it visible to test. + + :param environment: environment to update with topology variables. + :returns: instantiated topology container. + """ + + return tmt.steps.Topology.inject( + environment=environment, + guests=self.phase.step.plan.provision.guests(), + guest=self.guest, + dirpath=self.path, + logger=self.logger + ) + def handle_restart(self) -> bool: """ "Restart" the test if the test requested it. diff --git a/tmt/steps/execute/internal.py b/tmt/steps/execute/internal.py index 367f73b230..3bdf7cc332 100644 --- a/tmt/steps/execute/internal.py +++ b/tmt/steps/execute/internal.py @@ -325,13 +325,7 @@ def execute( options=["-s", "-p", "--chmod=755"]) # Create topology files - topology = tmt.steps.Topology(self.step.plan.provision.guests()) - topology.guest = tmt.steps.GuestTopology(guest) - - environment.update(topology.push( - dirpath=invocation.path, - guest=guest, - logger=logger)) + invocation.inject_topology(environment) command: str if guest.become and not guest.facts.is_superuser: diff --git a/tmt/steps/finish/shell.py b/tmt/steps/finish/shell.py index 3dd7e89d09..c5575f7ac8 100644 --- a/tmt/steps/finish/shell.py +++ b/tmt/steps/finish/shell.py @@ -67,6 +67,8 @@ def go( """ Perform finishing tasks on given guest """ super().go(guest=guest, environment=environment, logger=logger) + environment = environment or tmt.utils.Environment() + # Give a short summary overview = fmf.utils.listed(self.data.script, 'script') self.info('overview', f'{overview} found', 'green') @@ -74,6 +76,16 @@ def go( workdir = self.step.plan.worktree assert workdir is not None # narrow type + if not self.is_dry_run: + tmt.steps.Topology.inject( + environment=environment, + guests=self.step.plan.provision.guests(), + guest=guest, + dirpath=workdir, + filename_base=safe_filename(tmt.steps.TEST_TOPOLOGY_FILENAME_BASE, self, guest), + logger=logger + ) + finish_wrapper_filename = safe_filename(FINISH_WRAPPER_FILENAME, self, guest) finish_wrapper_path = workdir / finish_wrapper_filename diff --git a/tmt/steps/prepare/shell.py b/tmt/steps/prepare/shell.py index 8251bff399..187e2a6511 100644 --- a/tmt/steps/prepare/shell.py +++ b/tmt/steps/prepare/shell.py @@ -76,16 +76,14 @@ def go( assert workdir is not None # narrow type if not self.is_dry_run: - topology = tmt.steps.Topology(self.step.plan.provision.guests()) - topology.guest = tmt.steps.GuestTopology(guest) - - environment.update( - topology.push( - dirpath=workdir, - guest=guest, - logger=logger, - filename_base=safe_filename(tmt.steps.TEST_TOPOLOGY_FILENAME_BASE, self, guest) - )) + tmt.steps.Topology.inject( + environment=environment, + guests=self.step.plan.provision.guests(), + guest=guest, + dirpath=workdir, + filename_base=safe_filename(tmt.steps.TEST_TOPOLOGY_FILENAME_BASE, self, guest), + logger=logger + ) prepare_wrapper_filename = safe_filename(PREPARE_WRAPPER_FILENAME, self, guest) prepare_wrapper_path = workdir / prepare_wrapper_filename diff --git a/tmt/steps/provision/__init__.py b/tmt/steps/provision/__init__.py index 77234a2078..dcf4a99c3d 100644 --- a/tmt/steps/provision/__init__.py +++ b/tmt/steps/provision/__init__.py @@ -749,6 +749,12 @@ def package_manager(self) -> 'tmt.package_managers.PackageManager': return tmt.package_managers.find_package_manager( self.facts.package_manager)(guest=self, logger=self._logger) + @property + def actual_hardware(self) -> tmt.hardware.BaseConstraint: + """ An actual HW configuration expressed with tmt hardware specification """ + + raise NotImplementedError + @classmethod def options(cls, how: Optional[str] = None) -> list[tmt.options.ClickOptionDecoratorType]: """ Prepare command line options related to guests """ @@ -863,6 +869,19 @@ def show(self, show_multihost_name: bool = True) -> None: elif key in GUEST_FACTS_VERBOSE_FIELDS: self.verbose(key_formatted, value_formatted, color='green') + if self.hardware and self.hardware.constraint: + self.info( + 'hardware', + tmt.utils.dict_to_yaml(self.hardware.to_spec()).strip(), + color='green') + + self.info( + 'actual hardware', + tmt.utils.dict_to_yaml( + tmt.hardware.simplify_actual_hardware( + self.actual_hardware)).strip(), + color='green') + def _ansible_verbosity(self) -> list[str]: """ Prepare verbose level based on the --debug option count """ if self.debug_level < 3: diff --git a/tmt/steps/provision/artemis.py b/tmt/steps/provision/artemis.py index fb72e0e9ce..1c674c7b6d 100644 --- a/tmt/steps/provision/artemis.py +++ b/tmt/steps/provision/artemis.py @@ -498,6 +498,16 @@ def is_ready(self) -> bool: # return True if self.guest is not None return self.primary_address is not None + @property + def actual_hardware(self) -> tmt.hardware.BaseConstraint: + return tmt.hardware.parse_hw_requirements( + tmt.utils.yaml_to_dict( + f""" + arch: {self.arch} + """ + ) + ) + def _create(self) -> None: environment: dict[str, Any] = { 'hw': { diff --git a/tmt/steps/provision/connect.py b/tmt/steps/provision/connect.py index 9cf3135313..2cf1207b2b 100644 --- a/tmt/steps/provision/connect.py +++ b/tmt/steps/provision/connect.py @@ -2,6 +2,7 @@ from typing import Any, Optional, Union import tmt +import tmt.hardware import tmt.steps import tmt.steps.provision import tmt.utils @@ -85,6 +86,16 @@ class GuestConnect(tmt.steps.provision.GuestSsh): soft_reboot: Optional[ShellScript] hard_reboot: Optional[ShellScript] + @property + def actual_hardware(self) -> tmt.hardware.BaseConstraint: + return tmt.hardware.parse_hw_requirements( + tmt.utils.yaml_to_dict( + f""" + arch: {self.facts.arch} + """ + ) + ) + def reboot( self, hard: bool = False, diff --git a/tmt/steps/provision/local.py b/tmt/steps/provision/local.py index 3ceb6d53cf..e864ceeae2 100644 --- a/tmt/steps/provision/local.py +++ b/tmt/steps/provision/local.py @@ -1,8 +1,10 @@ import dataclasses +import os from typing import Any, Optional, Union import tmt import tmt.base +import tmt.hardware import tmt.log import tmt.steps import tmt.steps.provision @@ -26,6 +28,19 @@ def is_ready(self) -> bool: """ Local is always ready """ return True + @property + def actual_hardware(self) -> tmt.hardware.BaseConstraint: + memory = os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') + + return tmt.hardware.parse_hw_requirements( + tmt.utils.yaml_to_dict( + f""" + arch: {self.facts.arch} + memory: {memory} bytes + """ + ) + ) + def _run_ansible( self, playbook: Path, diff --git a/tmt/steps/provision/mrack.py b/tmt/steps/provision/mrack.py index 1559777c36..f308d7f387 100644 --- a/tmt/steps/provision/mrack.py +++ b/tmt/steps/provision/mrack.py @@ -823,6 +823,16 @@ def is_ready(self) -> bool: except mrack.errors.MrackError: return False + @property + def actual_hardware(self) -> tmt.hardware.BaseConstraint: + return tmt.hardware.parse_hw_requirements( + tmt.utils.yaml_to_dict( + f""" + arch: {self.arch} + """ + ) + ) + def _create(self, tmt_name: str) -> None: """ Create beaker job xml request and submit it to Beaker hub """ diff --git a/tmt/steps/provision/podman.py b/tmt/steps/provision/podman.py index e0c3102641..0da9da917c 100644 --- a/tmt/steps/provision/podman.py +++ b/tmt/steps/provision/podman.py @@ -5,6 +5,7 @@ import tmt import tmt.base +import tmt.hardware import tmt.log import tmt.steps import tmt.steps.provision @@ -114,6 +115,16 @@ def is_ready(self) -> bool: )) return str(cmd_output.stdout).strip() == 'true' + @property + def actual_hardware(self) -> tmt.hardware.BaseConstraint: + return tmt.hardware.parse_hw_requirements( + tmt.utils.yaml_to_dict( + f""" + arch: {self.facts.arch} + """ + ) + ) + def wake(self) -> None: """ Wake up the guest """ self.debug( diff --git a/tmt/steps/provision/testcloud.py b/tmt/steps/provision/testcloud.py index d6f707b378..f715263d87 100644 --- a/tmt/steps/provision/testcloud.py +++ b/tmt/steps/provision/testcloud.py @@ -4,6 +4,7 @@ import os import platform import re +import textwrap import threading import types from typing import TYPE_CHECKING, Any, Optional, Union, cast @@ -210,6 +211,22 @@ def import_testcloud() -> None: } +_ACTUAL_HARDWARE_TEMPLATE = """ +arch: "{{ GUEST.arch }}" +memory: "{{ GUEST._domain.memory_size }} kB" +{% if GUEST._domain.storage_devices %} +disk: + {% for device in GUEST._domain.storage_devices %} + {% if device.size %} + - size: "{{ device.size }} GB" + {% endif %} + {% endfor %} +{% else %} +disk: [] +{% endif %} +""" + + def normalize_memory_size( key_address: str, value: Any, @@ -503,6 +520,19 @@ def is_coreos(self) -> bool: # Is this a CoreOS? return bool(re.search('coreos|rhcos', self.image.lower())) + @property + def actual_hardware(self) -> tmt.hardware.BaseConstraint: + assert self._domain is not None # narrow type + + return tmt.hardware.parse_hw_requirements( + tmt.utils.yaml_to_dict( + tmt.utils.render_template( + textwrap.dedent(_ACTUAL_HARDWARE_TEMPLATE), + GUEST=self + ) + ) + ) + def _get_url(self, url: str, message: str) -> requests.Response: """ Get url, retry when fails, return response """ @@ -816,10 +846,10 @@ def start(self) -> None: self._combine_hw_memory() self._combine_hw_disk_size() - if self.hardware: + if self.hardware and self.hardware.constraint: self.verbose( 'effective hardware', - self.hardware.to_spec(), + tmt.utils.dict_to_yaml(self.hardware.to_spec()).strip(), color='green') for line in self.hardware.format_variants():