From 47d047c2d4f9f0f9d0dfaba18e7ce1e4d5bb1fdf Mon Sep 17 00:00:00 2001 From: Arvind Jangir Date: Tue, 10 Mar 2026 14:43:34 +0530 Subject: [PATCH 1/5] List enabled and available features --- src/playbooks/features/features.yaml | 2 ++ src/playbooks/features/metadata.obsah.yaml | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 src/playbooks/features/features.yaml create mode 100644 src/playbooks/features/metadata.obsah.yaml diff --git a/src/playbooks/features/features.yaml b/src/playbooks/features/features.yaml new file mode 100644 index 000000000..f893b1d75 --- /dev/null +++ b/src/playbooks/features/features.yaml @@ -0,0 +1,2 @@ +- name: List features + hosts: quadlet diff --git a/src/playbooks/features/metadata.obsah.yaml b/src/playbooks/features/metadata.obsah.yaml new file mode 100644 index 000000000..b8bce89bf --- /dev/null +++ b/src/playbooks/features/metadata.obsah.yaml @@ -0,0 +1,5 @@ +--- +help: | + List all enabled and available features + +script: features.py From 74c001deea1ef6d6169777ab6b792624a1c03a58 Mon Sep 17 00:00:00 2001 From: Arvind Jangir Date: Fri, 13 Mar 2026 15:08:11 +0530 Subject: [PATCH 2/5] Use custom stdout callback to display features --- src/ansible.cfg | 1 + src/callback_plugins/foremanctl_features.py | 80 +++++++++++++++++++++ src/playbooks/features/features.yaml | 7 ++ src/playbooks/features/metadata.obsah.yaml | 2 +- 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/callback_plugins/foremanctl_features.py diff --git a/src/ansible.cfg b/src/ansible.cfg index e27074e0c..3d1ae08f1 100644 --- a/src/ansible.cfg +++ b/src/ansible.cfg @@ -2,4 +2,5 @@ host_key_checking = False roles_path = ./roles filter_plugins = ./filter_plugins +callback_plugins = ./callback_plugins callback_result_format = yaml diff --git a/src/callback_plugins/foremanctl_features.py b/src/callback_plugins/foremanctl_features.py new file mode 100644 index 000000000..cefc55d15 --- /dev/null +++ b/src/callback_plugins/foremanctl_features.py @@ -0,0 +1,80 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible.plugins.callback import CallbackBase +import pathlib +import yaml +import os + +BASE_DIR = pathlib.Path(__file__).parent.parent +STATE_DIR = pathlib.Path(os.environ.get('OBSAH_STATE', '.var/lib/foremanctl')) + +def load_yaml(path: pathlib.Path) -> dict: + try: + with path.open() as f: + return yaml.safe_load(f) or {} + except FileNotFoundError: + return {} + + +class CallbackModule(CallbackBase): + """Features listing callback.""" + + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'stdout' + CALLBACK_NAME = 'foremanctl_features' + + def __init__(self): + super().__init__() + self.feature_metadata = self._load_features_metadata() + + def _load_features_metadata(self): + features_yaml = BASE_DIR / 'features.yaml' + return load_yaml(features_yaml) + + def v2_runner_on_ok(self, result): + if result._task.action in ('ansible.builtin.debug', 'debug'): + self._display_features() + + def _display_features(self): + params_yaml = STATE_DIR / "parameters.yaml" + params = load_yaml(params_yaml) + + flavor = params.get('flavor') + added_features = params.get('features', []) + + flavor_file = BASE_DIR / f"vars/flavors/{flavor}.yml" + flavor_config = load_yaml(flavor_file) + flavor_features = flavor_config.get('flavor_features', []) + + enabled_features = flavor_features + added_features + + enabled_list = [] + available_list = [] + + for name in sorted(self.feature_metadata.keys()): + meta = self.feature_metadata.get(name, {}) or {} + + if meta.get('internal', False): + continue + + description = meta.get('description', '') + + if name in enabled_features: + enabled_list.append((name, 'enabled', description)) + else: + available_list.append((name, 'available', description)) + + self._display.display(f"{'FEATURE':<25} {'STATE':<12} DESCRIPTION") + + for name, state, description in enabled_list: + self._display.display(f"{name:<25} {state:<12} {description}") + + for name, state, description in available_list: + self._display.display(f"{name:<25} {state:<12} {description}") + + total = len(enabled_list) + len(available_list) + self._display.display("") + self._display.display( + f"{total} features listed ({len(enabled_list)} enabled, {len(available_list)} available)." + ) diff --git a/src/playbooks/features/features.yaml b/src/playbooks/features/features.yaml index f893b1d75..8f43dcd5c 100644 --- a/src/playbooks/features/features.yaml +++ b/src/playbooks/features/features.yaml @@ -1,2 +1,9 @@ +--- - name: List features hosts: quadlet + gather_facts: false + + tasks: + - name: List Enabled features + ansible.builtin.debug: + msg: "" diff --git a/src/playbooks/features/metadata.obsah.yaml b/src/playbooks/features/metadata.obsah.yaml index b8bce89bf..cd8366444 100644 --- a/src/playbooks/features/metadata.obsah.yaml +++ b/src/playbooks/features/metadata.obsah.yaml @@ -2,4 +2,4 @@ help: | List all enabled and available features -script: features.py +stdout_callback: foremanctl_features From a5468c3712179ee584b972cb113e5a8d48acd624 Mon Sep 17 00:00:00 2001 From: Arvind Jangir Date: Mon, 16 Mar 2026 13:40:34 +0530 Subject: [PATCH 3/5] Make callback obsah independent and add fallback logic --- src/ansible.cfg | 1 + src/callback_plugins/foremanctl.py | 52 ++++++++++++++ src/callback_plugins/foremanctl_features.py | 80 --------------------- src/filter_plugins/foremanctl.py | 20 ++++++ src/playbooks/features/features.yaml | 9 ++- src/playbooks/features/metadata.obsah.yaml | 5 +- tests/features_test.py | 13 ++++ 7 files changed, 93 insertions(+), 87 deletions(-) create mode 100644 src/callback_plugins/foremanctl.py delete mode 100644 src/callback_plugins/foremanctl_features.py create mode 100644 tests/features_test.py diff --git a/src/ansible.cfg b/src/ansible.cfg index 3d1ae08f1..bc8f1cb10 100644 --- a/src/ansible.cfg +++ b/src/ansible.cfg @@ -4,3 +4,4 @@ roles_path = ./roles filter_plugins = ./filter_plugins callback_plugins = ./callback_plugins callback_result_format = yaml +stdout_callback = foremanctl \ No newline at end of file diff --git a/src/callback_plugins/foremanctl.py b/src/callback_plugins/foremanctl.py new file mode 100644 index 000000000..482d0615f --- /dev/null +++ b/src/callback_plugins/foremanctl.py @@ -0,0 +1,52 @@ +from ansible.plugins.callback.default import CallbackModule as DefaultCallbackModule +from os.path import basename + +DOCUMENTATION = """ + name: foremanctl + type: stdout + short_description: default Ansible screen output callback + description: + - This is the default output callback for ansible-playbooks. + extends_documentation_fragment: + - default_callback + - result_format_callback + requirements: + - set as stdout in configuration +""" + +class CallbackModule(DefaultCallbackModule): + """Foremanctl callback.""" + + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'stdout' + CALLBACK_NAME = 'foremanctl' + + FALLBACK_TO_DEFAULT = True + PLAYBOOKS_TO_SKIP_DEFAULT = ['features.yaml'] + + def v2_playbook_on_start(self, playbook): + playbook_filename = basename(playbook._file_name) + + if playbook_filename in self.PLAYBOOKS_TO_SKIP_DEFAULT: + self.FALLBACK_TO_DEFAULT = False + if self.FALLBACK_TO_DEFAULT: + super().v2_playbook_on_start(playbook) + + def v2_playbook_on_play_start(self, play): + if self.FALLBACK_TO_DEFAULT: + super().v2_playbook_on_play_start(play) + + def v2_playbook_on_task_start(self, task, is_conditional): + if self.FALLBACK_TO_DEFAULT: + super().v2_playbook_on_task_start(task, is_conditional) + + def v2_runner_on_ok(self, result): + if self.FALLBACK_TO_DEFAULT: + super().v2_runner_on_ok(result) + else: + if msg := result._result.get('msg'): + self._display.display(msg) + + def v2_playbook_on_stats(self, stats): + if self.FALLBACK_TO_DEFAULT: + super().v2_playbook_on_stats(stats) \ No newline at end of file diff --git a/src/callback_plugins/foremanctl_features.py b/src/callback_plugins/foremanctl_features.py deleted file mode 100644 index cefc55d15..000000000 --- a/src/callback_plugins/foremanctl_features.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - -from ansible.plugins.callback import CallbackBase -import pathlib -import yaml -import os - -BASE_DIR = pathlib.Path(__file__).parent.parent -STATE_DIR = pathlib.Path(os.environ.get('OBSAH_STATE', '.var/lib/foremanctl')) - -def load_yaml(path: pathlib.Path) -> dict: - try: - with path.open() as f: - return yaml.safe_load(f) or {} - except FileNotFoundError: - return {} - - -class CallbackModule(CallbackBase): - """Features listing callback.""" - - CALLBACK_VERSION = 2.0 - CALLBACK_TYPE = 'stdout' - CALLBACK_NAME = 'foremanctl_features' - - def __init__(self): - super().__init__() - self.feature_metadata = self._load_features_metadata() - - def _load_features_metadata(self): - features_yaml = BASE_DIR / 'features.yaml' - return load_yaml(features_yaml) - - def v2_runner_on_ok(self, result): - if result._task.action in ('ansible.builtin.debug', 'debug'): - self._display_features() - - def _display_features(self): - params_yaml = STATE_DIR / "parameters.yaml" - params = load_yaml(params_yaml) - - flavor = params.get('flavor') - added_features = params.get('features', []) - - flavor_file = BASE_DIR / f"vars/flavors/{flavor}.yml" - flavor_config = load_yaml(flavor_file) - flavor_features = flavor_config.get('flavor_features', []) - - enabled_features = flavor_features + added_features - - enabled_list = [] - available_list = [] - - for name in sorted(self.feature_metadata.keys()): - meta = self.feature_metadata.get(name, {}) or {} - - if meta.get('internal', False): - continue - - description = meta.get('description', '') - - if name in enabled_features: - enabled_list.append((name, 'enabled', description)) - else: - available_list.append((name, 'available', description)) - - self._display.display(f"{'FEATURE':<25} {'STATE':<12} DESCRIPTION") - - for name, state, description in enabled_list: - self._display.display(f"{name:<25} {state:<12} {description}") - - for name, state, description in available_list: - self._display.display(f"{name:<25} {state:<12} {description}") - - total = len(enabled_list) + len(available_list) - self._display.display("") - self._display.display( - f"{total} features listed ({len(enabled_list)} enabled, {len(available_list)} available)." - ) diff --git a/src/filter_plugins/foremanctl.py b/src/filter_plugins/foremanctl.py index 964dab743..fc7458034 100644 --- a/src/filter_plugins/foremanctl.py +++ b/src/filter_plugins/foremanctl.py @@ -55,6 +55,25 @@ def available_foreman_plugins(_value): plugins = [FEATURE_MAP.get(feature).get('foreman', {}).get('plugin_name') for feature in FEATURE_MAP.keys()] return compact_list(plugins) +def feature_list(value): + enabled_list = [] + available_list = [] + for name, meta in FEATURE_MAP.items(): + if meta.get('internal', False): + continue + description = meta.get('description', '') + if name in value: + enabled_list.append((name, 'enabled', description)) + else: + available_list.append((name, 'available', description)) + + output = [f"{'FEATURE':<25} {'STATE':<12} DESCRIPTION"] + for name, state, description in enabled_list: + output.append(f"{name:<25} {state:<12} {description}") + for name, state, description in available_list: + output.append(f"{name:<25} {state:<12} {description}") + + return "\n".join(output) def foreman_proxy_plugins(value): dependencies = list(get_dependencies(filter_features(value))) @@ -76,4 +95,5 @@ def filters(self): 'available_foreman_plugins': available_foreman_plugins, 'features_to_foreman_proxy_plugins': foreman_proxy_plugins, 'available_foreman_proxy_plugins': available_foreman_proxy_plugins, + 'feature_list': feature_list, } diff --git a/src/playbooks/features/features.yaml b/src/playbooks/features/features.yaml index 8f43dcd5c..b4fe12831 100644 --- a/src/playbooks/features/features.yaml +++ b/src/playbooks/features/features.yaml @@ -2,8 +2,11 @@ - name: List features hosts: quadlet gather_facts: false - + vars_files: + - "../../vars/defaults.yml" + - "../../vars/flavors/{{ flavor }}.yml" + - "../../vars/base.yaml" tasks: - - name: List Enabled features + - name: Print features ansible.builtin.debug: - msg: "" + msg: "{{ enabled_features | feature_list }}" diff --git a/src/playbooks/features/metadata.obsah.yaml b/src/playbooks/features/metadata.obsah.yaml index cd8366444..af5417a71 100644 --- a/src/playbooks/features/metadata.obsah.yaml +++ b/src/playbooks/features/metadata.obsah.yaml @@ -1,5 +1,2 @@ --- -help: | - List all enabled and available features - -stdout_callback: foremanctl_features +help: List all enabled and available features diff --git a/tests/features_test.py b/tests/features_test.py new file mode 100644 index 000000000..e6b4ee9df --- /dev/null +++ b/tests/features_test.py @@ -0,0 +1,13 @@ +import os + +def test_foremanctl_features(capfd): + result = os.system('./foremanctl features') + + captured = capfd.readouterr() + assert result == 0 + + for noise in ['PLAY [', 'TASK [', 'ok:', 'changed:', 'PLAY RECAP']: + assert noise not in captured.out, f"Ansible output not suppressed: found '{noise}'" + + for feature in ['foreman', 'foreman-proxy', 'azure_rm']: + assert feature in captured.out, f"Expected feature '{feature}' in output" From a8d918d9fd468ff0735688c2608236c2ceb882b6 Mon Sep 17 00:00:00 2001 From: Arvind Jangir Date: Tue, 17 Mar 2026 16:48:48 +0530 Subject: [PATCH 4/5] Add custom tag to skip the default callback --- src/ansible.cfg | 2 +- src/callback_plugins/foremanctl.py | 15 +++++++-------- src/filter_plugins/foremanctl.py | 6 +++--- src/playbooks/features/features.yaml | 4 +++- tests/features_test.py | 14 +++++++------- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/ansible.cfg b/src/ansible.cfg index bc8f1cb10..2b4e69983 100644 --- a/src/ansible.cfg +++ b/src/ansible.cfg @@ -4,4 +4,4 @@ roles_path = ./roles filter_plugins = ./filter_plugins callback_plugins = ./callback_plugins callback_result_format = yaml -stdout_callback = foremanctl \ No newline at end of file +stdout_callback = foremanctl diff --git a/src/callback_plugins/foremanctl.py b/src/callback_plugins/foremanctl.py index 482d0615f..81213fc80 100644 --- a/src/callback_plugins/foremanctl.py +++ b/src/callback_plugins/foremanctl.py @@ -1,12 +1,12 @@ from ansible.plugins.callback.default import CallbackModule as DefaultCallbackModule -from os.path import basename DOCUMENTATION = """ name: foremanctl type: stdout - short_description: default Ansible screen output callback + short_description: foremanctl stdout callback description: - - This is the default output callback for ansible-playbooks. + - Suppresses default Ansible output for plays tagged with + foremanctl_suppress_default_output, displaying only task msg rather than ansible default output. extends_documentation_fragment: - default_callback - result_format_callback @@ -22,12 +22,11 @@ class CallbackModule(DefaultCallbackModule): CALLBACK_NAME = 'foremanctl' FALLBACK_TO_DEFAULT = True - PLAYBOOKS_TO_SKIP_DEFAULT = ['features.yaml'] def v2_playbook_on_start(self, playbook): - playbook_filename = basename(playbook._file_name) - - if playbook_filename in self.PLAYBOOKS_TO_SKIP_DEFAULT: + plays = playbook.get_plays() + tags = plays[0].tags + if 'foremanctl_suppress_default_output' in tags: self.FALLBACK_TO_DEFAULT = False if self.FALLBACK_TO_DEFAULT: super().v2_playbook_on_start(playbook) @@ -49,4 +48,4 @@ def v2_runner_on_ok(self, result): def v2_playbook_on_stats(self, stats): if self.FALLBACK_TO_DEFAULT: - super().v2_playbook_on_stats(stats) \ No newline at end of file + super().v2_playbook_on_stats(stats) diff --git a/src/filter_plugins/foremanctl.py b/src/filter_plugins/foremanctl.py index fc7458034..5b5019688 100644 --- a/src/filter_plugins/foremanctl.py +++ b/src/filter_plugins/foremanctl.py @@ -55,14 +55,14 @@ def available_foreman_plugins(_value): plugins = [FEATURE_MAP.get(feature).get('foreman', {}).get('plugin_name') for feature in FEATURE_MAP.keys()] return compact_list(plugins) -def feature_list(value): +def list_all_features(enabled_features): enabled_list = [] available_list = [] for name, meta in FEATURE_MAP.items(): if meta.get('internal', False): continue description = meta.get('description', '') - if name in value: + if name in enabled_features: enabled_list.append((name, 'enabled', description)) else: available_list.append((name, 'available', description)) @@ -95,5 +95,5 @@ def filters(self): 'available_foreman_plugins': available_foreman_plugins, 'features_to_foreman_proxy_plugins': foreman_proxy_plugins, 'available_foreman_proxy_plugins': available_foreman_proxy_plugins, - 'feature_list': feature_list, + 'list_all_features': list_all_features, } diff --git a/src/playbooks/features/features.yaml b/src/playbooks/features/features.yaml index b4fe12831..5551d986a 100644 --- a/src/playbooks/features/features.yaml +++ b/src/playbooks/features/features.yaml @@ -2,6 +2,8 @@ - name: List features hosts: quadlet gather_facts: false + tags: + - foremanctl_suppress_default_output vars_files: - "../../vars/defaults.yml" - "../../vars/flavors/{{ flavor }}.yml" @@ -9,4 +11,4 @@ tasks: - name: Print features ansible.builtin.debug: - msg: "{{ enabled_features | feature_list }}" + msg: "{{ enabled_features | list_all_features }}" diff --git a/tests/features_test.py b/tests/features_test.py index e6b4ee9df..087582ec2 100644 --- a/tests/features_test.py +++ b/tests/features_test.py @@ -1,13 +1,13 @@ -import os +import subprocess -def test_foremanctl_features(capfd): - result = os.system('./foremanctl features') +def test_foremanctl_features(): + command = ['./foremanctl', 'features'] + result = subprocess.run(command, capture_output=True, text=True) - captured = capfd.readouterr() - assert result == 0 + assert result.returncode == 0 for noise in ['PLAY [', 'TASK [', 'ok:', 'changed:', 'PLAY RECAP']: - assert noise not in captured.out, f"Ansible output not suppressed: found '{noise}'" + assert noise not in result.stdout, f"Ansible output not suppressed: found '{noise}'" for feature in ['foreman', 'foreman-proxy', 'azure_rm']: - assert feature in captured.out, f"Expected feature '{feature}' in output" + assert feature in result.stdout, f"Expected feature '{feature}' in output" From c4f9d5d9f23b812e8b3e7785f8b23ecda559ddc6 Mon Sep 17 00:00:00 2001 From: Arvind Jangir Date: Thu, 19 Mar 2026 11:04:14 +0530 Subject: [PATCH 5/5] List enabled features only when passed --list-enabled --- src/filter_plugins/foremanctl.py | 8 +++----- src/playbooks/features/features.yaml | 4 +++- src/playbooks/features/metadata.obsah.yaml | 7 +++++++ tests/features_test.py | 11 ++++++++++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/filter_plugins/foremanctl.py b/src/filter_plugins/foremanctl.py index 5b5019688..fb88c9cac 100644 --- a/src/filter_plugins/foremanctl.py +++ b/src/filter_plugins/foremanctl.py @@ -55,7 +55,7 @@ def available_foreman_plugins(_value): plugins = [FEATURE_MAP.get(feature).get('foreman', {}).get('plugin_name') for feature in FEATURE_MAP.keys()] return compact_list(plugins) -def list_all_features(enabled_features): +def list_all_features(enabled_features, only_enabled=False): enabled_list = [] available_list = [] for name, meta in FEATURE_MAP.items(): @@ -64,13 +64,11 @@ def list_all_features(enabled_features): description = meta.get('description', '') if name in enabled_features: enabled_list.append((name, 'enabled', description)) - else: + elif not only_enabled: available_list.append((name, 'available', description)) output = [f"{'FEATURE':<25} {'STATE':<12} DESCRIPTION"] - for name, state, description in enabled_list: - output.append(f"{name:<25} {state:<12} {description}") - for name, state, description in available_list: + for name, state, description in enabled_list + available_list: output.append(f"{name:<25} {state:<12} {description}") return "\n".join(output) diff --git a/src/playbooks/features/features.yaml b/src/playbooks/features/features.yaml index 5551d986a..8bf525ad8 100644 --- a/src/playbooks/features/features.yaml +++ b/src/playbooks/features/features.yaml @@ -4,6 +4,8 @@ gather_facts: false tags: - foremanctl_suppress_default_output + vars: + list_enabled: false vars_files: - "../../vars/defaults.yml" - "../../vars/flavors/{{ flavor }}.yml" @@ -11,4 +13,4 @@ tasks: - name: Print features ansible.builtin.debug: - msg: "{{ enabled_features | list_all_features }}" + msg: "{{ enabled_features | list_all_features(list_enabled) }}" diff --git a/src/playbooks/features/metadata.obsah.yaml b/src/playbooks/features/metadata.obsah.yaml index af5417a71..5480d4ff9 100644 --- a/src/playbooks/features/metadata.obsah.yaml +++ b/src/playbooks/features/metadata.obsah.yaml @@ -1,2 +1,9 @@ --- help: List all enabled and available features + +variables: + list_enabled: + parameter: --list-enabled + help: List only enabled features + action: store_true + persist: false diff --git a/tests/features_test.py b/tests/features_test.py index 087582ec2..92309c950 100644 --- a/tests/features_test.py +++ b/tests/features_test.py @@ -9,5 +9,14 @@ def test_foremanctl_features(): for noise in ['PLAY [', 'TASK [', 'ok:', 'changed:', 'PLAY RECAP']: assert noise not in result.stdout, f"Ansible output not suppressed: found '{noise}'" - for feature in ['foreman', 'foreman-proxy', 'azure_rm']: + for feature in ['foreman', 'foreman-proxy', 'azure-rm']: assert feature in result.stdout, f"Expected feature '{feature}' in output" + +def test_foremanctl_features_list_enabled(): + command = ['./foremanctl', 'features', '--list-enabled'] + result = subprocess.run(command, capture_output=True, text=True) + + assert result.returncode == 0 + + assert 'enabled' in result.stdout + assert 'available' not in result.stdout