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
next prev parent 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