[PATCH 18/26] lucid: render on-screen results via _dump_results

Daniel Gomez <[email protected]> Tue, 19 May 2026 15:28:15 +0200
Newsgroups dev.linux.lists.kdevops
Message-ID <[email protected]>
From: Daniel Gomez <[email protected]>

The on-screen display path was reading stdout, stderr, msg, and exception
straight off result._result with the same leak surface the log path had
before the previous commit. More importantly, users who opted in to the
structured full result via -vvv got nothing extra from lucid: the compact
four-field rendering stayed the same regardless of verbosity, so the
result_format, pretty_results, result_yaml_line_width, and
result_indentation options declared by the result_format_callback doc
fragment had no effect on lucid output.

A new helper produces a cleaned copy of the result once per render by
wrapping CallbackBase._clean_results on a copy of the raw dict, and the
display path reads from that copy for its compact rendering. At
verbosity 3 or higher the helper's output is also passed to
_dump_results, whose serialization honors every option the doc fragment
declares and which runs strip_internal_keys on a deep copy so the dump
stays free of _ansible_* bookkeeping. The per-item failure buffer
populated from v2_runner_item_on_failed now captures its stdout, stderr,
and msg fields from the cleaned copy as well, so the per-item failure
rendering cannot bypass the same redaction as the aggregate view.

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
---
 callback_plugins/lucid.py | 58 +++++++++++++++++++++++++++++++++++++----------
 1 file changed, 46 insertions(+), 12 deletions(-)

diff --git a/callback_plugins/lucid.py b/callback_plugins/lucid.py
index 46538a4c..a9639b33 100644
--- a/callback_plugins/lucid.py
+++ b/callback_plugins/lucid.py
@@ -566,6 +566,11 @@ class CallbackModule(CallbackBase):
     def v2_runner_item_on_failed(self, result):
         """Loop item failed — log per-item command and output"""
         self._log_item_result(result)
+        # Read label-related fields from the raw result: the _ansible_item_label
+        # and ansible_loop_var keys are Ansible bookkeeping that identify the
+        # item and are not user-content subject to no_log redaction. The
+        # rendered stdout/stderr/msg, however, come from the cleaned copy so
+        # the per-item failure buffer never carries unredacted payload.
         res = result._result
         item = res.get("_ansible_item_label")
         if item is None:
@@ -573,11 +578,12 @@ class CallbackModule(CallbackBase):
             item = res.get(loop_var, "")
         if isinstance(item, dict):
             item = item.get("name", item.get("group", str(item)))
+        cleaned = self._cleaned_result(result)
         self.failed_items.append({
             "item": item,
-            "stderr": res.get("stderr", ""),
-            "stdout": res.get("stdout", ""),
-            "msg": res.get("msg", ""),
+            "stderr": cleaned.get("stderr", ""),
+            "stdout": cleaned.get("stdout", ""),
+            "msg": cleaned.get("msg", ""),
             "cmd": self._get_task_command(result),
         })
 
@@ -788,30 +794,58 @@ class CallbackModule(CallbackBase):
                 f"task path: {task_path}", color=C.COLOR_VERBOSE, stderr=use_stderr
             )
 
+    def _cleaned_result(self, result):
+        """Return a cleaned copy of result._result suitable for display.
+
+        CallbackBase._clean_results mutates in place, so we copy first to
+        preserve the original for other observers. The returned dict has
+        debug-module specific redaction applied; the _ansible_* bookkeeping
+        keys are only stripped when we subsequently pass through
+        _dump_results (which calls strip_internal_keys on its own deep copy).
+        For the ad-hoc stdout/stderr/msg reads the lucid compact format does
+        at verbosity 0–1, reading cleaned[...] is safe because no_log results
+        arrive at callbacks with their payload already replaced by a
+        'censored' placeholder at the executor level.
+        """
+        cleaned = result._result.copy()
+        self._clean_results(cleaned, result._task.action)
+        return cleaned
+
     def _display_output(self, result, stderr: bool = False, task_path: str = ""):
         """Display stdout/stderr/msg from task result"""
         output = []
-        res = result._result
+        cleaned = self._cleaned_result(result)
 
         # stdout
-        if "stdout" in res and res["stdout"]:
-            output.append(f"\nSTDOUT:\n{res['stdout']}")
+        if "stdout" in cleaned and cleaned["stdout"]:
+            output.append(f"\nSTDOUT:\n{cleaned['stdout']}")
 
         # stderr
-        if "stderr" in res and res["stderr"]:
-            output.append(f"\nSTDERR:\n{res['stderr']}")
+        if "stderr" in cleaned and cleaned["stderr"]:
+            output.append(f"\nSTDERR:\n{cleaned['stderr']}")
 
         # msg (only if no stdout content)
-        if "msg" in res and res["msg"] and not res.get("stdout"):
-            msg_text = res["msg"]
+        if "msg" in cleaned and cleaned["msg"] and not cleaned.get("stdout"):
+            msg_text = cleaned["msg"]
             # Handle lists/dicts in msg
             if isinstance(msg_text, (list, dict)):
                 msg_text = json.dumps(msg_text, indent=2)
             output.append(f"\nMSG:\n{msg_text}")
 
         # exception (Python traceback from module failures)
-        if "exception" in res and res["exception"]:
-            output.append(f"\nEXCEPTION:\n{res['exception']}")
+        if "exception" in cleaned and cleaned["exception"]:
+            output.append(f"\nEXCEPTION:\n{cleaned['exception']}")
+
+        # At -vvv or higher the user explicitly asked for the full structured
+        # result. Delegate serialization to _dump_results so the user's
+        # result_format, pretty_results, result_yaml_line_width, and
+        # result_indentation options (declared via the result_format_callback
+        # doc fragment) actually take effect. _dump_results strips _ansible_*
+        # keys internally, so secrets and bookkeeping are not leaked even at
+        # the highest verbosity.
+        if self._display.verbosity >= 3:
+            dump = self._dump_results(cleaned, keep_invocation=False)
+            output.append(f"\n{dump}")
 
         # Task source path for failures, mirroring the default callback's
         # show_task_path_on_failure behavior.

-- 
2.53.0