From f677c2ab7d413eeb7f303303d734a5b4e785eae3 Mon Sep 17 00:00:00 2001 From: Alexei Znamensky <103110+russoz@users.noreply.github.com> Date: Sun, 14 Jun 2026 03:01:50 +1200 Subject: [PATCH] counter_enabled callback: display output for looped tasks (#12067) * fix(counter_enabled): display output for looped tasks with delegate_to Implement v2_runner_item_on_ok, v2_runner_item_on_failed, and v2_runner_item_on_skipped so that looped tasks (including those using delegate_to: localhost) produce visible output. Also extract _host_label, _display_result_ok, _display_result_failed, and _display_result_skipped helpers to eliminate repeated delegation and message-building logic across the callback methods. Fixes #8187 Co-Authored-By: Claude Sonnet 4.6 * changelog(counter_enabled): add fragment for PR #12067 Co-Authored-By: Claude Sonnet 4.6 * test(counter_enabled): add integration tests, adjust _host_label Co-Authored-By: Claude Sonnet 4.6 * test(counter_enabled): migrate integration tests to callback test framework Co-Authored-By: Claude Sonnet 4.6 * test(counter_enabled): fix integration tests to use set_fact instead of debug Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../12067-counter-enabled-loop-output.yml | 2 + plugins/callback/counter_enabled.py | 111 +++++++++--------- .../targets/callback_counter_enabled/aliases | 6 + .../callback_counter_enabled/tasks/main.yml | 102 ++++++++++++++++ 4 files changed, 165 insertions(+), 56 deletions(-) create mode 100644 changelogs/fragments/12067-counter-enabled-loop-output.yml create mode 100644 tests/integration/targets/callback_counter_enabled/aliases create mode 100644 tests/integration/targets/callback_counter_enabled/tasks/main.yml diff --git a/changelogs/fragments/12067-counter-enabled-loop-output.yml b/changelogs/fragments/12067-counter-enabled-loop-output.yml new file mode 100644 index 0000000000..a97ec38fea --- /dev/null +++ b/changelogs/fragments/12067-counter-enabled-loop-output.yml @@ -0,0 +1,2 @@ +bugfixes: + - counter_enabled callback - fix missing output for looped tasks, including tasks using ``delegate_to`` (https://github.com/ansible-collections/community.general/issues/8187, https://github.com/ansible-collections/community.general/pull/12067). diff --git a/plugins/callback/counter_enabled.py b/plugins/callback/counter_enabled.py index f6a961bb09..c543868485 100644 --- a/plugins/callback/counter_enabled.py +++ b/plugins/callback/counter_enabled.py @@ -140,69 +140,83 @@ class CallbackModule(CallbackBase): self._host_counter = self._previous_batch_total self._task_counter += 1 - def v2_runner_on_ok(self, result): - self._host_counter += 1 - - delegated_vars = result._result.get("_ansible_delegated_vars", None) - - if self._play.strategy == "free" and self._last_task_banner != result._task._uuid: - self._print_task_banner(result._task) + def _host_label(self, result): + host_name = result._host.get_name() + delegate_to = result._task.delegate_to + if delegate_to and delegate_to != host_name: + delegated_vars = result._result.get("_ansible_delegated_vars", {}) + ahost = delegated_vars.get("ansible_host", delegate_to) + label = f"{host_name} -> {delegate_to}" + if ahost != delegate_to: + label += f"({ahost})" + return f"[{label}]" + return f"[{host_name}]" + def _display_result_ok(self, result, counter_str="", item_suffix=""): if isinstance(result._task, TaskInclude): return elif result._result.get("changed", False): - if delegated_vars: - msg = f"changed: {self._host_counter}/{self._host_total} [{result._host.get_name()} -> {delegated_vars['ansible_host']}]" - else: - msg = f"changed: {self._host_counter}/{self._host_total} [{result._host.get_name()}]" + msg = f"changed: {counter_str}{self._host_label(result)}{item_suffix}" color = C.COLOR_CHANGED else: if not self._plugin_options.get("display_ok_hosts", True): return - if delegated_vars: - msg = f"ok: {self._host_counter}/{self._host_total} [{result._host.get_name()} -> {delegated_vars['ansible_host']}]" - else: - msg = f"ok: {self._host_counter}/{self._host_total} [{result._host.get_name()}]" + msg = f"ok: {counter_str}{self._host_label(result)}{item_suffix}" color = C.COLOR_OK self._handle_warnings(result._result) + self._clean_results(result._result, result._task.action) + if self._run_is_verbose(result): + msg += f" => {self._dump_results(result._result)}" + self._display.display(msg, color=color) + + def v2_runner_on_ok(self, result): + self._host_counter += 1 + + if self._play.strategy == "free" and self._last_task_banner != result._task._uuid: + self._print_task_banner(result._task) if result._task.loop and "results" in result._result: self._process_items(result) else: - self._clean_results(result._result, result._task.action) + self._display_result_ok(result, f"{self._host_counter}/{self._host_total} ") - if self._run_is_verbose(result): - msg += f" => {self._dump_results(result._result)}" - self._display.display(msg, color=color) + def v2_runner_item_on_ok(self, result): + self._display_result_ok(result, item_suffix=f" => (item={self._get_item_label(result._result)})") + + def _display_result_failed(self, result, counter_str="", item_suffix=""): + self._clean_results(result._result, result._task.action) + self._handle_warnings(result._result) + prefix = "failed" if item_suffix else "fatal" + msg = f"{prefix}: {counter_str}{self._host_label(result)}{item_suffix}" + self._display.display(f"{msg} => {self._dump_results(result._result)}", color=C.COLOR_ERROR) + + def v2_runner_item_on_failed(self, result): + self._display_result_failed(result, item_suffix=f" (item={self._get_item_label(result._result)})") + + def _display_result_skipped(self, result, counter_str="", item_suffix=""): + self._clean_results(result._result, result._task.action) + msg = f"skipping: {counter_str}[{result._host.get_name()}]{item_suffix}" + if self._run_is_verbose(result): + msg += f" => {self._dump_results(result._result)}" + self._display.display(msg, color=C.COLOR_SKIP) + + def v2_runner_item_on_skipped(self, result): + if self._plugin_options.get("show_skipped_hosts", C.DISPLAY_SKIPPED_HOSTS): + self._display_result_skipped(result, item_suffix=f" => (item={self._get_item_label(result._result)})") def v2_runner_on_failed(self, result, ignore_errors=False): self._host_counter += 1 - delegated_vars = result._result.get("_ansible_delegated_vars", None) - self._clean_results(result._result, result._task.action) - if self._play.strategy == "free" and self._last_task_banner != result._task._uuid: self._print_task_banner(result._task) self._handle_exception(result._result) - self._handle_warnings(result._result) if result._task.loop and "results" in result._result: self._process_items(result) - else: - if delegated_vars: - self._display.display( - f"fatal: {self._host_counter}/{self._host_total} [{result._host.get_name()} -> " - f"{delegated_vars['ansible_host']}]: FAILED! => {self._dump_results(result._result)}", - color=C.COLOR_ERROR, - ) - else: - self._display.display( - f"fatal: {self._host_counter}/{self._host_total} [{result._host.get_name()}]: FAILED! => {self._dump_results(result._result)}", - color=C.COLOR_ERROR, - ) + self._display_result_failed(result, f"{self._host_counter}/{self._host_total} ", ": FAILED!") if ignore_errors: self._display.display("...ignoring", color=C.COLOR_SKIP) @@ -210,21 +224,14 @@ class CallbackModule(CallbackBase): def v2_runner_on_skipped(self, result): self._host_counter += 1 - if self._plugin_options.get( - "show_skipped_hosts", C.DISPLAY_SKIPPED_HOSTS - ): # fallback on constants for inherited plugins missing docs - self._clean_results(result._result, result._task.action) - + if self._plugin_options.get("show_skipped_hosts", C.DISPLAY_SKIPPED_HOSTS): if self._play.strategy == "free" and self._last_task_banner != result._task._uuid: self._print_task_banner(result._task) if result._task.loop and "results" in result._result: self._process_items(result) else: - msg = f"skipping: {self._host_counter}/{self._host_total} [{result._host.get_name()}]" - if self._run_is_verbose(result): - msg += f" => {self._dump_results(result._result)}" - self._display.display(msg, color=C.COLOR_SKIP) + self._display_result_skipped(result, f"{self._host_counter}/{self._host_total} ") def v2_runner_on_unreachable(self, result): self._host_counter += 1 @@ -232,15 +239,7 @@ class CallbackModule(CallbackBase): if self._play.strategy == "free" and self._last_task_banner != result._task._uuid: self._print_task_banner(result._task) - delegated_vars = result._result.get("_ansible_delegated_vars", None) - if delegated_vars: - self._display.display( - f"fatal: {self._host_counter}/{self._host_total} [{result._host.get_name()} -> " - f"{delegated_vars['ansible_host']}]: UNREACHABLE! => {self._dump_results(result._result)}", - color=C.COLOR_UNREACHABLE, - ) - else: - self._display.display( - f"fatal: {self._host_counter}/{self._host_total} [{result._host.get_name()}]: UNREACHABLE! => {self._dump_results(result._result)}", - color=C.COLOR_UNREACHABLE, - ) + self._display.display( + f"fatal: {self._host_counter}/{self._host_total} {self._host_label(result)}: UNREACHABLE! => {self._dump_results(result._result)}", + color=C.COLOR_UNREACHABLE, + ) diff --git a/tests/integration/targets/callback_counter_enabled/aliases b/tests/integration/targets/callback_counter_enabled/aliases new file mode 100644 index 0000000000..3e2dd244c1 --- /dev/null +++ b/tests/integration/targets/callback_counter_enabled/aliases @@ -0,0 +1,6 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/3 +needs/target/callback diff --git a/tests/integration/targets/callback_counter_enabled/tasks/main.yml b/tests/integration/targets/callback_counter_enabled/tasks/main.yml new file mode 100644 index 0000000000..7e824cbfb3 --- /dev/null +++ b/tests/integration/targets/callback_counter_enabled/tasks/main.yml @@ -0,0 +1,102 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Run tests + include_role: + name: callback + vars: + tests: + - name: Task and host counters shown in N/M format + environment: + ANSIBLE_NOCOLOR: 'true' + ANSIBLE_FORCE_COLOR: 'false' + ANSIBLE_STDOUT_CALLBACK: community.general.counter_enabled + playbook: | + - hosts: testhost + gather_facts: false + tasks: + - name: First task + set_fact: + counter_test: first + - name: Second task + set_fact: + counter_test: second + expected_output: + - "" + - "PLAY [testhost] ****************************************************************" + - "" + - "TASK 1/2 [First task] **********************************************************" + - "ok: 1/1 [testhost]" + - "" + - "TASK 2/2 [Second task] *********************************************************" + - "ok: 1/1 [testhost]" + - "" + - "PLAY RECAP *********************************************************************" + - "testhost : ok=2 changed=0 unreachable=0 failed=0 rescued=0 ignored=0 " + + - name: Loop task items are displayed (regression for issue 8187) + environment: + ANSIBLE_NOCOLOR: 'true' + ANSIBLE_FORCE_COLOR: 'false' + ANSIBLE_STDOUT_CALLBACK: community.general.counter_enabled + playbook: !unsafe | + - hosts: testhost + gather_facts: false + tasks: + - name: Loop task + set_fact: + dummy_fact: "{{ item }}" + loop: + - item_a + - item_b + - item_c + expected_output: + - "" + - "PLAY [testhost] ****************************************************************" + - "" + - "TASK 1/1 [Loop task] ***********************************************************" + - "ok: [testhost] => (item=item_a)" + - "ok: [testhost] => (item=item_b)" + - "ok: [testhost] => (item=item_c)" + - "" + - "PLAY RECAP *********************************************************************" + - "testhost : ok=1 changed=0 unreachable=0 failed=0 rescued=0 ignored=0 " + + - name: Delegated loop task items show delegation target (regression for issue 8187) + environment: + ANSIBLE_NOCOLOR: 'true' + ANSIBLE_FORCE_COLOR: 'false' + ANSIBLE_STDOUT_CALLBACK: community.general.counter_enabled + playbook: !unsafe | + - hosts: testhost + gather_facts: false + tasks: + - name: Delegated loop task + set_fact: + dummy_fact: "{{ item }}" + loop: + - item_a + - item_b + - item_c + delegate_to: localhost + expected_output: + - "" + - "PLAY [testhost] ****************************************************************" + - "" + - "TASK 1/1 [Delegated loop task] *************************************************" + - - "ok: [testhost -> localhost] => (item=item_a)" + - "ok: [testhost -> localhost(127.0.0.1)] => (item=item_a)" + - - "ok: [testhost -> localhost] => (item=item_b)" + - "ok: [testhost -> localhost(127.0.0.1)] => (item=item_b)" + - - "ok: [testhost -> localhost] => (item=item_c)" + - "ok: [testhost -> localhost(127.0.0.1)] => (item=item_c)" + - "" + - "PLAY RECAP *********************************************************************" + - "testhost : ok=1 changed=0 unreachable=0 failed=0 rescued=0 ignored=0 "