Re: [PATCH v4 2/2] ansible: add lucid callback plugin for clean output
Chuck Lever <[email protected]> Wed, 3 Dec 2025 16:11:00 -0500
| Newsgroups | dev.linux.lists.kdevops |
|---|---|
| Message-ID | <[email protected]> |
On Tue, Dec 02, 2025 at 12:11:17AM +0100, Daniel Gomez wrote: > 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]> 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