actually show plugin config warnings/deprecations (#82593)

previouslly we recorded but did not show to avoid spam
since we could not dedup from forks, that was already
fixed in another PR so now we can show/display them.

Also:
  * funcitonalize deprecation msg construct from docs
  * reuse formatting func in cli
  * normalize alternatives: most of the code used intended plural
    but some and most data/tests used the singular
  * update schemas and tests

Co-authored-by: Matt Davis <6775756+nitzmahone@users.noreply.github.com>
Co-authored-by: Felix Fontein <felix@fontein.de>
pull/83511/head
Brian Coca 5 months ago committed by GitHub
parent 101f017ef5
commit 00ddc27d69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,2 @@
minor_changes:
- plugins, deprecations and warnings concerning configuration are now displayed to the user, technical issue that prevented 'de-duplication' have been resolved.

@ -1089,7 +1089,7 @@ class DocCLI(CLI, RoleMixin):
text = DocCLI.get_man_text(doc, collection_name, plugin_type) text = DocCLI.get_man_text(doc, collection_name, plugin_type)
except Exception as e: except Exception as e:
display.vvv(traceback.format_exc()) display.vvv(traceback.format_exc())
raise AnsibleError("Unable to retrieve documentation from '%s' due to: %s" % (plugin, to_native(e)), orig_exc=e) raise AnsibleError("Unable to retrieve documentation from '%s'" % (plugin), orig_exc=e)
return text return text
@ -1387,16 +1387,15 @@ class DocCLI(CLI, RoleMixin):
if doc.get('deprecated', False): if doc.get('deprecated', False):
text.append(_format("DEPRECATED: ", 'bold', 'DEP')) text.append(_format("DEPRECATED: ", 'bold', 'DEP'))
if isinstance(doc['deprecated'], dict): if isinstance(doc['deprecated'], dict):
if 'removed_at_date' in doc['deprecated']: if 'removed_at_date' not in doc['deprecated'] and 'version' in doc['deprecated'] and 'removed_in' not in doc['deprecated']:
text.append(
"\tReason: %(why)s\n\tWill be removed in a release after %(removed_at_date)s\n\tAlternatives: %(alternative)s" % doc.pop('deprecated')
)
else:
if 'version' in doc['deprecated'] and 'removed_in' not in doc['deprecated']:
doc['deprecated']['removed_in'] = doc['deprecated']['version'] doc['deprecated']['removed_in'] = doc['deprecated']['version']
text.append("\tReason: %(why)s\n\tWill be removed in: Ansible %(removed_in)s\n\tAlternatives: %(alternative)s" % doc.pop('deprecated')) try:
text.append('\t' + C.config.get_deprecated_msg_from_config(doc['deprecated'], True))
except KeyError as e:
raise AnsibleError("Invalid deprecation documentation structure", orig_exc=e)
else: else:
text.append("%s" % doc.pop('deprecated')) text.append("%s" % doc['deprecated'])
del doc['deprecated']
if doc.pop('has_action', False): if doc.pop('has_action', False):
text.append("") text.append("")

@ -2116,4 +2116,35 @@ VERBOSE_TO_STDERR:
- section: defaults - section: defaults
key: verbose_to_stderr key: verbose_to_stderr
type: bool type: bool
... _Z_TEST_ENTRY:
name: testentry
description: for tests
env:
- name: ANSIBLE_TEST_ENTRY
- name: ANSIBLE_TEST_ENTRY_D
deprecated:
why: for testing
version: '3.30'
alternatives: nothing
ini:
- section: testing
key: valid
- section: testing
key: deprecated
deprecated:
why: for testing
version: '3.30'
alternatives: nothing
_Z_TEST_ENTRY_2:
version_added: '2.18'
name: testentry
description: for tests
deprecated:
why: for testing
version: '3.30'
alternatives: nothing
env:
- name: ANSIBLE_TEST_ENTRY2
ini:
- section: testing
key: valid2

@ -672,3 +672,17 @@ class ConfigManager(object):
self._plugins[plugin_type] = {} self._plugins[plugin_type] = {}
self._plugins[plugin_type][name] = defs self._plugins[plugin_type][name] = defs
@staticmethod
def get_deprecated_msg_from_config(dep_docs, include_removal=False):
removal = ''
if include_removal:
if 'removed_at_date' in dep_docs:
removal = f"Will be removed in a release after {dep_docs['removed_at_date']}\n\t"
else:
removal = f"Will be removed in: Ansible {dep_docs['removed_in']}\n\t"
# TODO: choose to deprecate either singular or plural
alt = dep_docs.get('alternatives', dep_docs.get('alternative', ''))
return f"Reason: {dep_docs['why']}\n\t{removal}Alternatives: {alt}"

@ -15,6 +15,10 @@ from ansible.module_utils.parsing.convert_bool import BOOLEANS_TRUE
from ansible.release import __version__ from ansible.release import __version__
from ansible.utils.fqcn import add_internal_fqcns from ansible.utils.fqcn import add_internal_fqcns
# initialize config manager/config data to read/store global settings
# and generate 'pseudo constants' for app consumption.
config = ConfigManager()
def _warning(msg): def _warning(msg):
''' display is not guaranteed here, nor it being the full class, but try anyways, fallback to sys.stderr.write ''' ''' display is not guaranteed here, nor it being the full class, but try anyways, fallback to sys.stderr.write '''
@ -36,6 +40,26 @@ def _deprecated(msg, version):
sys.stderr.write(' [DEPRECATED] %s, to be removed in %s\n' % (msg, version)) sys.stderr.write(' [DEPRECATED] %s, to be removed in %s\n' % (msg, version))
def handle_config_noise(display=None):
if display is not None:
w = display.warning
d = display.deprecated
else:
w = _warning
d = _deprecated
while config.WARNINGS:
warn = config.WARNINGS.pop(0)
w(warn)
while config.DEPRECATED:
# tuple with name and options
dep = config.DEPRECATED.pop(0)
msg = config.get_deprecated_msg_from_config(dep[1])
d(msg, version=dep[1]['version'])
def set_constant(name, value, export=vars()): def set_constant(name, value, export=vars()):
''' sets constants and returns resolved options dict ''' ''' sets constants and returns resolved options dict '''
export[name] = value export[name] = value
@ -218,11 +242,8 @@ MAGIC_VARIABLE_MAPPING = dict(
) )
# POPULATE SETTINGS FROM CONFIG ### # POPULATE SETTINGS FROM CONFIG ###
config = ConfigManager()
# Generate constants from config
for setting in config.get_configuration_definitions(): for setting in config.get_configuration_definitions():
set_constant(setting, config.get_config_value(setting, variables=vars())) set_constant(setting, config.get_config_value(setting, variables=vars()))
for warn in config.WARNINGS: # emit any warnings or deprecations
_warning(warn) handle_config_noise()

@ -92,6 +92,7 @@ class AnsiblePlugin(ABC):
def set_option(self, option, value): def set_option(self, option, value):
self._options[option] = C.config.get_config_value(option, plugin_type=self.plugin_type, plugin_name=self._load_name, direct={option: value}) self._options[option] = C.config.get_config_value(option, plugin_type=self.plugin_type, plugin_name=self._load_name, direct={option: value})
C.handle_config_noise(display)
def set_options(self, task_keys=None, var_options=None, direct=None): def set_options(self, task_keys=None, var_options=None, direct=None):
''' '''
@ -108,6 +109,7 @@ class AnsiblePlugin(ABC):
if self.allow_extras and var_options and '_extras' in var_options: if self.allow_extras and var_options and '_extras' in var_options:
# these are largely unvalidated passthroughs, either plugin or underlying API will validate # these are largely unvalidated passthroughs, either plugin or underlying API will validate
self._options['_extras'] = var_options['_extras'] self._options['_extras'] = var_options['_extras']
C.handle_config_noise(display)
def has_option(self, option): def has_option(self, option):
if not self._options: if not self._options:

@ -18,7 +18,7 @@
that: that:
- result is failed - result is failed
- | - |
"ERROR! Unable to retrieve documentation from 'test_docs_missing_description' due to: All (sub-)options and return values must have a 'description' field" "ERROR! Unable to retrieve documentation from 'test_docs_missing_description'. All (sub-)options and return values must have a 'description' field"
in result.stderr in result.stderr
- name: module with suboptions (avoid first line as it has full path) - name: module with suboptions (avoid first line as it has full path)

@ -0,0 +1,2 @@
shippable/posix/group3
context/controller

@ -0,0 +1,82 @@
# (c) 2020 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import annotations
DOCUMENTATION = '''
cache: notjsonfile
short_description: NotJSON cache plugin
description: This cache uses is NOT JSON
author: Ansible Core (@ansible-core)
version_added: 0.7.0
options:
_uri:
required: True
description:
- Path in which the cache plugin will save the JSON files
env:
- name: ANSIBLE_CACHE_PLUGIN_CONNECTION
version_added: 1.2.0
ini:
- key: fact_caching_connection
section: notjsonfile_cache
- key: fact_caching_connection
section: defaults
_prefix:
description: User defined prefix to use when creating the JSON files
env:
- name: ANSIBLE_CACHE_PLUGIN_PREFIX
version_added: 1.1.0
ini:
- key: fact_caching_prefix
section: defaults
- key: fact_caching_prefix
section: notjson_cache
deprecated:
alternative: section is notjsonfile_cache
why: Another test deprecation
removed_at_date: '2050-01-01'
- key: fact_caching_prefix
section: notjsonfile_cache
_timeout:
default: 86400
description: Expiration timeout for the cache plugin data
env:
- name: ANSIBLE_CACHE_PLUGIN_TIMEOUT
- name: ANSIBLE_NOTJSON_CACHE_PLUGIN_TIMEOUT
deprecated:
alternative: do not use a variable
why: Test deprecation
version: '3.0.0'
ini:
- key: fact_caching_timeout
section: defaults
- key: fact_caching_timeout
section: notjsonfile_cache
vars:
- name: notsjonfile_fact_caching_timeout
version_added: 1.5.0
type: integer
removeme:
default: 86400
description: Expiration timeout for the cache plugin data
deprecated:
alternative: cause i need to test it
why: Test deprecation
version: '2.0.0'
env:
- name: ANSIBLE_NOTJSON_CACHE_PLUGIN_REMOVEME
'''
from ansible.plugins.cache import BaseFileCacheModule
class CacheModule(BaseFileCacheModule):
"""
A caching module backed by json files.
"""
def _dump(self):
pass
def _load(self):
pass

@ -0,0 +1,3 @@
[testing]
# ini key not deprecated, but parent setting is
valid2=true

@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
# Copyright: Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import annotations
DOCUMENTATION = r'''
---
module: removeoption
short_description: noop
description: does nothing, test for removal of option
options:
one:
description:
- first option
type: bool
default: no
two:
description:
- second option
deprecated:
removed_in: '3.30'
why: cause i wanna test this!
alternatives: none needed
notes:
- Just noop to test module deprecation
seealso:
- module: willremove
author:
- Ansible Core Team
attributes:
action:
support: full
async:
support: full
bypass_host_loop:
support: none
check_mode:
support: full
diff_mode:
support: none
platform:
platforms: all
'''
EXAMPLES = r'''
- name: useless
remove_option:
one: true
two: /etc/file.conf
'''
RETURN = r'''
'''
from ansible.module_utils.basic import AnsibleModule
def main():
module = AnsibleModule(
argument_spec=dict(
one=dict(type='bool', default='no'),
two=dict(type='str', removed_in_version='3.30'),
),
supports_check_mode=True
)
one = module.params['one']
two = module.params['two']
result = {'yolo': 'lola'}
module.exit_json(**result)
if __name__ == '__main__':
main()

@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Copyright: Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import annotations
DOCUMENTATION = r'''
---
module: willremove
version_added: histerical
short_description: does nothing
description: does nothing, this is deprecation test
deprecated:
removed_in: '3.30'
why: cause i wanna!
alternatives: no soup for you!
options:
one:
description:
- first option
type: bool
default: no
two:
description:
- second option
notes:
- Just noop to test module deprecation
seealso:
- module: removeoption
author:
- Ansible Core Team
attributes:
action:
support: full
async:
support: full
bypass_host_loop:
support: none
check_mode:
support: full
diff_mode:
support: none
platform:
platforms: all
'''
EXAMPLES = r'''
- name: useless
willremove:
one: true
two: /etc/file.conf
'''
RETURN = r'''
'''
from ansible.module_utils.basic import AnsibleModule
def main():
module = AnsibleModule(
argument_spec=dict(
one=dict(type='bool', default='no'),
two=dict(type='str'),
),
supports_check_mode=True
)
one = module.params['one']
two = module.params['two']
result = {'yolo': 'lola'}
module.exit_json(**result)
if __name__ == '__main__':
main()

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -eux -o pipefail
export ANSIBLE_DEPRECATION_WARNINGS=True
### check general config
# check for entry key valid, no deprecation
[ "$(ANSIBLE_CONFIG='entry_key_not_deprecated.cfg' ansible -m meta -a 'noop' localhost 2>&1 | grep -c 'DEPRECATION')" -eq "0" ]
# check for entry key deprecation, must be defined to trigger
[ "$(ANSIBLE_CONFIG='entry_key_deprecated.cfg' ansible -m meta -a 'noop' localhost 2>&1 | grep -c 'DEPRECATION')" -eq "1" ]
# check for deprecation of entry itself, must be consumed to trigger
[ "$(ANSIBLE_TEST_ENTRY2=1 ansible -m debug -a 'msg={{q("config", "_Z_TEST_ENTRY_2")}}' localhost 2>&1 | grep -c 'DEPRECATION')" -eq "1" ]
# check for entry deprecation, just need key defined to trigger
[ "$(ANSIBLE_CONFIG='entry_key_deprecated2.cfg' ansible -m meta -a 'noop' localhost 2>&1 | grep -c 'DEPRECATION')" -eq "1" ]
### check plugin config
# force use of the test plugin
export ANSIBLE_CACHE_PLUGIN_CONNECTION=/var/tmp
export ANSIBLE_CACHE_PLUGIN=notjsonfile
# check for plugin(s) config option and setting non deprecation
[ "$(ANSIBLE_CACHE_PLUGIN_TIMEOUT=1 ansible -m meta -a 'noop' localhost --playbook-dir ./ 2>&1 | grep -c 'DEPRECATION')" -eq "0" ]
# check for plugin(s) config option setting deprecation
[ "$(ANSIBLE_NOTJSON_CACHE_PLUGIN_TIMEOUT=1 ansible -m meta -a 'noop' localhost --playbook-dir ./ 2>&1 | grep -c 'DEPRECATION')" -eq "1" ]
# check for plugin(s) config option deprecation
[ "$(ANSIBLE_NOTJSON_CACHE_PLUGIN_REMOVEME=1 ansible -m meta -a 'noop' localhost --playbook-dir ./ 2>&1 | grep -c 'DEPRECATION')" -eq "1" ]
# TODO: check for module deprecation
# TODO: check for module option deprecation
# TODO: check for plugin deprecation

@ -1235,7 +1235,7 @@ class ModuleValidator(Validator):
self._validate_semantic_markup(entry.get(key)) self._validate_semantic_markup(entry.get(key))
if isinstance(docs.get('deprecated'), dict): if isinstance(docs.get('deprecated'), dict):
for key in ('why', 'alternative'): for key in ('why', 'alternative', 'alternatives'):
self._validate_semantic_markup(docs.get('deprecated').get(key)) self._validate_semantic_markup(docs.get('deprecated').get(key))
self._validate_semantic_markup_options(docs.get('options')) self._validate_semantic_markup_options(docs.get('options'))

@ -84,6 +84,22 @@ def date(error_code=None):
return Any(isodate, error_code=error_code) return Any(isodate, error_code=error_code)
def require_only_one(keys):
def f(obj):
found = None
for k in obj.keys():
if k in keys:
if k is None:
found = k
else:
raise Invalid('Found conflicting keys, must contain only one of {}'.format(keys))
if found is None:
raise Invalid('Must contain one of {}'.format(keys))
return obj
return f
# Roles can also be referenced by semantic markup # Roles can also be referenced by semantic markup
_VALID_PLUGIN_TYPES = set(DOCUMENTABLE_PLUGINS + ('role', )) _VALID_PLUGIN_TYPES = set(DOCUMENTABLE_PLUGINS + ('role', ))
@ -568,7 +584,9 @@ def list_dict_option_schema(for_collection, plugin_type):
{ {
# This definition makes sure everything has the correct types/values # This definition makes sure everything has the correct types/values
'why': doc_string, 'why': doc_string,
'alternatives': doc_string, # TODO: phase out either plural or singular, 'alt' is exclusive group
Exclusive('alternative', 'alt'): doc_string,
Exclusive('alternatives', 'alt'): doc_string,
# vod stands for 'version or date'; this is the name of the exclusive group # vod stands for 'version or date'; this is the name of the exclusive group
Exclusive('removed_at_date', 'vod'): date(), Exclusive('removed_at_date', 'vod'): date(),
Exclusive('version', 'vod'): version(for_collection), Exclusive('version', 'vod'): version(for_collection),
@ -577,7 +595,7 @@ def list_dict_option_schema(for_collection, plugin_type):
{ {
# This definition makes sure that everything we require is there # This definition makes sure that everything we require is there
Required('why'): Any(*string_types), Required('why'): Any(*string_types),
'alternatives': Any(*string_types), Required(Any('alternatives', 'alternative')): Any(*string_types),
Required(Any('removed_at_date', 'version')): Any(*string_types), Required(Any('removed_at_date', 'version')): Any(*string_types),
Required('collection_name'): Any(*string_types), Required('collection_name'): Any(*string_types),
}, },
@ -761,13 +779,16 @@ def return_schema(for_collection, plugin_type='module'):
def deprecation_schema(for_collection): def deprecation_schema(for_collection):
main_fields = { main_fields = {
Required('why'): doc_string, Required('why'): doc_string,
Required('alternative'): doc_string, 'alternative': doc_string,
Required('removed_from_collection'): collection_name, 'alternatives': doc_string,
'removed': Any(True),
} }
if for_collection:
main_fields.update({Required('removed_from_collection'): collection_name, 'removed': Any(True)})
date_schema = { date_schema = {
Required('removed_at_date'): date(), Required('removed_at_date'): date(),
} }
@ -791,6 +812,7 @@ def deprecation_schema(for_collection):
if for_collection: if for_collection:
result = All( result = All(
result, result,
require_only_one(['alternative', 'alternatives']),
partial(check_removal_version, partial(check_removal_version,
version_field='removed_in', version_field='removed_in',
collection_name_field='removed_from_collection', collection_name_field='removed_from_collection',

Loading…
Cancel
Save