public inbox for kdevops@lists.linux.dev
 help / color / mirror / Atom feed
From: Chuck Lever <cel@kernel.org>
To: Daniel Gomez <da.gomez@kernel.org>
Cc: Luis Chamberlain <mcgrof@kernel.org>,
	Chuck Lever <chuck.lever@oracle.com>,
	kdevops@lists.linux.dev, Daniel Gomez <da.gomez@samsung.com>
Subject: Re: [PATCH v4 2/2] ansible: add lucid callback plugin for clean output
Date: Wed, 3 Dec 2025 16:11:00 -0500	[thread overview]
Message-ID: <aTCnZB4FSAYc4_7V@klimt.1015granger.net> (raw)
In-Reply-To: <20251202-lucid-v4-2-a077cca27736@samsung.com>

On Tue, Dec 02, 2025 at 12:11:17AM +0100, Daniel Gomez wrote:
> From: Daniel Gomez <da.gomez@samsung.com>
> 
> Add the lucid callback plugin to provide clean, minimal Ansible output
> for kdevops workflows. Current Ansible output is verbose by default
> making it difficult to see what matters during long-running test
> operations. Lucid solves this by showing only changes and errors at
> default verbosity while providing progressive detail at higher levels.
> 
> The plugin includes dynamic mode with live updates for interactive
> terminals showing running tasks with spinners and elapsed time similar
> to BitBake/Yocto build output. For CI/CD and piped contexts it
> automatically falls back to static output. All runs generate
> comprehensive logs in .ansible/logs/ with playbook names and timestamps
> allowing multiple executions to coexist without conflicts.
> 
> Task-level output control is available via the output_verbosity variable
> allowing playbooks to specify per-task visibility. Execution time is
> shown for all tasks providing consistent performance visibility. Logs
> always capture full verbosity independent of display settings ensuring
> complete audit trails.
> 
> Verbosity levels control output progressively: no flags show only
> changed/failed tasks with stdout/stderr, -v adds ok/skipped tasks plus
> executed commands, -vv and -vvv enable tasks marked with higher
> output_verbosity values.
> 
> Integrate lucid with kdevops Kconfig system providing output mode
> selection (auto/static/dynamic). Add defconfig fragments for standard
> use (lucid.config) and CI environments (lucid-ci.config). The CI
> fragment enables static mode with verbosity level 1 for comprehensive
> output suitable for build logs.
> 
> Add documentation covering requirements, parameters, verbosity levels,
> status symbols, command display behavior, and CI/CD configuration.
> 
> Generated-by: Claude AI
> Signed-off-by: Daniel Gomez <da.gomez@samsung.com>

Summary: Aside from one or two details, the bulk of my comments are
about keeping display output from becoming garbled.


> ---
>  .gitignore                                         |   4 +
>  CLAUDE.md                                          |  10 +
>  callback_plugins/__init__.py                       |   0
>  callback_plugins/lucid.py                          | 879 +++++++++++++++++++++
>  defconfigs/configs/lucid-ci.config                 |   3 +
>  defconfigs/configs/lucid.config                    |   2 +
>  docs/ansible-callbacks.md                          | 294 +++++++
>  kconfigs/Kconfig.ansible_cfg                       |  74 ++
>  .../roles/ansible_cfg/templates/ansible.cfg.j2     |  12 +-
>  9 files changed, 1277 insertions(+), 1 deletion(-)
> 
> diff --git a/.gitignore b/.gitignore
> index 7472860e..ddc19b17 100644
> --- a/.gitignore
> +++ b/.gitignore
> @@ -26,6 +26,7 @@ extra_vars.json
>  .config.old
>  
>  ansible.cfg
> +callback_plugins/__pycache__/
>  
>  scripts/kconfig/.mconf-cfg
>  scripts/workflows/fstests/lib/__pycache__/
> @@ -131,3 +132,6 @@ terraform/oci/scripts/__pycache__/
>  
>  scripts/__pycache__/
>  docs/contrib/kdevops_contributions*
> +__pycache__/
> +
> +.ansible
> diff --git a/CLAUDE.md b/CLAUDE.md
> index 5c9091d9..83376619 100644
> --- a/CLAUDE.md
> +++ b/CLAUDE.md
> @@ -128,6 +128,16 @@ make fix-whitespace-last-commit # Fixes commit white space damage
>  make mrproper           # Clean everything and restart from scratch
>  ```
>  
> +### Ansible Callbacks
> +
> +kdevops supports multiple Ansible stdout callback plugins (dense, debug,
> +diy, lucid, or custom). The default is dense.
> +
> +See [docs/ansible-callbacks.md](docs/ansible-callbacks.md) for:
> +- Supported plugins and configuration
> +- Command line override via `ANSIBLE_CFG_CALLBACK_PLUGIN`
> +- Lucid plugin features and parameters
> +
>  ## Key Workflows
>  
>  ### fstests (Filesystem Testing)
> diff --git a/callback_plugins/__init__.py b/callback_plugins/__init__.py
> new file mode 100644
> index 00000000..e69de29b
> diff --git a/callback_plugins/lucid.py b/callback_plugins/lucid.py
> new file mode 100644
> index 00000000..e45f46b1
> --- /dev/null
> +++ b/callback_plugins/lucid.py
> @@ -0,0 +1,879 @@
> +#!/usr/bin/env python3
> +"""
> +Lucid Ansible Callback Plugin
> +
> +A modern stdout callback plugin providing:
> +- Clean, minimal output with progressive verbosity levels
> +- Task-level output control via output_verbosity variable
> +- Comprehensive logging (always max verbosity)
> +- Dynamic terminal display (Yocto/BitBake style) for interactive use
> +- Static output for CI/CD and non-interactive terminals
> +"""
> +
> +from __future__ import annotations
> +
> +import json
> +import os
> +import shutil
> +import sys
> +import time
> +import threading
> +from datetime import datetime
> +from typing import Dict, Tuple, Optional, Any
> +from collections import deque
> +
> +from ansible.plugins.callback import CallbackBase
> +from ansible import constants as C
> +
> +DOCUMENTATION = """
> +    name: lucid
> +    type: stdout
> +    short_description: Clean, minimal Ansible output with dynamic display
> +    version_added: "2.10"
> +    description:
> +        - Provides clean, minimal output by default
> +        - Progressive verbosity levels (-v, -vv, -vvv)
> +        - Task-level output control via output_verbosity variable
> +        - Comprehensive logging independent of display verbosity
> +        - Dynamic live display for interactive terminals
> +        - Static output for CI/CD environments
> +    requirements:
> +        - Ansible 2.10+
> +        - Python 3.8+
> +    options:
> +        output_mode:
> +            description: Output display mode (auto, static, dynamic)
> +            default: auto
> +            type: str
> +            ini:
> +                - section: callback_lucid
> +                  key: output_mode
> +            env:
> +                - name: ANSIBLE_LUCID_OUTPUT_MODE
> +            choices: ['auto', 'static', 'dynamic']
> +"""
> +
> +
> +class CallbackModule(CallbackBase):
> +    """
> +    Lucid callback plugin for clean, minimal Ansible output
> +    with dynamic terminal display and comprehensive logging.
> +    """
> +
> +    CALLBACK_VERSION = 2.0
> +    CALLBACK_TYPE = "stdout"
> +    CALLBACK_NAME = "lucid"
> +
> +    # Spinner animation frames
> +    SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
> +
> +    # Status symbols and colors (used consistently across static, dynamic, and logging)
> +    STATUS_SYMBOLS = {
> +        "ok": "✓",
> +        "changed": "*",
> +        "failed": "✗",
> +        "skipped": "⊘",
> +        "unreachable": "!",
> +    }
> +
> +    STATUS_COLORS = {
> +        "ok": C.COLOR_OK,
> +        "changed": C.COLOR_CHANGED,
> +        "failed": C.COLOR_ERROR,
> +        "skipped": C.COLOR_SKIP,
> +        "unreachable": C.COLOR_UNREACHABLE,
> +    }
> +
> +    # Default terminal width when detection fails
> +    DEFAULT_TERMINAL_WIDTH = 80
> +
> +    def __init__(self):
> +        super(CallbackModule, self).__init__()
> +
> +        # State tracking
> +        self.running_tasks: Dict[Tuple[str, str], Dict[str, Any]] = (
> +            {}
> +        )  # (host, task_uuid) -> task_info
> +        self.completed_tasks = deque(maxlen=3)  # Keep last 3 completed
> +        self.current_task_name: str = ""
> +        self.current_task_hosts: list[str] = []
> +        self.play_hosts: list[str] = []  # All hosts in current play

The requirements above state: 

> +        - Python 3.8+

This syntax (PEP 585 - using built-in generics like list[str],
dict[str, int]) was introduced in Python 3.9. In Python 3.8, this
will raise a TypeError at runtime.

RHEL 8 and Debian (before 11?) still use older versions of Python,
and kdevops is supposed to support both of those.

Using "List[str] = []" might be a good solution.


> +
> +        # Dynamic display state
> +        self.display_lines = 0
> +        self.last_update = 0.0
> +        self.spinner_index = 0
> +        self.dynamic_mode = False
> +        self.update_thread: Optional[threading.Thread] = None
> +        self.update_thread_stop: Optional[threading.Event] = None
> +        self.task_lock = threading.Lock()

I guess there is more than one thread executing (a display thread and
a background thread), so shared state variables need some protection.

Consider adding serialization around current_task_name,
current_task_hosts, and maybe also completed_tasks, spinner_index,
and last_update.


> +
> +        # Will be set in set_options()
> +        self.output_mode = "auto"
> +        self.log_file_path: Optional[str] = None
> +        self.log_timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
> +        self.log_write_failed = False
> +
> +    def set_options(self, task_keys=None, var_options=None, direct=None):
> +        """Set plugin options from ansible.cfg"""
> +        super(CallbackModule, self).set_options(
> +            task_keys=task_keys, var_options=var_options, direct=direct
> +        )
> +
> +        # Load configuration
> +        self.output_mode = self.get_option("output_mode")
> +
> +        # Determine display mode based on configuration
> +        is_interactive = self._detect_interactive()
> +        if self.output_mode == "static":
> +            self.dynamic_mode = False
> +        elif self.output_mode == "dynamic":
> +            self.dynamic_mode = True
> +        else:  # 'auto' or any other value
> +            self.dynamic_mode = is_interactive
> +
> +        # Initialize logging
> +        self._init_log_file()
> +
> +        # Start update thread for dynamic mode
> +        if self.dynamic_mode:
> +            self._start_update_thread()
> +
> +    def _detect_interactive(self) -> bool:
> +        """Detect if running in interactive terminal"""
> +        return (
> +            sys.stdout.isatty()
> +            and sys.stderr.isatty()
> +            and os.getenv("TERM") != "dumb"
> +            and os.getenv("CI") is None
> +            and os.getenv("JENKINS_HOME") is None
> +            and os.getenv("GITHUB_ACTIONS") is None
> +            and os.getenv("GITLAB_CI") is None
> +        )
> +
> +    def _init_log_file(self):
> +        """Defer log file creation until playbook name is known"""
> +        # Log file path will be set in _create_log_file when playbook starts
> +        pass
> +
> +    def _create_log_file(self, playbook_name: str):
> +        """Create log file with playbook name and timestamp using auto-detection"""
> +        if self.log_file_path:
> +            # Already initialized
> +            return
> +
> +        # Remove extension from playbook name
> +        playbook_base = os.path.splitext(playbook_name)[0]
> +
> +        # Try default locations in order (project dir first, then user home, then system)
> +        attempts = [
> +            f".ansible/logs/{playbook_base}-{self.log_timestamp}.log",
> +            os.path.expanduser(
> +                f"~/.ansible/logs/{playbook_base}-{self.log_timestamp}.log"
> +            ),
> +            f"/var/log/ansible/{playbook_base}-{self.log_timestamp}.log",
> +        ]
> +
> +        for path in attempts:
> +            try:
> +                os.makedirs(os.path.dirname(path), exist_ok=True)
> +                # Test write access
> +                with open(path, "a"):
> +                    pass
> +                self.log_file_path = path
> +                break
> +            except (PermissionError, OSError):
> +                continue
> +
> +        if self.log_file_path:
> +            try:
> +                with open(self.log_file_path, "w") as f:
> +                    f.write(
> +                        f"=== Ansible Playbook Log Started: {datetime.now().isoformat()} ===\n"
> +                    )
> +                    f.write(f"=== Playbook: {playbook_name} ===\n\n")
> +            except (PermissionError, OSError) as e:
> +                self._display.warning(
> +                    f"Could not initialize log file {self.log_file_path}: {e}"
> +                )
> +                self.log_file_path = None
> +
> +    def _write_to_log(self, message: str):
> +        """Write to log file with timestamp (always max verbosity)"""
> +        if self.log_file_path:
> +            timestamp = datetime.now().isoformat()
> +            try:
> +                with open(self.log_file_path, "a") as f:
> +                    f.write(f"[{timestamp}] {message}\n")
> +            except (PermissionError, OSError) as e:
> +                # Warn once on first failure, then disable logging
> +                if not self.log_write_failed:
> +                    self._display.warning(f"Log write failed, disabling logging: {e}")
> +                    self.log_write_failed = True
> +                    self.log_file_path = None
> +
> +    def _start_update_thread(self):
> +        """Start background thread for live display updates"""
> +        self.update_thread_stop = threading.Event()
> +        self.update_thread = threading.Thread(target=self._update_loop, daemon=True)

If thread termination occurs while _redraw_display() is in the
middle of writing ANSI escape sequences, the terminal could be left
in a corrupted state with partial escape sequences written, cursor
in the wrong position, and/or lines not properly cleared.

You could do any one of:

1. Don't use daemon=True - Use a non-daemon thread and ensure proper
   cleanup via signal handlers
2. Register signal handlers - Catch SIGINT/SIGTERM to perform
   graceful shutdown with terminal reset
3. Use atexit module - Register a cleanup function to reset terminal
   state
4. Write ANSI sequences atomically - Build complete escape sequence
   strings and write in single write() call


> +        self.update_thread.start()
> +
> +    def _update_loop(self):
> +        """Update display every 0.5 seconds in dynamic mode"""
> +        while not self.update_thread_stop.is_set():
> +            if self.running_tasks:
> +                self._redraw_display()
> +            time.sleep(0.5)
> +
> +    def _should_display_output(self, result, status: str) -> bool:
> +        """
> +        Determine if task output should be shown based on verbosity.
> +
> +        Rules:
> +        - Changed tasks: ALWAYS show
> +        - Failed tasks: ALWAYS show
> +        - OK tasks: Show if current_verbosity >= task's output_verbosity
> +        - Skipped tasks: Only at -v or higher
> +        """
> +        # Changed and failed always show
> +        if status == "changed" or status == "failed" or status == "unreachable":
> +            return True
> +
> +        # Skipped only at -v
> +        if status == "skipped":
> +            return self._display.verbosity >= 1
> +
> +        # Get task verbosity setting (default is 1)
> +        task_verbosity = self._get_task_verbosity(result)
> +
> +        # Compare with current verbosity
> +        current_verbosity = self._display.verbosity
> +
> +        return current_verbosity >= task_verbosity
> +
> +    def _get_task_verbosity(self, result) -> int:
> +        """Get the output_verbosity setting for a task (default is 1)"""
> +        task_verbosity = 1
> +        if hasattr(result, "_task_fields"):
> +            task_vars = result._task_fields.get("vars", {})
> +            task_verbosity = task_vars.get("output_verbosity", 1)
> +        elif hasattr(result, "_task"):
> +            task_vars = getattr(result._task, "vars", {})
> +            task_verbosity = task_vars.get("output_verbosity", 1)
> +        return task_verbosity
> +
> +    def _get_task_command(self, result) -> Optional[str]:
> +        """
> +        Extract the command from modules that execute shell commands.
> +        Returns None for modules that don't expose their commands.
> +
> +        Supported modules:
> +        - ansible.builtin.shell/command: returns cmd (string or list)
> +        - ansible.builtin.pip: returns cmd (string)
> +        - community.general.make: returns command (string)
> +        - community.general.flatpak: returns command (string)
> +        - community.general.flatpak_remote: returns command (string)
> +        - community.general.terraform: returns command (string)
> +        """
> +        task = result._task
> +        action = task.action
> +        res = result._result
> +
> +        # Modules that return 'cmd' key
> +        if action in (
> +            "ansible.builtin.shell",
> +            "ansible.builtin.command",
> +            "ansible.builtin.pip",
> +            "shell",
> +            "command",
> +            "pip",
> +        ):
> +            if "cmd" in res:
> +                cmd = res["cmd"]
> +                if isinstance(cmd, list):
> +                    return " ".join(cmd)
> +                return cmd
> +
> +            # Fall back to task args for shell/command
> +            if action in (
> +                "ansible.builtin.shell",
> +                "ansible.builtin.command",
> +                "shell",
> +                "command",
> +            ):
> +                args = task.args
> +                if "_raw_params" in args:
> +                    return args["_raw_params"]
> +                if "cmd" in args:
> +                    return args["cmd"]
> +
> +            return None
> +
> +        # Modules that return 'command' key
> +        if action in (
> +            "community.general.make",
> +            "community.general.flatpak",
> +            "community.general.flatpak_remote",
> +            "community.general.terraform",
> +            "make",
> +            "flatpak",
> +            "flatpak_remote",
> +            "terraform",
> +        ):
> +            if "command" in res:
> +                return res["command"]
> +            return None
> +
> +        return None
> +
> +    def _has_significant_output(self, result) -> bool:
> +        """Check if result has stdout, stderr, or msg content worth showing"""
> +        res = result._result
> +        return bool(
> +            (res.get("stdout") and res["stdout"].strip())
> +            or (res.get("stderr") and res["stderr"].strip())
> +            or (res.get("msg") and not res.get("stdout"))
> +        )
> +
> +    def _get_terminal_width(self) -> int:
> +        """Get current terminal width, with fallback to default"""
> +        try:
> +            size = shutil.get_terminal_size(fallback=(self.DEFAULT_TERMINAL_WIDTH, 24))
> +            return size.columns
> +        except Exception:
> +            return self.DEFAULT_TERMINAL_WIDTH
> +
> +    def _truncate_line(self, line: str, max_width: Optional[int] = None) -> str:
> +        """Truncate line to fit terminal width, adding ellipsis if needed"""
> +        if max_width is None:
> +            max_width = self._get_terminal_width()
> +
> +        if len(line) <= max_width:
> +            return line
> +
> +        # Reserve 3 characters for ellipsis
> +        if max_width <= 3:
> +            return "..." if max_width >= 3 else line[:max_width]
> +
> +        return line[: max_width - 3] + "..."
> +
> +    def _format_duration(self, seconds: float, width: int = 0) -> str:
> +        """Format duration as human readable, optionally right-aligned to width"""
> +        if seconds < 60:
> +            duration = f"{seconds:.1f}s"
> +        elif seconds < 3600:
> +            mins = int(seconds // 60)
> +            secs = int(seconds % 60)
> +            duration = f"{mins}m {secs}s"
> +        elif seconds < 86400:
> +            hours = int(seconds // 3600)
> +            mins = int((seconds % 3600) // 60)
> +            duration = f"{hours}h {mins}m"
> +        else:
> +            days = int(seconds // 86400)
> +            hours = int((seconds % 86400) // 3600)
> +            duration = f"{days}d {hours}h"
> +
> +        if width > 0:
> +            return duration.rjust(width)
> +        return duration
> +
> +    def _display_message(self, message: str, color=None):
> +        """Display message with optional color"""
> +        if color:
> +            self._display.display(message, color=color)
> +        else:
> +            self._display.display(message)
> +
> +    # ========================================================================
> +    # Ansible v2 Callback Methods
> +    # ========================================================================
> +
> +    def v2_playbook_on_start(self, playbook):
> +        """Playbook started"""
> +        playbook_name = os.path.basename(playbook._file_name)
> +
> +        # Create log file now that we have playbook name
> +        self._create_log_file(playbook_name)
> +
> +        msg = f"PLAYBOOK: {playbook_name}"
> +        self._display_message(msg, C.COLOR_HIGHLIGHT)
> +        self._write_to_log(msg)
> +
> +        # Show log file path early so user can tail -f
> +        if self.log_file_path:
> +            self._display.display(f"Log: {self.log_file_path}")
> +
> +    def v2_playbook_on_play_start(self, play):
> +        """Play started"""
> +        name = play.get_name().strip()
> +
> +        # Get actual host list from play (resolved from inventory)
> +        # play.hosts is just the pattern, we need to get actual hosts
> +        self.play_hosts = []
> +        try:
> +            # Get hosts from the play's host list (resolved by PlayIterator)
> +            host_list = (
> +                play.get_variable_manager()
> +                .get_vars(play=play)
> +                .get("ansible_play_hosts_all", [])
> +            )
> +            if host_list:
> +                self.play_hosts = list(host_list)
> +        except (AttributeError, TypeError):
> +            # Fallback: hosts will be populated as tasks start
> +            pass
> +
> +        hosts = play.hosts
> +        if isinstance(hosts, list):
> +            hosts_str = ", ".join(hosts[:3])
> +            if len(hosts) > 3:
> +                hosts_str += f" (+{len(hosts) - 3} more)"
> +        else:
> +            hosts_str = str(hosts)
> +
> +        msg = f"\nPLAY: {name} [{hosts_str}]"
> +        self._display_message(msg, C.COLOR_HIGHLIGHT)
> +        self._write_to_log(msg)
> +
> +    def v2_playbook_on_task_start(self, task, is_conditional):
> +        """Task started"""
> +        self.current_task_name = task.get_name().strip()
> +        # Initialize with play hosts so display is stable from the start
> +        self.current_task_hosts = list(self.play_hosts) if self.play_hosts else []
> +
> +        # In static mode, print immediately (compact - no leading newline)
> +        if not self.dynamic_mode:
> +            msg = f"TASK: {self.current_task_name}"
> +            self._display_message(msg, C.COLOR_HIGHLIGHT)
> +
> +        self._write_to_log(f"TASK: {self.current_task_name}")
> +
> +    def v2_runner_on_start(self, host, task):
> +        """Task started on a host (for dynamic tracking)"""
> +        key = (host.name, task._uuid)
> +
> +        # Check for task delegation
> +        delegate_to = getattr(task, "delegate_to", None)
> +
> +        with self.task_lock:
> +            self.running_tasks[key] = {
> +                "start_time": time.time(),
> +                "host": host.name,
> +                "delegate_to": delegate_to,
> +                "task_name": task.get_name().strip(),
> +            }
> +
> +        if host.name not in self.current_task_hosts:
> +            self.current_task_hosts.append(host.name)
> +
> +        if self.dynamic_mode:
> +            self._redraw_display()
> +
> +    def v2_runner_on_ok(self, result):
> +        """Task succeeded"""
> +        changed = result._result.get("changed", False)
> +        status = "changed" if changed else "ok"
> +        self._handle_result(result, status)
> +
> +    def v2_runner_on_failed(self, result, ignore_errors=False):
> +        """Task failed"""
> +        self._handle_result(result, "failed", ignore_errors=ignore_errors)
> +
> +    def v2_runner_on_skipped(self, result):
> +        """Task skipped"""
> +        self._handle_result(result, "skipped")
> +
> +    def v2_runner_on_unreachable(self, result):
> +        """Host unreachable"""
> +        self._handle_result(result, "unreachable")
> +
> +    def v2_runner_retry(self, result):
> +        """Task is being retried after failure"""
> +        host = result._host.name
> +        task_uuid = result._task._uuid
> +        key = (host, task_uuid)
> +
> +        retries = result._result.get("retries", 0)
> +        attempts = result._result.get("attempts", 0)
> +
> +        # Update retry info in running_tasks
> +        with self.task_lock:
> +            if key in self.running_tasks:
> +                self.running_tasks[key]["retry_attempt"] = attempts
> +                self.running_tasks[key]["retry_total"] = retries
> +
> +        # Log the retry attempt
> +        self._write_to_log(f"  ✗ [{host}] retry {attempts}/{retries} - retrying...")
> +
> +        # In static mode, show retry immediately
> +        if not self.dynamic_mode:
> +            msg = f"  ✗ [{host}] retry {attempts}/{retries}"
> +            self._display_message(msg, C.COLOR_WARN)
> +
> +    def v2_playbook_on_stats(self, stats):
> +        """Final summary"""
> +        # Stop update thread
> +        if self.update_thread_stop:
> +            self.update_thread_stop.set()
> +        if self.update_thread:
> +            self.update_thread.join(timeout=1.0)
> +
> +        # Clear dynamic display if active
> +        if self.dynamic_mode and self.display_lines > 0:
> +            self._clear_display()
> +
> +        # Print recap
> +        self._display_recap(stats)
> +
> +        # Log file footer
> +        self._write_to_log(
> +            f"\n=== Playbook Completed: {datetime.now().isoformat()} ==="
> +        )
> +
> +    # ========================================================================
> +    # Result Handling
> +    # ========================================================================
> +
> +    def _handle_result(self, result, status: str, ignore_errors: bool = False):
> +        """Unified result handler for all task outcomes"""
> +        host = result._host.name
> +        task_uuid = result._task._uuid
> +        key = (host, task_uuid)
> +
> +        # Calculate duration with defensive access
> +        with self.task_lock:
> +            task_info = self.running_tasks.pop(key, None)
> +        start_time = (
> +            task_info.get("start_time", time.time()) if task_info else time.time()
> +        )
> +        duration = time.time() - start_time
> +
> +        # Get delegation info from result (more reliable than task attribute)
> +        delegated_vars = result._result.get("_ansible_delegated_vars", {})
> +        delegate_to = delegated_vars.get("ansible_delegated_host")

Is the potential inconsistency between the result shown during task
execution and the result shown after completion documented
somewhere? (Does the user know she should expect weirdness?)


> +
> +        # Store result data for dynamic mode display
> +        result_data = {
> +            "result": result,
> +            "status": status,
> +            "duration": duration,
> +            "host": host,
> +            "delegate_to": delegate_to,
> +            "task_name": result._task.get_name().strip(),
> +        }
> +
> +        # Add to completed tasks (last 3 for dynamic mode)
> +        self.completed_tasks.append(result_data)
> +
> +        # Log everything (max verbosity)
> +        self._log_result(result, status, duration)
> +
> +        # Display based on mode
> +        if self.dynamic_mode:
> +            # Dynamic mode will update on next refresh
> +            # Freeze display and show output for failed tasks (not ignored)
> +            if status == "failed" and not ignore_errors:
> +                self._freeze_and_show_output(result_data)
> +        else:
> +            # Static mode - display immediately
> +            self._display_result_static(result, status, duration)
> +
> +    def _display_result_static(self, result, status: str, duration: float):
> +        """Display result in static mode"""
> +        host = result._host.name
> +
> +        # Early exit for tasks that shouldn't be shown at current verbosity
> +        # At verbosity 0: only show changed, failed, unreachable
> +        if self._display.verbosity < 1 and status in ("ok", "skipped"):
> +            return
> +
> +        # Determine if we should show output (for tasks that pass visibility check)
> +        show_output = self._should_display_output(result, status)
> +
> +        # Format status line using class constants
> +        symbol = self.STATUS_SYMBOLS.get(status, "?")
> +        color = self.STATUS_COLORS.get(status, C.COLOR_OK)
> +
> +        # Get delegation info for display (like Ansible default: [host -> delegate])
> +        delegated_vars = result._result.get("_ansible_delegated_vars", {})
> +        delegate_to = delegated_vars.get("ansible_delegated_host")
> +        if delegate_to:
> +            host_display = f"{host} -> {delegate_to}"
> +        else:
> +            host_display = host
> +
> +        # Format time (always shown for all statuses)
> +        time_str = f" ({self._format_duration(duration)})"
> +
> +        # Status line with unicode spacing (using regular spaces, not braille)
> +        status_line = f"  {symbol} [{host_display}]{time_str}"
> +        self._display_message(status_line, color)
> +
> +        # Show command for supported modules when running with -v or higher
> +        if self._display.verbosity >= 1:
> +            command = self._get_task_command(result)
> +            if command:
> +                # Truncate very long commands
> +                if len(command) > 200:
> +                    command = command[:197] + "..."
> +                self._display.display(f"    $ {command}", color=C.COLOR_VERBOSE)
> +
> +        # Show output if conditions met
> +        if show_output:
> +            self._display_output(result)
> +
> +    def _display_output(self, result):
> +        """Display stdout/stderr/msg from task result"""
> +        output = []
> +        res = result._result
> +
> +        # stdout
> +        if "stdout" in res and res["stdout"]:
> +            output.append(f"\nSTDOUT:\n{res['stdout']}")
> +
> +        # stderr
> +        if "stderr" in res and res["stderr"]:
> +            output.append(f"\nSTDERR:\n{res['stderr']}")
> +
> +        # msg (only if no stdout content)
> +        if "msg" in res and res["msg"] and not res.get("stdout"):
> +            msg_text = res["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}")
> +
> +        if output:
> +            self._display.display("".join(output))
> +
> +    def _log_result(self, result, status: str, duration: float):
> +        """Write result to log file (always max verbosity)"""
> +        host = result._host.name
> +        res = result._result
> +
> +        # Get delegation info for logging
> +        delegated_vars = res.get("_ansible_delegated_vars", {})
> +        delegate_to = delegated_vars.get("ansible_delegated_host")
> +        if delegate_to:
> +            host_display = f"{host} -> {delegate_to}"
> +        else:
> +            host_display = host
> +
> +        # Status line using class constants
> +        symbol = self.STATUS_SYMBOLS.get(status, "?")
> +        time_str = self._format_duration(duration)
> +        log_line = f"  {symbol} [{host_display}] ({time_str})"
> +        self._write_to_log(log_line)
> +
> +        # Log command for supported modules
> +        command = self._get_task_command(result)
> +        if command:
> +            self._write_to_log(f"\n$ {command}")
> +
> +        # Always log full output
> +        if "stdout" in res and res["stdout"]:
> +            self._write_to_log(f"\nSTDOUT:\n{res['stdout']}\n")
> +
> +        if "stderr" in res and res["stderr"]:
> +            self._write_to_log(f"\nSTDERR:\n{res['stderr']}\n")
> +
> +        if "msg" in res and res["msg"]:
> +            msg_text = res["msg"]
> +            if isinstance(msg_text, (list, dict)):
> +                msg_text = json.dumps(msg_text, indent=2)
> +            self._write_to_log(f"\nMSG:\n{msg_text}\n")
> +
> +        if status == "failed" and "exception" in res:
> +            self._write_to_log(f"\nEXCEPTION:\n{res['exception']}\n")
> +
> +    def _display_recap(self, stats):
> +        """Display final statistics"""
> +        self._display_message("\nPLAY RECAP", C.COLOR_HIGHLIGHT)
> +
> +        hosts = sorted(stats.processed.keys())
> +        for host in hosts:
> +            summary = stats.summarize(host)
> +
> +            # More compact format
> +            msg = (
> +                f"{host:25s} : "
> +                f"ok={summary['ok']:<3d} "
> +                f"changed={summary['changed']:<3d} "
> +                f"unreachable={summary['unreachable']:<3d} "
> +                f"failed={summary['failures']:<3d} "
> +                f"skipped={summary['skipped']:<3d}"
> +            )
> +
> +            # Color based on status
> +            if summary["failures"] > 0 or summary["unreachable"] > 0:
> +                color = C.COLOR_ERROR
> +            elif summary["changed"] > 0:
> +                color = C.COLOR_CHANGED
> +            else:
> +                color = C.COLOR_OK
> +
> +            self._display_message(msg, color)
> +
> +    # ========================================================================
> +    # Dynamic Mode Display
> +    # ========================================================================
> +
> +    def _redraw_display(self):
> +        """Redraw entire display in dynamic mode"""
> +        # Throttle updates (max once per 0.1s)
> +        now = time.time()
> +        if now - self.last_update < 0.1:
> +            return
> +        self.last_update = now
> +
> +        # Increment spinner
> +        self.spinner_index = (self.spinner_index + 1) % len(self.SPINNER_FRAMES)
> +
> +        # Clear previous display
> +        self._clear_display()
> +
> +        # Get terminal width once for all lines
> +        term_width = self._get_terminal_width()
> +
> +        # Build new display
> +        lines = []
> +
> +        # Task header - truncate to terminal width
> +        if self.current_task_name:
> +            task_line = f"TASK: {self.current_task_name}"
> +            lines.append(self._truncate_line(task_line, term_width))
> +            lines.append("")
> +
> +        # Host status - show ALL hosts in task, running ones get spinner
> +        # Create snapshot of running tasks under lock
> +        with self.task_lock:
> +            running_hosts = {
> +                host: info for (host, _), info in self.running_tasks.items()
> +            }
> +
> +        if self.current_task_hosts:
> +            running_count = len(running_hosts)
> +            total_hosts = len(self.current_task_hosts)
> +            lines.append(f"Hosts: {running_count}/{total_hosts} running")
> +
> +            spinner = self.SPINNER_FRAMES[self.spinner_index]
> +
> +            # Show all hosts in stable order, running ones with spinner
> +            # Format: [spinner] <time> <hostname> - time is fixed width (8 chars)
> +            # This keeps spinner and time columns stable, hostname varies at end
> +            time_width = 8  # Enough for "1m 30s", "10h 5m", or "99d 23h"
> +
> +            for host in self.current_task_hosts:
> +                if host in running_hosts:
> +                    # Running - show spinner, time, then hostname
> +                    task_info = running_hosts[host]
> +                    elapsed = time.time() - task_info["start_time"]
> +                    duration_str = self._format_duration(elapsed, width=time_width)
> +
> +                    # Get delegation info
> +                    delegate_to = task_info.get("delegate_to")
> +                    if delegate_to:
> +                        host_display = f"{host} -> {delegate_to}"
> +                    else:
> +                        host_display = host
> +
> +                    # Build retry suffix if applicable
> +                    retry_suffix = ""
> +                    if "retry_attempt" in task_info:
> +                        attempt = task_info["retry_attempt"]
> +                        total = task_info["retry_total"]
> +                        retry_suffix = f" (retry {attempt}/{total})"
> +
> +                    # Format: spinner, fixed-width time, hostname at end
> +                    host_line = (
> +                        f"  [{spinner}] {duration_str}  {host_display}{retry_suffix}"
> +                    )
> +                else:
> +                    # Not running - show empty brackets and blank time column
> +                    blank_time = " " * time_width
> +                    host_line = f"  [ ] {blank_time}  {host}"
> +
> +                lines.append(self._truncate_line(host_line, term_width))
> +
> +            lines.append("")
> +
> +        # Recently completed (after running tasks)
> +        if self.completed_tasks:
> +            lines.append("Recent:")
> +            for task_data in self.completed_tasks:
> +                status = task_data["status"]
> +                host = task_data["host"]
> +                duration = task_data["duration"]
> +                task_name = task_data.get("task_name", "Unknown task")
> +                delegate_to = task_data.get("delegate_to")
> +
> +                # Format host with delegation if present
> +                if delegate_to:
> +                    host_display = f"{host} -> {delegate_to}"
> +                else:
> +                    host_display = host
> +
> +                # Use class constants for consistent symbols
> +                symbol = self.STATUS_SYMBOLS.get(status, "?")
> +
> +                time_str = self._format_duration(duration)
> +                recent_line = f"  {symbol} {task_name} [{host_display}] ({time_str})"
> +                lines.append(self._truncate_line(recent_line, term_width))
> +            lines.append("")
> +
> +        # Display all lines
> +        if lines:
> +            output = "\n".join(lines)
> +            # Write directly to avoid extra newlines
> +            sys.stdout.write(output)
> +            sys.stdout.flush()
> +            # Count actual lines printed (number of newlines + 1 for the last line)
> +            self.display_lines = output.count("\n") + 1
> +        else:
> +            self.display_lines = 0

Writing directly to sys.stdout bypasses Ansible's display system
(self._display), which could cause output interleaving issues or
conflict with Ansible's output redirection/buffering. The
background thread writes via sys.stdout.write() while the main
Ansible thread could simultaneously call self._display.display()
(e.g., during _display_result_static), resulting in garbled output.

Depending on what the code is trying to avoid here:

- For ANSI escape sequences, use self._display.display() with empty
  or specially crafted messages

- Lock around all output: If raw stdout is necessary for ANSI cursor
  control, use a lock shared between both output paths

- Flush coordination: Ensure sys.stdout.flush() and any internal
  display buffers are synchronized

- You might be able to access lower-level methods that support
  cursor control while preventing interleaving


> +
> +    def _clear_display(self):
> +        """Clear dynamic display using ANSI escape codes"""
> +        if self.display_lines > 0:
> +            # Clear current line first
> +            sys.stdout.write("\r\033[2K")
> +            # Move up and clear remaining lines
> +            for _ in range(self.display_lines - 1):
> +                sys.stdout.write("\033[1A")  # Move cursor up one line
> +                sys.stdout.write("\033[2K")  # Clear entire line
> +            sys.stdout.flush()
> +            self.display_lines = 0
> +
> +    def _freeze_and_show_output(self, result_data):
> +        """
> +        Freeze display and show task output in dynamic mode for failures.
> +        Disables dynamic mode for the rest of the playbook.
> +        """
> +        # Clear dynamic display
> +        if self.display_lines > 0:
> +            self._clear_display()
> +
> +        # Show result in static format
> +        result = result_data["result"]
> +        status = result_data["status"]
> +        duration = result_data["duration"]
> +
> +        # Print task name
> +        msg = f"TASK: {self.current_task_name}"
> +        self._display_message(msg, C.COLOR_HIGHLIGHT)
> +
> +        # Show result with output
> +        self._display_result_static(result, status, duration)
> +
> +        # Disable dynamic mode for rest of playbook after failure
> +        self.dynamic_mode = False
> +        if self.update_thread_stop:
> +            self.update_thread_stop.set()
> +
> +    def __del__(self):
> +        """Cleanup when plugin is destroyed"""
> +        if self.update_thread_stop:
> +            self.update_thread_stop.set()
> +        if self.update_thread:
> +            self.update_thread.join(timeout=1.0)

I'm told that relying on __del__ for cleanup is fragile and not
recommended in Python. Is this only a fallback? I'm not sure it's
needed at all.


> diff --git a/defconfigs/configs/lucid-ci.config b/defconfigs/configs/lucid-ci.config
> new file mode 100644
> index 00000000..03aee9fd
> --- /dev/null
> +++ b/defconfigs/configs/lucid-ci.config
> @@ -0,0 +1,3 @@
> +CONFIG_ANSIBLE_CFG_CALLBACK_PLUGIN_LUCID=y
> +CONFIG_ANSIBLE_CFG_LUCID_OUTPUT_MODE_STATIC=y
> +CONFIG_KDEVOPS_ANSIBLE_VERBOSE=1
> diff --git a/defconfigs/configs/lucid.config b/defconfigs/configs/lucid.config
> new file mode 100644
> index 00000000..94e943f0
> --- /dev/null
> +++ b/defconfigs/configs/lucid.config
> @@ -0,0 +1,2 @@
> +CONFIG_ANSIBLE_CFG_CALLBACK_PLUGIN_LUCID=y
> +CONFIG_ANSIBLE_CFG_LUCID_OUTPUT_MODE_AUTO=y
> diff --git a/docs/ansible-callbacks.md b/docs/ansible-callbacks.md
> new file mode 100644
> index 00000000..2f99089f
> --- /dev/null
> +++ b/docs/ansible-callbacks.md
> @@ -0,0 +1,294 @@
> +# Ansible Callback Plugins
> +
> +kdevops supports multiple Ansible stdout callback plugins for controlling
> +playbook output. The callback plugin can be configured via Kconfig or
> +overridden from the command line.
> +
> +## Supported Plugins
> +
> +| Plugin | Description |
> +|--------|-------------|
> +| dense (default) | Compact output from community.general |
> +| debug | Ansible builtin debug callback |
> +| diy | Customizable output from community.general |
> +| lucid | Clean, minimal output with dynamic display (kdevops custom) |
> +| custom | Any Ansible callback plugin by name |
> +
> +## Configuration
> +
> +### Via Kconfig
> +
> +Select the callback plugin in menuconfig:
> +
> +```bash
> +make menuconfig
> +# Navigate to: Ansible Callback Plugin Configuration
> +```
> +
> +### Via Command Line
> +
> +Override the default at build time:
> +
> +```bash
> +make ANSIBLE_CFG_CALLBACK_PLUGIN=lucid menuconfig
> +make
> +```
> +
> +When `ANSIBLE_CFG_CALLBACK_PLUGIN` is set via environment, the custom plugin
> +option is automatically selected and populated with the specified value.
> +
> +## Plugin Details
> +
> +### dense (default)
> +
> +The dense callback from community.general provides compact single-line output.
> +
> +- Documentation: https://docs.ansible.com/ansible/latest/collections/community/general/dense_callback.html
> +
> +### debug
> +
> +The Ansible builtin debug callback shows detailed task information.
> +
> +- Documentation: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/debug_module.html

A better URL:

  https://docs.ansible.com/projects/ansible/latest/collections/ansible/posix/debug_callback.html


> +
> +### diy
> +
> +The DIY callback from community.general allows custom output formatting
> +via ansible.cfg configuration.
> +
> +- Documentation: https://docs.ansible.com/ansible/latest/collections/community/general/diy_callback.html
> +
> +### lucid
> +
> +A custom callback plugin developed for kdevops providing clean, minimal
> +output with progressive verbosity levels and optional dynamic terminal
> +display.
> +
> +#### Features
> +
> +- Clean, minimal output by default (shows only changes and errors)
> +- Progressive verbosity with `-v`, `-vv`, `-vvv` flags
> +- Dynamic live display for interactive terminals (BitBake/Yocto style)
> +- Automatic static fallback for CI/CD and piped output
> +- Task-level output control via `output_verbosity` variable
> +- Comprehensive logging (always max verbosity, independent of display)
> +- Consistent time tracking shown for all tasks in both modes
> +
> +#### Status Symbols
> +
> +| Symbol | Status | Meaning |
> +|--------|--------|---------|
> +| ✓ | ok | Task succeeded, no changes made |
> +| * | changed | Task succeeded and made changes |
> +| ✗ | failed | Task failed |
> +| ⊘ | skipped | Task was skipped |
> +| ! | unreachable | Host was unreachable |
> +
> +Example output (no flags):
> +
> +```
> +TASK: Install packages
> +  * [host2] (2.3s)
> +TASK: Configure service
> +  ✗ [host2] (0.1s)
> +```
> +
> +Example output with `-v`:
> +
> +```
> +TASK: Install packages
> +  ✓ [host1] (0.6s)
> +  * [host2] (2.3s)
> +    $ apt-get install -y nginx
> +TASK: Configure service
> +  ⊘ [host1] (0.0s)
> +  ✗ [host2] (0.1s)
> +```
> +
> +#### Verbosity Levels
> +
> +Lucid uses Ansible's standard verbosity flags to control output detail:
> +
> +| Flag | Level | Console Output |
> +|------|-------|----------------|
> +| (none) | 0 | Only changed, failed, unreachable tasks (with stdout/stderr) |
> +| `-v` | 1 | All tasks (ok, skipped) + executed commands + stdout/stderr |
> +| `-vv` | 2 | Above + tasks with `output_verbosity: 2` |
> +| `-vvv` | 3 | Above + tasks with `output_verbosity: 3` |
> +
> +**Output display rules:**
> +- Changed, failed, unreachable: Always show stdout/stderr/msg
> +- OK tasks: Show stdout/stderr only with `-v` or higher
> +- Skipped tasks: Show only with `-v` or higher
> +
> +**Log files always capture full output regardless of verbosity level.**
> +
> +#### Enable lucid
> +
> +```bash
> +./scripts/kconfig/merge_config.sh -n .config defconfigs/configs/lucid.config
> +make
> +```
> +
> +Or via command line:
> +
> +```bash
> +make ANSIBLE_CFG_CALLBACK_PLUGIN=lucid menuconfig
> +make
> +```
> +
> +#### CI/CD Configuration
> +
> +For CI/CD environments where you want full verbose output similar to log files,
> +use the lucid-ci configuration fragment:
> +
> +```bash
> +./scripts/kconfig/merge_config.sh -n .config defconfigs/configs/lucid-ci.config
> +make
> +```
> +
> +This enables lucid with static output mode and verbosity level 1, providing
> +comprehensive output including all task results, executed commands, and
> +stdout/stderr without requiring manual `-v` flags.
> +
> +The verbosity level can be configured via Kconfig:
> +
> +```bash
> +make menuconfig
> +# Navigate to: Ansible Configuration -> Ansible verbosity level
> +```
> +
> +Or override at runtime using the native Ansible environment variable:
> +
> +```bash
> +ANSIBLE_VERBOSITY=1 make target  # Force verbosity level 1 for this run
> +```
> +
> +#### Parameters
> +
> +| Parameter | Choices/Defaults | Configuration | Comments |
> +|-----------|------------------|---------------|----------|
> +| output_mode | Choices: auto/static/dynamic<br>Default: auto | ini: [callback_lucid] output_mode<br>env: ANSIBLE_LUCID_OUTPUT_MODE | Display mode: auto detects terminal, static for CI/CD, dynamic for live updates |
> +
> +Log files are automatically created in `.ansible/logs/` with timestamped filenames.
> +
> +#### Command Display
> +
> +When running with `-v` or higher verbosity, lucid shows the executed command
> +for modules that expose their commands in task results:
> +
> +- `ansible.builtin.shell`, `ansible.builtin.command`
> +- `ansible.builtin.pip`
> +- `community.general.make`
> +- `community.general.flatpak`, `community.general.flatpak_remote`
> +- `community.general.terraform`
> +
> +Commands are always logged to the log file regardless of verbosity level.
> +
> +#### Task-level Output Control
> +
> +Control visibility of individual tasks using the `output_verbosity` variable.
> +Tasks are shown when the current verbosity level meets or exceeds the task's
> +`output_verbosity` setting:
> +
> +```yaml
> +- name: Always visible task (shown even without -v)
> +  debug:
> +    msg: "Important status message"
> +  vars:
> +    output_verbosity: 0
> +
> +- name: Normal task (shown with -v)
> +  debug:
> +    msg: "Standard task output"
> +  vars:
> +    output_verbosity: 1  # This is the default
> +
> +- name: Debug task (shown with -vv)
> +  debug:
> +    msg: "Debugging information"
> +  vars:
> +    output_verbosity: 2
> +
> +- name: Trace task (shown with -vvv)
> +  debug:
> +    msg: "Detailed trace output"
> +  vars:
> +    output_verbosity: 3
> +```
> +
> +#### Force Static Mode
> +
> +Use the environment variable to force static output mode:
> +
> +```bash
> +ANSIBLE_LUCID_OUTPUT_MODE=static ansible-playbook playbooks/test.yml
> +```
> +
> +Or via make:
> +
> +```bash
> +ANSIBLE_LUCID_OUTPUT_MODE=static make bringup
> +```
> +
> +The standard `TERM=dumb` convention is also respected for compatibility:
> +
> +```bash
> +TERM=dumb make bringup
> +```
> +
> +#### Dynamic Mode Display
> +
> +In dynamic mode, the display shows all hosts participating in a task with a
> +stable layout that prevents flickering:
> +
> +```
> +TASK: Install packages
> +
> +Hosts: 1/2 running
> +  [⠦]    2.3s  host1 -> localhost
> +  [ ]          host2
> +
> +Recent:
> +  * Install deps [host1] (1.2s)
> +```
> +
> +Running hosts show a spinner and elapsed time. Non-running hosts show empty
> +brackets. The time column is fixed-width to prevent display jumping.
> +
> +#### Delegation Display
> +
> +Tasks using `delegate_to` show delegation info in the format `host -> delegate`,
> +matching Ansible's default callback behavior:
> +
> +```
> +  * [host1 -> localhost] (2.3s)
> +```
> +
> +This appears in static mode, dynamic mode, and log files.
> +
> +#### Notes
> +
> +- Dynamic mode provides live updates with spinner when running in interactive terminals
> +- Automatically detects CI/CD environments (GitHub Actions, Jenkins, GitLab CI) and uses static mode
> +- Logs are always full verbosity regardless of display verbosity
> +- Log files include playbook name and timestamp: `<playbook>-YYYY-MM-DD_HH-MM-SS.log`
> +- Compatible with ansible.posix.profile_tasks callback
> +- Failed tasks always freeze dynamic display and switch to static mode
> +
> +### custom
> +
> +Select any Ansible callback plugin by name. When selected, a text field
> +allows entering the full plugin name (e.g., `ansible.posix.json`).
> +
> +## Timing Analysis
> +
> +Enable `profile_tasks` for detailed timing analysis alongside any callback:
> +
> +```bash
> +make menuconfig
> +# Enable: Ansible Callback Plugin Configuration -> Enable profile_tasks callback for timing analysis
> +```
> +
> +This adds a `TASKS RECAP` section at the end showing execution times for
> +each task, similar to `systemd-analyze blame`.
> diff --git a/kconfigs/Kconfig.ansible_cfg b/kconfigs/Kconfig.ansible_cfg
> index bea093fe..1e9539b1 100644
> --- a/kconfigs/Kconfig.ansible_cfg
> +++ b/kconfigs/Kconfig.ansible_cfg
> @@ -59,6 +59,23 @@ choice
>  	default ANSIBLE_CFG_CALLBACK_PLUGIN_DENSE if !ANSIBLE_CFG_CALLBACK_PLUGIN_SET_BY_CLI
>  	default ANSIBLE_CFG_CALLBACK_PLUGIN_CUSTOM if ANSIBLE_CFG_CALLBACK_PLUGIN_SET_BY_CLI
>  
> +config ANSIBLE_CFG_CALLBACK_PLUGIN_LUCID
> +	bool "Ansible Lucid Callback Plugin"
> +	help
> +	  Lucid: Modern, clean Ansible output with progressive verbosity levels.
> +
> +	  Features:
> +	  - Clean, minimal output by default (shows only changes and errors)
> +	  - Progressive verbosity: -v shows all tasks, -vv shows debug tasks
> +	  - Dynamic live display for interactive terminals (BitBake/Yocto style)
> +	  - Static output for CI/CD and piped output
> +	  - Task-level output control via output_verbosity variable
> +	  - Comprehensive logging (always max verbosity, independent of display)
> +	  - Time tracking for tasks > 3 seconds
> +
> +	  Lucid is designed by kernel developers for kernel testing workflows
> +	  where human-readable output and silent operation are essential.
> +
>  config ANSIBLE_CFG_CALLBACK_PLUGIN_DEBUG
>  	bool "Ansible Debug Callback Plugin"
>  	help
> @@ -93,6 +110,7 @@ endif # ANSIBLE_CFG_CALLBACK_PLUGIN_CUSTOM
>  config ANSIBLE_CFG_CALLBACK_PLUGIN_STRING
>  	string
>  	output yaml
> +	default "lucid" if ANSIBLE_CFG_CALLBACK_PLUGIN_LUCID
>  	default "debug" if ANSIBLE_CFG_CALLBACK_PLUGIN_DEBUG
>  	default "dense" if ANSIBLE_CFG_CALLBACK_PLUGIN_DENSE
>  	default "community.general.diy" if ANSIBLE_CFG_CALLBACK_PLUGIN_DIY
> @@ -170,6 +188,62 @@ config ANSIBLE_CFG_CALLBACK_PLUGIN_PROFILE_TASKS
>  	  and generates a "TASKS RECAP" section showing execution times.
>  
>  	  https://docs.ansible.com/ansible/latest/collections/ansible/posix/profile_tasks_callback.html
> +
> +if ANSIBLE_CFG_CALLBACK_PLUGIN_LUCID
> +
> +choice
> +	prompt "Lucid output display mode"
> +	default ANSIBLE_CFG_LUCID_OUTPUT_MODE_AUTO
> +
> +config ANSIBLE_CFG_LUCID_OUTPUT_MODE_AUTO
> +	bool "Auto (dynamic if interactive, static otherwise)"
> +	help
> +	  Automatically select output mode based on terminal detection.
> +
> +	  Uses dynamic live updating display (similar to BitBake/Yocto) when
> +	  running in an interactive terminal with spinner animations, live
> +	  task timers, and parallel task visualization.
> +
> +	  Automatically falls back to traditional static output for CI/CD,
> +	  piped output, and non-TTY contexts.
> +
> +	  This is the recommended default for most use cases.
> +
> +config ANSIBLE_CFG_LUCID_OUTPUT_MODE_STATIC
> +	bool "Static (always use traditional output)"
> +	help
> +	  Force static output mode regardless of terminal type.
> +
> +	  Always use traditional static output with no live updates or
> +	  animations. Each task result is printed as it completes.
> +
> +	  Useful for users who prefer traditional Ansible output or when
> +	  dynamic mode causes issues with specific terminal emulators.
> +
> +config ANSIBLE_CFG_LUCID_OUTPUT_MODE_DYNAMIC
> +	bool "Dynamic (always attempt live display)"
> +	help
> +	  Force dynamic output mode even in non-interactive contexts.
> +
> +	  Always attempt to use live updating display with ANSI escape codes
> +	  even if terminal detection suggests static mode should be used.
> +
> +	  Warning: May produce garbled output in CI/CD, piped contexts, or
> +	  terminals that don't support ANSI escape sequences. Only use this
> +	  if you know your environment supports dynamic output but the
> +	  automatic detection fails.
> +
> +endchoice
> +
> +config ANSIBLE_CFG_LUCID_OUTPUT_MODE_STRING
> +	string
> +	output yaml
> +	default "auto" if ANSIBLE_CFG_LUCID_OUTPUT_MODE_AUTO
> +	default "static" if ANSIBLE_CFG_LUCID_OUTPUT_MODE_STATIC
> +	default "dynamic" if ANSIBLE_CFG_LUCID_OUTPUT_MODE_DYNAMIC
> +
> +endif # ANSIBLE_CFG_CALLBACK_PLUGIN_LUCID
> +
>  endmenu
>  
>  config ANSIBLE_CFG_VERBOSITY
> diff --git a/playbooks/roles/ansible_cfg/templates/ansible.cfg.j2 b/playbooks/roles/ansible_cfg/templates/ansible.cfg.j2
> index 165aec0e..e4841fb3 100644
> --- a/playbooks/roles/ansible_cfg/templates/ansible.cfg.j2
> +++ b/playbooks/roles/ansible_cfg/templates/ansible.cfg.j2
> @@ -1,6 +1,10 @@
>  [defaults]
> +{% if ansible_cfg_callback_plugin_string == 'lucid' %}
> +callback_plugins = ./callback_plugins
> +{% endif %}
>  {% if ansible_cfg_callback_plugin_profile_tasks %}
> -callbacks_enabled = ansible.posix.profile_tasks, {{ ansible_cfg_callback_plugin_string }}
> +callbacks_enabled = ansible.posix.profile_tasks{% if ansible_cfg_callback_plugin_string == 'lucid' %}, {{ ansible_cfg_callback_plugin_string }}{% endif %}
> +
>  {% endif %}
>  verbosity = {{ ansible_cfg_verbosity | default(0) }}
>  deprecation_warnings = {{ ansible_cfg_deprecation_warnings }}
> @@ -48,6 +52,12 @@ playbook_on_stats_msg_color = bright green
>  [callback_profile_tasks]
>  summary_only = true
>  {% endif %}
> +{% if ansible_cfg_callback_plugin_string == 'lucid' %}
> +
> +[callback_lucid]
> +output_mode = {{ ansible_cfg_lucid_output_mode_string | default('auto') }}
> +{% endif %}
> +
>  [ssh_connection]
>  remote_port = {{ ansible_cfg_ssh_port }}
>  {% if ansible_facts['distribution'] == 'openSUSE' %}
> 
> -- 
> 2.52.0
> 
> 

-- 
Chuck Lever

  reply	other threads:[~2025-12-03 21:11 UTC|newest]

Thread overview: 6+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-12-01 23:11 [PATCH v4 0/2] Lucid: A custom Ansible stdout callback for kdevops Daniel Gomez
2025-12-01 23:11 ` [PATCH v4 1/2] ansible: use native verbosity config instead of custom AV variable Daniel Gomez
2025-12-03 20:26   ` Chuck Lever
2025-12-01 23:11 ` [PATCH v4 2/2] ansible: add lucid callback plugin for clean output Daniel Gomez
2025-12-03 21:11   ` Chuck Lever [this message]
2025-12-01 23:14 ` [PATCH v4 0/2] Lucid: A custom Ansible stdout callback for kdevops Daniel Gomez

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=aTCnZB4FSAYc4_7V@klimt.1015granger.net \
    --to=cel@kernel.org \
    --cc=chuck.lever@oracle.com \
    --cc=da.gomez@kernel.org \
    --cc=da.gomez@samsung.com \
    --cc=kdevops@lists.linux.dev \
    --cc=mcgrof@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox