[PATCH v4 2/2] ansible: add lucid callback plugin for clean output
Daniel Gomez <[email protected]> Tue, 02 Dec 2025 00:11:17 +0100
| Newsgroups | dev.linux.lists.kdevops |
|---|---|
| Message-ID | <[email protected]> |
From: Daniel Gomez <[email protected]> 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 <[email protected]> --- .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 + + # 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() + + # 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) + 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") + + # 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 + + 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) 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 + +### 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