Skip to content

Commit

Permalink
Add a helper for saving command output in a file
Browse files Browse the repository at this point in the history
This will be a very needed functionality: plugin runs a command, and its
output needs to be saved in a dedicated file, usualy because the output
represents some interesting, standalone piece of information. For
example, an AVC denial report generated by `ausearch`.

If it would be the only such actor, it would be fine to keep the
implementation in the `avc` plugin. But, with the advent of saving
results of `prepare` and `finish` phases, the question is, if I get
`results.yaml` for a `prepare` step, would it even contain anything
besides `pass` or `error`? Well, it can contain logs of commands like
`ansible-playbook` or shell scripts. And suddenly we have several
plugins that may need to save some walls of texts, usually produced by a
command, in files, and then link those files to results and present them
to user.

And because of that, it makes sense to provide a helper function that
would produce unified output for all of them? Why should `avc` separate
stdout and stderr by two lines and `prepare/shell` with just one? Not on
my watch.
  • Loading branch information
happz committed Nov 1, 2024
1 parent 9064574 commit c2295ff
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 33 deletions.
61 changes: 61 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1789,3 +1789,64 @@ def test_create_link_relation(self, mock_config_tree, mock_add_simple_link) -> N
# Load the test object again with the link present
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
assert test.link.get('verifies')[0].target == 'https://issues.redhat.com/browse/TT-262'


def test_render_command_report_output():
assert '\n'.join(tmt.utils.render_command_report(
label='foo',
command=ShellScript('/bar/baz'),
output=tmt.utils.CommandOutput(
stdout='This is some stdout',
stderr='This is some stderr'
)
)) == """## foo
# /bar/baz
# stdout (1 lines)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This is some stdout
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# stderr (1 lines)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This is some stderr
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
"""


def test_render_command_report_exception():
assert '\n'.join(tmt.utils.render_command_report(
label='foo',
command=ShellScript('/bar/baz'),
exc=tmt.utils.RunError(
'foo failed',
ShellScript('/bar/baz').to_shell_command(),
1,
stdout='This is some stdout',
stderr='This is some stderr'
)
)) == """## foo
# /bar/baz
# stdout (1 lines)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This is some stdout
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# stderr (1 lines)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This is some stderr
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
"""


def test_render_command_report_minimal():
print(list(tmt.utils.render_command_report(
label='foo'
)))
assert '\n'.join(tmt.utils.render_command_report(
label='foo'
)) == """## foo
"""
20 changes: 4 additions & 16 deletions tmt/checks/avc.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Path,
ShellScript,
format_timestamp,
render_run_exception_streams,
render_command_report,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -117,21 +117,13 @@ def _output_logger(
def _report_success(label: str, output: tmt.utils.CommandOutput) -> list[str]:
""" Format successful command output for the report """

return [
f'# {label}',
output.stdout or '',
''
]
return list(render_command_report(label=label, output=output))


def _report_failure(label: str, exc: tmt.utils.RunError) -> list[str]:
""" Format failed command output for the report """

return [
f'# {label}',
"\n".join(render_run_exception_streams(exc.stdout, exc.stderr, verbose=1)),
''
]
return list(render_command_report(label=label, exc=exc))


def create_ausearch_timestamp(
Expand Down Expand Up @@ -243,11 +235,7 @@ def create_final_report(
got_ausearch = True
got_denials = True

report += [
'# ausearch',
"\n".join(render_run_exception_streams(output.stdout, output.stderr, verbose=1)),
''
]
report += list(render_command_report(label='ausearch', output=output))

else:
if exc.returncode == 1 and exc.stderr and '<no matches>' in exc.stderr.strip():
Expand Down
2 changes: 1 addition & 1 deletion tmt/checks/dmesg.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def _save_dmesg(

except tmt.utils.RunError as exc:
outcome = ResultOutcome.ERROR
output = "\n".join(render_run_exception_streams(exc.stdout, exc.stderr, verbose=1))
output = "\n".join(render_run_exception_streams(exc.output, verbose=1))

else:
outcome = ResultOutcome.PASS
Expand Down
4 changes: 2 additions & 2 deletions tmt/checks/watchdog.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ def _handle_output(ping_output: str) -> None:
_handle_output(exc.stdout or '')

else:
_handle_output('\n'.join(render_run_exception_streams(exc.stdout, exc.stderr)))
_handle_output('\n'.join(render_run_exception_streams(exc.output)))

def do_ssh_ping(
self,
Expand Down Expand Up @@ -337,7 +337,7 @@ def _success(ncat_output: str) -> None:
_fail_connection_refused(exc.stderr or '')

else:
_fail_unknown('\n'.join(render_run_exception_streams(exc.stdout, exc.stderr)))
_fail_unknown('\n'.join(render_run_exception_streams(exc.output)))

logger.debug(
f'failed {guest_context.ssh_ping_failures}'
Expand Down
118 changes: 104 additions & 14 deletions tmt/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2289,6 +2289,21 @@ def __init__(
# `finish`), save a logger for later.
self.logger = caller._logger if isinstance(caller, Common) else None

@functools.cached_property
def output(self) -> CommandOutput:
"""
Captured output of the command.
.. note::
This field contains basically the same info as :py:attr:`stdout`
and :py:attr:`stderr`, but it's bundled into a single object.
This is how command output is passed between functions, therefore
the exception should offer it as well.
"""

return CommandOutput(self.stdout, self.stderr)


class MetadataError(GeneralError):
""" General metadata error """
Expand Down Expand Up @@ -2446,29 +2461,104 @@ def from_env(cls) -> 'TracebackVerbosity':


def render_run_exception_streams(
stdout: Optional[str],
stderr: Optional[str],
verbose: int = 0) -> Iterator[str]:
output: CommandOutput,
verbose: int = 0,
comment_sign: str = '#') -> Iterator[str]:
""" Render run exception output streams for printing """

for name, output in (('stdout', stdout), ('stderr', stderr)):
if not output:
for name, content in (('stdout', output.stdout), ('stderr', output.stderr)):
if not content:
continue
output_lines = output.strip().split('\n')
content_lines = content.strip().split('\n')
# Show all lines in verbose mode, limit to maximum otherwise
if verbose > 0:
line_summary = f"{len(output_lines)}"
line_summary = f"{len(content_lines)}"
else:
line_summary = f"{min(len(output_lines), OUTPUT_LINES)}/{len(output_lines)}"
output_lines = output_lines[-OUTPUT_LINES:]
line_summary = f"{min(len(content_lines), OUTPUT_LINES)}/{len(content_lines)}"
content_lines = content_lines[-OUTPUT_LINES:]

yield f'{name} ({line_summary} lines)'
yield OUTPUT_WIDTH * '~'
yield from output_lines
yield OUTPUT_WIDTH * '~'
yield f'{comment_sign} {name} ({line_summary} lines)'
yield comment_sign + (OUTPUT_WIDTH - 1) * '~'
yield from content_lines
yield comment_sign + (OUTPUT_WIDTH - 1) * '~'
yield ''


@overload
def render_command_report(
*,
label: str,
command: Optional[Union[ShellScript, Command]] = None,
output: CommandOutput,
exc: None = None) -> Iterator[str]:
pass


@overload
def render_command_report(
*,
label: str,
command: Optional[Union[ShellScript, Command]] = None,
output: None = None,
exc: RunError) -> Iterator[str]:
pass


def render_command_report(
*,
label: str,
command: Optional[Union[ShellScript, Command]] = None,
output: Optional[CommandOutput] = None,
exc: Optional[RunError] = None,
comment_sign: str = '#') -> Iterator[str]:
"""
Format a command output for a report file.
To provide unified look of various files reporting command outputs,
this helper would combine its arguments and emit lines the caller
may then write to a file. The following template is used:
.. code-block::
## ${label}
# ${command}
# stdout (N lines)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
...
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# stderr (N lines)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
...
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:param label: a string describing the intent of the command. It is
useful for user who reads the report file eventually.
:param command: command that was executed.
:param output: if set, it contains output of the command. It has
higher priority than ``exc``.
:param exc: if set, it represents a failed command, and input stored
in it is rendered.
:param comment_sign: a character to mark lines with comments that
document the report.
"""

yield f'{comment_sign}{comment_sign} {label}'
yield ''

if command:
yield f'{comment_sign} {command.to_element()}'
yield ''

if output is not None:
yield from render_run_exception_streams(output, verbose=1)

elif exc is not None:
yield from render_run_exception_streams(exc.output, verbose=1)


def render_run_exception(exception: RunError) -> Iterator[str]:
""" Render detailed output upon command execution errors for printing """

Expand All @@ -2480,7 +2570,7 @@ def render_run_exception(exception: RunError) -> Iterator[str]:
else:
verbose = 0

yield from render_run_exception_streams(exception.stdout, exception.stderr, verbose=verbose)
yield from render_run_exception_streams(exception.output, verbose=verbose)


def render_exception_stack(
Expand Down

0 comments on commit c2295ff

Please sign in to comment.