actionable_classic callback is incompatible with ansible-core ≥ 2.14

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 (apnscp master, 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

  1. Failure and unreachable results are swallowed. v2_runner_on_failed prints the task banner, then evaluates self.get_option('display_failed_stderr') (default.py:58) and raises before printing fatal: [host]: FAILED! => {...}. Same for v2_runner_on_unreachable (:131) and v2_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).
  2. The stdout log is not auditable. Because v2_runner_on_ok (:88) and v2_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 an include_tasks/include_role (which returns early at :77-80), so the ok/skipped counts in PLAY RECAP are unreconcilable against the log.
  3. ok/skipped suppression currently works only as a side effect of the exception, not by configuration.

Steps to reproduce

  1. On ansible-core ≥ 2.14: ANSIBLE_STDOUT_CALLBACK=actionable_classic ansible-playbook -i localhost, any-playbook.yml
  2. Include a task that fails and one that is skipped by a when: condition.
  3. Observe the Failure using method (...) warnings, no skipping: lines, and no fatal: 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.

Thanks for the bug report. This is fixed in edge :+1: