From 0c51a30d93d19cc8698e31e5a3c8d76b70b7ab67 Mon Sep 17 00:00:00 2001 From: Brian Coca Date: Wed, 17 Apr 2024 16:26:05 -0400 Subject: [PATCH] ansible-config: add 'validate' option (#83007) We can now validate both ansible.cfg and 'ANSIBLE_' env vars match either core (-t base), installed plugin(s) (-t ) or both (-t all) --- .../fragments/ansible-config-validate.yml | 2 + lib/ansible/cli/config.py | 105 +++++++++++++++++- .../ansible-config/files/base_all_valid.cfg | 5 + .../ansible-config/files/base_valid.cfg | 2 + .../targets/ansible-config/files/empty.cfg | 0 .../ansible-config/files/invalid_base.cfg | 2 + .../files/invalid_plugins_config.ini | 2 + .../targets/ansible-config/tasks/main.yml | 46 +++++++- 8 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 changelogs/fragments/ansible-config-validate.yml create mode 100644 test/integration/targets/ansible-config/files/base_all_valid.cfg create mode 100644 test/integration/targets/ansible-config/files/base_valid.cfg create mode 100644 test/integration/targets/ansible-config/files/empty.cfg create mode 100644 test/integration/targets/ansible-config/files/invalid_base.cfg create mode 100644 test/integration/targets/ansible-config/files/invalid_plugins_config.ini diff --git a/changelogs/fragments/ansible-config-validate.yml b/changelogs/fragments/ansible-config-validate.yml new file mode 100644 index 00000000000..fab48db9026 --- /dev/null +++ b/changelogs/fragments/ansible-config-validate.yml @@ -0,0 +1,2 @@ +minor_changes: + - ansible-config has new 'validate' option to find mispelled/forgein configurations in ini file or environment variables. diff --git a/lib/ansible/cli/config.py b/lib/ansible/cli/config.py index e7f240c80d4..995649c3b12 100755 --- a/lib/ansible/cli/config.py +++ b/lib/ansible/cli/config.py @@ -9,9 +9,10 @@ from __future__ import annotations from ansible.cli import CLI import os -import yaml import shlex import subprocess +import sys +import yaml from collections.abc import Mapping @@ -49,6 +50,37 @@ def get_constants(): return get_constants.cvars +def _ansible_env_vars(varname): + ''' return true or false depending if variable name is possibly a 'configurable' ansible env variable ''' + return all( + [ + varname.startswith("ANSIBLE_"), + not varname.startswith(("ANSIBLE_TEST_", "ANSIBLE_LINT_")), + varname not in ("ANSIBLE_CONFIG", "ANSIBLE_DEV_HOME"), + ] + ) + + +def _get_evar_list(settings): + data = [] + for setting in settings: + if 'env' in settings[setting] and settings[setting]['env']: + for varname in settings[setting]['env']: + data.append(varname.get('name')) + return data + + +def _get_ini_entries(settings): + data = {} + for setting in settings: + if 'ini' in settings[setting] and settings[setting]['ini']: + for kv in settings[setting]['ini']: + if not kv['section'] in data: + data[kv['section']] = set() + data[kv['section']].add(kv['key']) + return data + + class ConfigCLI(CLI): """ Config command line class """ @@ -99,9 +131,13 @@ class ConfigCLI(CLI): init_parser.add_argument('--disabled', dest='commented', action='store_true', default=False, help='Prefixes all entries with a comment character to disable them') - # search_parser = subparsers.add_parser('find', help='Search configuration') - # search_parser.set_defaults(func=self.execute_search) - # search_parser.add_argument('args', help='Search term', metavar='') + validate_parser = subparsers.add_parser('validate', + help='Validate the configuration file and environment variables. ' + 'By default it only checks the base settings without accounting for plugins (see -t).', + parents=[common]) + validate_parser.set_defaults(func=self.execute_validate) + validate_parser.add_argument('--format', '-f', dest='format', action='store', choices=['ini', 'env'] , default='ini', + help='Output format for init') def post_process_args(self, options): options = super(ConfigCLI, self).post_process_args(options) @@ -239,6 +275,7 @@ class ConfigCLI(CLI): for ptype in C.CONFIGURABLE_PLUGINS: config_entries['PLUGINS'][ptype.upper()] = self._list_plugin_settings(ptype) elif context.CLIARGS['type'] != 'base': + # only for requested types config_entries['PLUGINS'][context.CLIARGS['type']] = self._list_plugin_settings(context.CLIARGS['type'], context.CLIARGS['args']) return config_entries @@ -358,7 +395,7 @@ class ConfigCLI(CLI): elif default is None: default = '' - if context.CLIARGS['commented']: + if context.CLIARGS.get('commented', False): entry['key'] = ';%s' % entry['key'] key = desc + '\n%s=%s' % (entry['key'], default) @@ -552,6 +589,64 @@ class ConfigCLI(CLI): self.pager(to_text(text, errors='surrogate_or_strict')) + def execute_validate(self): + + found = False + config_entries = self._list_entries_from_args() + plugin_types = config_entries.pop('PLUGINS', None) + + if context.CLIARGS['format'] == 'ini': + if C.CONFIG_FILE is not None: + # validate ini config since it is found + + sections = _get_ini_entries(config_entries) + # Also from plugins + if plugin_types: + for ptype in plugin_types: + for plugin in plugin_types[ptype].keys(): + plugin_sections = _get_ini_entries(plugin_types[ptype][plugin]) + for s in plugin_sections: + if s in sections: + sections[s].update(plugin_sections[s]) + else: + sections[s] = plugin_sections[s] + if sections: + p = C.config._parsers[C.CONFIG_FILE] + for s in p.sections(): + # check for valid sections + if s not in sections: + display.error(f"Found unknown section '{s}' in '{C.CONFIG_FILE}.") + found = True + continue + + # check keys in valid sections + for k in p.options(s): + if k not in sections[s]: + display.error(f"Found unknown key '{k}' in section '{s}' in '{C.CONFIG_FILE}.") + found = True + + elif context.CLIARGS['format'] == 'env': + # validate any 'ANSIBLE_' env vars found + evars = [varname for varname in os.environ.keys() if _ansible_env_vars(varname)] + if evars: + data = _get_evar_list(config_entries) + if plugin_types: + for ptype in plugin_types: + for plugin in plugin_types[ptype].keys(): + data.extend(_get_evar_list(plugin_types[ptype][plugin])) + + for evar in evars: + if evar not in data: + display.error(f"Found unknown environment variable '{evar}'.") + found = True + + # we found discrepancies! + if found: + sys.exit(1) + + # allsgood + display.display("All configurations seem valid!") + def main(args=None): ConfigCLI.cli_executor(args) diff --git a/test/integration/targets/ansible-config/files/base_all_valid.cfg b/test/integration/targets/ansible-config/files/base_all_valid.cfg new file mode 100644 index 00000000000..f9c3fb16bb2 --- /dev/null +++ b/test/integration/targets/ansible-config/files/base_all_valid.cfg @@ -0,0 +1,5 @@ +[defaults] +cow_selection=random + +[ssh_connection] +control_path=/var/tmp diff --git a/test/integration/targets/ansible-config/files/base_valid.cfg b/test/integration/targets/ansible-config/files/base_valid.cfg new file mode 100644 index 00000000000..f5c203237eb --- /dev/null +++ b/test/integration/targets/ansible-config/files/base_valid.cfg @@ -0,0 +1,2 @@ +[defaults] +cow_selection=random diff --git a/test/integration/targets/ansible-config/files/empty.cfg b/test/integration/targets/ansible-config/files/empty.cfg new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/ansible-config/files/invalid_base.cfg b/test/integration/targets/ansible-config/files/invalid_base.cfg new file mode 100644 index 00000000000..71bc2129947 --- /dev/null +++ b/test/integration/targets/ansible-config/files/invalid_base.cfg @@ -0,0 +1,2 @@ +[defaults] +cow_destruction=random diff --git a/test/integration/targets/ansible-config/files/invalid_plugins_config.ini b/test/integration/targets/ansible-config/files/invalid_plugins_config.ini new file mode 100644 index 00000000000..62baebed034 --- /dev/null +++ b/test/integration/targets/ansible-config/files/invalid_plugins_config.ini @@ -0,0 +1,2 @@ +[ssh_connection] +controller_road=/var/tmp diff --git a/test/integration/targets/ansible-config/tasks/main.yml b/test/integration/targets/ansible-config/tasks/main.yml index a894dd45cf5..7e7ba19070c 100644 --- a/test/integration/targets/ansible-config/tasks/main.yml +++ b/test/integration/targets/ansible-config/tasks/main.yml @@ -1,4 +1,4 @@ -- name: test ansible-config for valid output and no dupes +- name: test ansible-config init for valid output and no dupes block: - name: Create temporary file tempfile: @@ -12,3 +12,47 @@ - name: run ini tester, for correctness and dupes shell: "{{ansible_playbook_python}} '{{role_path}}/files/ini_dupes.py' '{{ini_tempfile.path}}'" + +- name: test ansible-config validate + block: + # not testing w/o -t all as ansible-test uses it's own plugins and would give false positives + - name: validate config files + shell: ansible-config validate -t all -v + register: valid_cfg + loop: + - empty.cfg + - base_valid.cfg + - base_all_valid.cfg + - invalid_base.cfg + - invalid_plugins_config.ini + ignore_errors: true + environment: + ANSIBLE_CONFIG: "{{role_path ~ '/files/' ~ item}}" + + - name: ensure expected cfg check results + assert: + that: + - valid_cfg['results'][0] is success + - valid_cfg['results'][1] is success + - valid_cfg['results'][2] is success + - valid_cfg['results'][3] is failed + - valid_cfg['results'][4] is failed + + - name: validate env vars + shell: ansible-config validate -t all -v -f env + register: valid_env + environment: + ANSIBLE_COW_SELECTION: 1 + + - name: validate env vars + shell: ansible-config validate -t all -v -f env + register: invalid_env + ignore_errors: true + environment: + ANSIBLE_COW_DESTRUCTION: 1 + + - name: ensure env check is what we expected + assert: + that: + - valid_env is success + - invalid_env is failed