diff --git a/tmt/steps/__init__.py b/tmt/steps/__init__.py index d19fe3cad7..b8c27fce14 100644 --- a/tmt/steps/__init__.py +++ b/tmt/steps/__init__.py @@ -1383,6 +1383,8 @@ class BasePlugin(Phase, Generic[StepDataT, PluginReturnValueT]): _data_class: type[StepDataT] + preserved_members: list[str] = [] + @classmethod def get_data_class(cls) -> type[StepDataT]: """ @@ -1876,11 +1878,26 @@ def prune(self, logger: tmt.log.Logger) -> None: if self.workdir is None: return - logger.debug(f"Remove '{self.name}' workdir '{self.workdir}'.", level=3) - try: - shutil.rmtree(self.workdir) - except OSError as error: - logger.warning(f"Unable to remove '{self.workdir}': {error}") + remove_workdir = True + for member in self.workdir.iterdir(): + if member.name in self.preserved_members: + # We only want to remove the workdir, if there is no preserved members + remove_workdir = False + else: + logger.debug(f"Remove '{member}'.", level=3) + try: + if member.is_file() or member.is_symlink(): + member.unlink() + else: + shutil.rmtree(member) + except OSError as error: + logger.warning(f"Unable to remove '{member}': {error}") + if remove_workdir: + logger.debug(f"Remove '{self.name}' workdir '{self.workdir}'.", level=3) + try: + shutil.rmtree(self.workdir) + except OSError as error: + logger.warning(f"Unable to remove '{self.workdir}': {error}") class GuestlessPlugin(BasePlugin[StepDataT, PluginReturnValueT]): diff --git a/tmt/steps/finish/__init__.py b/tmt/steps/finish/__init__.py index 55ed62910d..8b4d9bdaaa 100644 --- a/tmt/steps/finish/__init__.py +++ b/tmt/steps/finish/__init__.py @@ -226,6 +226,7 @@ def go(self, force: bool = False) -> None: # Stop and remove provisioned guests for guest in self.plan.provision.guests(): + guest.fetch_logs(guest_logs=guest.guest_logs) guest.stop() guest.remove() diff --git a/tmt/steps/provision/__init__.py b/tmt/steps/provision/__init__.py index 20e0dc0b0c..7c89d5395c 100644 --- a/tmt/steps/provision/__init__.py +++ b/tmt/steps/provision/__init__.py @@ -975,6 +975,38 @@ def show( logger.info(key_to_option(key).replace('-', ' '), printable_value, color='green') +@container +class GuestLog: + name: str + + def fetch(self) -> str: + """ + Fetch and return content of a log. + + :returns: content of the log, or ``None`` if the log cannot be retrieved. + """ + raise NotImplementedError + + def store(self, path: Path, logname: Optional[str] = None) -> None: + """ + Save log content to a file. + + :param path: a path to save into, could be a directory + or a file path. + :param content: content of the log. + :param logname: name of the log, if not set, logpath + is supposed to be a file path. + """ + # if path is file path + if not path.is_dir(): + path.write_text(self.fetch()) + # if path is a directory + elif logname: + (path / logname).write_text(self.fetch()) + else: + raise tmt.utils.GeneralError('Log path is a directory but log name is not defined.') + + class Guest(tmt.utils.Common): """ Guest provisioned for test execution @@ -1041,6 +1073,7 @@ def __init__( """ Initialize guest data """ + self.guest_logs: list[GuestLog] = [] super().__init__(logger=logger, parent=parent, name=name) self.load(data) @@ -1702,6 +1735,33 @@ def essential_requires(cls) -> list['tmt.base.Dependency']: return [] + @property + def logdir(self) -> Optional[Path]: + """ + Path to store logs + """ + return self.workdir / 'logs' if self.workdir else None + + def fetch_logs( + self, dirpath: Optional[Path] = None, guest_logs: Optional[list[GuestLog]] = None + ) -> None: + """ + Get log content and save it to a directory. + + :param dirpath: a directory to save into. If not set, step's working directory + (:py:attr:`workdir`) or current working directory will be used. + :param lognames: name list of logs need to be handled. If not set, all guest logs + would be collected, as reported by :py:attr:`lognames`. + """ + + guest_logs = guest_logs or self.guest_logs or [] + + dirpath = dirpath or self.logdir or Path.cwd() + if dirpath == self.logdir: + self.logdir.mkdir(parents=True, exist_ok=True) + for log in guest_logs: + log.store(dirpath, log.name) + @container class GuestSshData(GuestData): @@ -2613,6 +2673,8 @@ class ProvisionPlugin(tmt.steps.GuestlessPlugin[ProvisionStepDataT, None]): # TODO: Generics would provide a better type, https://github.com/teemtee/tmt/issues/1437 _guest: Optional[Guest] = None + preserved_members = ['logs'] + @classmethod def base_command( cls, diff --git a/tmt/steps/provision/mrack.py b/tmt/steps/provision/mrack.py index 95c145679b..3335c5029a 100644 --- a/tmt/steps/provision/mrack.py +++ b/tmt/steps/provision/mrack.py @@ -1285,6 +1285,14 @@ def get_new_state() -> GuestInspectType: raise ProvisionError('Failed to create, provisioning failed.') if state == 'Reserved': + for key in response["logs"]: + self.guest_logs.append( + GuestLogBeaker(key.replace('.log', ''), self, response["logs"][key]) + ) + # console.log contains dmesg, and accessible even when the system is dead. + self.guest_logs.append( + GuestLogBeaker('dmesg', self, response["logs"]["console.log"]) + ) return current raise tmt.utils.WaitingIncompleteError @@ -1474,3 +1482,18 @@ def guest(self) -> Optional[GuestBeaker]: """ return self._guest + + +@container +class GuestLogBeaker(tmt.steps.provision.GuestLog): + guest: GuestBeaker + url: str + + def fetch(self) -> str: + """ + Fetch and return content of a log. + + :returns: content of the log, or ``None`` if the log cannot be retrieved. + """ + + return tmt.utils.get_url_content(self.url) if self.url else ''