actionable_classic callback is incompatible with ansible-core ≥ 2.14 — task failures print no output
Category: Bug Reports / Bootstrapper
Here’s another finding from some Bootstrapper log errors that my AI model has diagnosed.
Environment
- ApisCP 3.x (
apnscpmaster,resources/playbooks/plugins/callbacks/actionable_classic.py) - ansible-core 2.16.16 (RHEL 8/9)
- Reproduces on every
upcp/ bootstrapper run; also reproducible with any playbook
Summary
ANSIBLE_STDOUT_CALLBACK=actionable_classic (set in BootstrapperTask::run()) is broken on any ansible-core ≥ 2.14. The callback raises inside nearly every v2_runner_* method, which floods stderr with warnings, makes the run output unauditable, and — most seriously — suppresses the output of failed and unreachable tasks entirely. A failing run prints a bare task banner and a recap line, with no fatal: ... FAILED! => detail.
Observed output
[WARNING]: Failure using method (v2_playbook_on_task_start) in callback plugin
(<ansible.plugins.callback.actionable_classic.CallbackModule object at 0x7fc2210e4770>): 'display_skipped_hosts'
[WARNING]: Failure using method (v2_runner_on_ok) in callback plugin (...): 'display_ok_hosts'
[WARNING]: Failure using method (v2_runner_on_skipped) in callback plugin (...): 'display_skipped_hosts'
[WARNING]: Failure using method (v2_runner_item_on_ok) in callback plugin (...): 'display_ok_hosts'
[WARNING]: Failure using method (v2_runner_item_on_skipped) in callback plugin (...): 'display_skipped_hosts'
[WARNING]: Failure using method (v2_playbook_on_handler_task_start) in callback plugin (...): 'display_skipped_hosts'
Each method warns once (Ansible’s Display.warning dedupes identical messages) and then fails silently for the remainder of the run. In a full bootstrap this is ~2,800 tasks, so the six lines look cosmetic — they are not.
Root cause
actionable_classic subclasses the default callback and relies on the pre-2.14 implementation, which read plain instance attributes:
def set_options(self, task_keys=None, var_options=None, direct=None):
super(CallbackModule, self).set_options(...)
self.display_ok_hosts = False
self.display_skipped_hosts = False
As of ansible-core 2.14, default.py no longer reads those attributes — it calls self.get_option('display_ok_hosts') / self.get_option('display_skipped_hosts') (default.py:88, :110, :162, :250, :282 in 2.14 through 2.17).
CallbackBase.get_option() is an unguarded dict lookup:
# lib/ansible/plugins/callback/__init__.py
def get_option(self, k):
return self._plugin_options[k]
and _plugin_options is populated by C.config.get_plugin_options(self.plugin_type, self._load_name, ...) from the plugin’s DOCUMENTATION. actionable_classic.py has no DOCUMENTATION block, so no options are registered for the name actionable_classic and the dict is empty — hence KeyError('display_skipped_hosts'), with the bare option name as the message. The set_options override assigning instance attributes is now a no-op.
The same bug is present in upstream apisnetworks/apnscp master; the file is unchanged since commit 677779fb2 (2019), which predates the 2.14 config refactor.
Impact
- Failure and unreachable results are swallowed.
v2_runner_on_failedprints the task banner, then evaluatesself.get_option('display_failed_stderr')(default.py:58) and raises before printingfatal: [host]: FAILED! => {...}. Same forv2_runner_on_unreachable(:131) andv2_runner_item_on_failed(:271). The playbook still exits non-zero, so the bootstrapper still reports failure — but the operator is given no indication of what failed or why. Workaround is to re-run with the debug callback (is_debug()path). - The stdout log is not auditable. Because
v2_runner_on_ok(:88) andv2_runner_on_skipped(:110) raise before printing, an unchanged task and a skipped task produce no output at all — not even a banner. Every banner-only line in a bootstrap log is aninclude_tasks/include_role(which returns early at:77-80), so the ok/skipped counts inPLAY RECAPare unreconcilable against the log. - ok/skipped suppression currently works only as a side effect of the exception, not by configuration.
Steps to reproduce
- On ansible-core ≥ 2.14:
ANSIBLE_STDOUT_CALLBACK=actionable_classic ansible-playbook -i localhost, any-playbook.yml - Include a task that fails and one that is skipped by a
when:condition. - Observe the
Failure using method (...)warnings, noskipping:lines, and nofatal:block for the failing task.
Proposed fix
Register the options the callback depends on. The correct pattern already exists in the tree — resources/playbooks/plugins/callbacks/actionable.py (vendored from community.general.actionable) does exactly this. Add to actionable_classic.py:
DOCUMENTATION = '''
name: actionable_classic
type: stdout
short_description: Terse actionable output for apnscp
description:
- Suppresses ok/skipped results and the "included:" markup emitted by the default callback.
extends_documentation_fragment:
- default_callback
requirements:
- set as stdout callback in configuration
options:
display_skipped_hosts:
name: Show skipped hosts
description: Toggle to control displaying skipped task/host results in a task.
type: bool
default: no
env:
- name: ANSIBLE_DISPLAY_SKIPPED_HOSTS
ini:
- key: display_skipped_hosts
section: defaults
display_ok_hosts:
name: Show 'ok' hosts
description: Toggle to control displaying 'ok' task/host results in a task.
type: bool
default: no
env:
- name: ANSIBLE_DISPLAY_OK_HOSTS
ini:
- key: display_ok_hosts
section: defaults
'''
and on the class:
class CallbackModule(CallbackModule_default):
CALLBACK_VERSION = 2.0
CALLBACK_TYPE = 'stdout'
CALLBACK_NAME = 'actionable_classic'
With the options registered, the set_options override becomes redundant and can be dropped (the default: no values now do the suppression by configuration), and v2_runner_on_start / v2_playbook_on_include keep working as-is since neither touches get_option. This fixes the warnings, restores failure output, and removes the version coupling.
Alternative
community.general.actionable is deprecated in favour of the default callback with display_skipped_hosts = no and display_ok_hosts = no. actionable_classic is that plus suppression of the included: line, so if the included: line is not actually wanted, the plugin could be retired in favour of ansible.cfg settings.