Fix notifying handlers by using an exact match (#55624)
* Fix notifying handlers by using an exact match rather than a string subset if listen is text rather than a list * Enforce better type checking for listeners * Share code for validating handler listeners * Add test for handlers without names * Add test for templating in handlers * Add test for include_role * Add a couple notes about 'listen' for handlers * changelog * Add a test for handlers without names * Test templating in handlers * changelog * Add some tests for include_role * Add a couple notes about 'listen' for handlers * make more sense * move local function into a class method
This commit is contained in:
parent
ee52b60d7d
commit
ec1287ca7e
11 changed files with 153 additions and 52 deletions
|
@ -0,0 +1,3 @@
|
||||||
|
bugfixes:
|
||||||
|
- handlers - Only notify a handler if the handler is an exact match by ensuring `listen` is a list of strings.
|
||||||
|
(https://github.com/ansible/ansible/issues/55575)
|
|
@ -34,7 +34,7 @@ force_handlers: Will force notified handler execution for hosts even if they fai
|
||||||
gather_facts: "A boolean that controls if the play will automatically run the 'setup' task to gather facts for the hosts."
|
gather_facts: "A boolean that controls if the play will automatically run the 'setup' task to gather facts for the hosts."
|
||||||
gather_subset: Allows you to pass subset options to the fact gathering plugin controlled by :term:`gather_facts`.
|
gather_subset: Allows you to pass subset options to the fact gathering plugin controlled by :term:`gather_facts`.
|
||||||
gather_timeout: Allows you to set the timeout for the fact gathering plugin controlled by :term:`gather_facts`.
|
gather_timeout: Allows you to set the timeout for the fact gathering plugin controlled by :term:`gather_facts`.
|
||||||
handlers: "A section with tasks that are treated as handlers, these won't get executed normally, only when notified after each section of tasks is complete."
|
handlers: "A section with tasks that are treated as handlers, these won't get executed normally, only when notified after each section of tasks is complete. A handler's `listen` field is not templatable."
|
||||||
hosts: "A list of groups, hosts or host pattern that translates into a list of hosts that are the play's target."
|
hosts: "A list of groups, hosts or host pattern that translates into a list of hosts that are the play's target."
|
||||||
ignore_errors: Boolean that allows you to ignore task failures and continue with play. It does not affect connection errors.
|
ignore_errors: Boolean that allows you to ignore task failures and continue with play. It does not affect connection errors.
|
||||||
ignore_unreachable: Boolean that allows you to ignore unreachable hosts and continue with play. This does not affect other task errors (see :term:`ignore_errors`) but is useful for groups of volatile/ephemeral hosts.
|
ignore_unreachable: Boolean that allows you to ignore unreachable hosts and continue with play. This does not affect other task errors (see :term:`ignore_errors`) but is useful for groups of volatile/ephemeral hosts.
|
||||||
|
|
|
@ -455,6 +455,7 @@ a shared source like Galaxy).
|
||||||
.. note::
|
.. note::
|
||||||
* Notify handlers are always run in the same order they are defined, `not` in the order listed in the notify-statement. This is also the case for handlers using `listen`.
|
* Notify handlers are always run in the same order they are defined, `not` in the order listed in the notify-statement. This is also the case for handlers using `listen`.
|
||||||
* Handler names and `listen` topics live in a global namespace.
|
* Handler names and `listen` topics live in a global namespace.
|
||||||
|
* Handler names are templatable and `listen` topics are not.
|
||||||
* Use unique handler names. If you trigger more than one handler with the same name, the first one(s) get overwritten. Only the last one defined will run.
|
* Use unique handler names. If you trigger more than one handler with the same name, the first one(s) get overwritten. Only the last one defined will run.
|
||||||
* You cannot notify a handler that is defined inside of an include. As of Ansible 2.1, this does work, however the include must be `static`.
|
* You cannot notify a handler that is defined inside of an include. As of Ansible 2.1, this does work, however the include must be `static`.
|
||||||
|
|
||||||
|
|
|
@ -334,6 +334,57 @@ class FieldAttributeBase(with_metaclass(BaseMeta, object)):
|
||||||
|
|
||||||
return new_me
|
return new_me
|
||||||
|
|
||||||
|
def get_validated_value(self, name, attribute, value, templar):
|
||||||
|
if attribute.isa == 'string':
|
||||||
|
value = to_text(value)
|
||||||
|
elif attribute.isa == 'int':
|
||||||
|
value = int(value)
|
||||||
|
elif attribute.isa == 'float':
|
||||||
|
value = float(value)
|
||||||
|
elif attribute.isa == 'bool':
|
||||||
|
value = boolean(value, strict=False)
|
||||||
|
elif attribute.isa == 'percent':
|
||||||
|
# special value, which may be an integer or float
|
||||||
|
# with an optional '%' at the end
|
||||||
|
if isinstance(value, string_types) and '%' in value:
|
||||||
|
value = value.replace('%', '')
|
||||||
|
value = float(value)
|
||||||
|
elif attribute.isa == 'list':
|
||||||
|
if value is None:
|
||||||
|
value = []
|
||||||
|
elif not isinstance(value, list):
|
||||||
|
value = [value]
|
||||||
|
if attribute.listof is not None:
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, attribute.listof):
|
||||||
|
raise AnsibleParserError("the field '%s' should be a list of %s, "
|
||||||
|
"but the item '%s' is a %s" % (name, attribute.listof, item, type(item)), obj=self.get_ds())
|
||||||
|
elif attribute.required and attribute.listof == string_types:
|
||||||
|
if item is None or item.strip() == "":
|
||||||
|
raise AnsibleParserError("the field '%s' is required, and cannot have empty values" % (name,), obj=self.get_ds())
|
||||||
|
elif attribute.isa == 'set':
|
||||||
|
if value is None:
|
||||||
|
value = set()
|
||||||
|
elif not isinstance(value, (list, set)):
|
||||||
|
if isinstance(value, string_types):
|
||||||
|
value = value.split(',')
|
||||||
|
else:
|
||||||
|
# Making a list like this handles strings of
|
||||||
|
# text and bytes properly
|
||||||
|
value = [value]
|
||||||
|
if not isinstance(value, set):
|
||||||
|
value = set(value)
|
||||||
|
elif attribute.isa == 'dict':
|
||||||
|
if value is None:
|
||||||
|
value = dict()
|
||||||
|
elif not isinstance(value, dict):
|
||||||
|
raise TypeError("%s is not a dictionary" % value)
|
||||||
|
elif attribute.isa == 'class':
|
||||||
|
if not isinstance(value, attribute.class_type):
|
||||||
|
raise TypeError("%s is not a valid %s (got a %s instead)" % (name, attribute.class_type, type(value)))
|
||||||
|
value.post_validate(templar=templar)
|
||||||
|
return value
|
||||||
|
|
||||||
def post_validate(self, templar):
|
def post_validate(self, templar):
|
||||||
'''
|
'''
|
||||||
we can't tell that everything is of the right type until we have
|
we can't tell that everything is of the right type until we have
|
||||||
|
@ -389,54 +440,7 @@ class FieldAttributeBase(with_metaclass(BaseMeta, object)):
|
||||||
|
|
||||||
# and make sure the attribute is of the type it should be
|
# and make sure the attribute is of the type it should be
|
||||||
if value is not None:
|
if value is not None:
|
||||||
if attribute.isa == 'string':
|
value = self.get_validated_value(name, attribute, value, templar)
|
||||||
value = to_text(value)
|
|
||||||
elif attribute.isa == 'int':
|
|
||||||
value = int(value)
|
|
||||||
elif attribute.isa == 'float':
|
|
||||||
value = float(value)
|
|
||||||
elif attribute.isa == 'bool':
|
|
||||||
value = boolean(value, strict=False)
|
|
||||||
elif attribute.isa == 'percent':
|
|
||||||
# special value, which may be an integer or float
|
|
||||||
# with an optional '%' at the end
|
|
||||||
if isinstance(value, string_types) and '%' in value:
|
|
||||||
value = value.replace('%', '')
|
|
||||||
value = float(value)
|
|
||||||
elif attribute.isa == 'list':
|
|
||||||
if value is None:
|
|
||||||
value = []
|
|
||||||
elif not isinstance(value, list):
|
|
||||||
value = [value]
|
|
||||||
if attribute.listof is not None:
|
|
||||||
for item in value:
|
|
||||||
if not isinstance(item, attribute.listof):
|
|
||||||
raise AnsibleParserError("the field '%s' should be a list of %s, "
|
|
||||||
"but the item '%s' is a %s" % (name, attribute.listof, item, type(item)), obj=self.get_ds())
|
|
||||||
elif attribute.required and attribute.listof == string_types:
|
|
||||||
if item is None or item.strip() == "":
|
|
||||||
raise AnsibleParserError("the field '%s' is required, and cannot have empty values" % (name,), obj=self.get_ds())
|
|
||||||
elif attribute.isa == 'set':
|
|
||||||
if value is None:
|
|
||||||
value = set()
|
|
||||||
elif not isinstance(value, (list, set)):
|
|
||||||
if isinstance(value, string_types):
|
|
||||||
value = value.split(',')
|
|
||||||
else:
|
|
||||||
# Making a list like this handles strings of
|
|
||||||
# text and bytes properly
|
|
||||||
value = [value]
|
|
||||||
if not isinstance(value, set):
|
|
||||||
value = set(value)
|
|
||||||
elif attribute.isa == 'dict':
|
|
||||||
if value is None:
|
|
||||||
value = dict()
|
|
||||||
elif not isinstance(value, dict):
|
|
||||||
raise TypeError("%s is not a dictionary" % value)
|
|
||||||
elif attribute.isa == 'class':
|
|
||||||
if not isinstance(value, attribute.class_type):
|
|
||||||
raise TypeError("%s is not a valid %s (got a %s instead)" % (name, attribute.class_type, type(value)))
|
|
||||||
value.post_validate(templar=templar)
|
|
||||||
|
|
||||||
# and assign the massaged value back to the attribute field
|
# and assign the massaged value back to the attribute field
|
||||||
setattr(self, name, value)
|
setattr(self, name, value)
|
||||||
|
|
|
@ -21,11 +21,12 @@ __metaclass__ = type
|
||||||
|
|
||||||
from ansible.playbook.attribute import FieldAttribute
|
from ansible.playbook.attribute import FieldAttribute
|
||||||
from ansible.playbook.task import Task
|
from ansible.playbook.task import Task
|
||||||
|
from ansible.module_utils.six import string_types
|
||||||
|
|
||||||
|
|
||||||
class Handler(Task):
|
class Handler(Task):
|
||||||
|
|
||||||
_listen = FieldAttribute(isa='list', default=list)
|
_listen = FieldAttribute(isa='list', default=list, listof=string_types, static=True)
|
||||||
|
|
||||||
def __init__(self, block=None, role=None, task_include=None):
|
def __init__(self, block=None, role=None, task_include=None):
|
||||||
self.notified_hosts = []
|
self.notified_hosts = []
|
||||||
|
|
|
@ -350,6 +350,10 @@ class StrategyBase:
|
||||||
|
|
||||||
return [actual_host]
|
return [actual_host]
|
||||||
|
|
||||||
|
def get_handler_templar(self, handler_task, iterator):
|
||||||
|
handler_vars = self._variable_manager.get_vars(play=iterator._play, task=handler_task)
|
||||||
|
return Templar(loader=self._loader, variables=handler_vars)
|
||||||
|
|
||||||
@debug_closure
|
@debug_closure
|
||||||
def _process_pending_results(self, iterator, one_pass=False, max_passes=None):
|
def _process_pending_results(self, iterator, one_pass=False, max_passes=None):
|
||||||
'''
|
'''
|
||||||
|
@ -373,8 +377,7 @@ class StrategyBase:
|
||||||
for handler_task in handler_block.block:
|
for handler_task in handler_block.block:
|
||||||
if handler_task.name:
|
if handler_task.name:
|
||||||
if not handler_task.cached_name:
|
if not handler_task.cached_name:
|
||||||
handler_vars = self._variable_manager.get_vars(play=iterator._play, task=handler_task)
|
templar = self.get_handler_templar(handler_task, iterator)
|
||||||
templar = Templar(loader=self._loader, variables=handler_vars)
|
|
||||||
handler_task.name = templar.template(handler_task.name)
|
handler_task.name = templar.template(handler_task.name)
|
||||||
handler_task.cached_name = True
|
handler_task.cached_name = True
|
||||||
|
|
||||||
|
@ -530,6 +533,10 @@ class StrategyBase:
|
||||||
for listening_handler_block in iterator._play.handlers:
|
for listening_handler_block in iterator._play.handlers:
|
||||||
for listening_handler in listening_handler_block.block:
|
for listening_handler in listening_handler_block.block:
|
||||||
listeners = getattr(listening_handler, 'listen', []) or []
|
listeners = getattr(listening_handler, 'listen', []) or []
|
||||||
|
templar = self.get_handler_templar(listening_handler, iterator)
|
||||||
|
listeners = listening_handler.get_validated_value(
|
||||||
|
'listen', listening_handler._valid_attrs['listen'], listeners, templar
|
||||||
|
)
|
||||||
if handler_name not in listeners:
|
if handler_name not in listeners:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
- name: name1
|
||||||
|
set_fact:
|
||||||
|
role_non_templated_name: True
|
||||||
|
- name: "{{ handler2 }}"
|
||||||
|
set_fact:
|
||||||
|
role_templated_name: True
|
||||||
|
- name: testlistener1
|
||||||
|
set_fact:
|
||||||
|
role_non_templated_listener: True
|
||||||
|
listen: name3
|
||||||
|
- name: testlistener2
|
||||||
|
set_fact:
|
||||||
|
role_templated_listener: True
|
||||||
|
listen: "{{ handler4 }}"
|
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
- command: echo Hello World
|
||||||
|
notify:
|
||||||
|
- "{{ handler1 }}"
|
||||||
|
- "{{ handler2 }}"
|
||||||
|
- "{{ handler3 }}"
|
||||||
|
- "{{ handler4 }}"
|
||||||
|
|
||||||
|
- meta: flush_handlers
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- role_non_templated_name is defined
|
||||||
|
- role_templated_name is defined
|
||||||
|
- role_non_templated_listener is defined
|
||||||
|
- role_templated_listener is undefined
|
|
@ -66,6 +66,8 @@ grep -q "ERROR! The requested handler 'notify_inexistent_handler' was not found
|
||||||
# Notify inexistent handlers without errors when ANSIBLE_ERROR_ON_MISSING_HANDLER=false
|
# Notify inexistent handlers without errors when ANSIBLE_ERROR_ON_MISSING_HANDLER=false
|
||||||
ANSIBLE_ERROR_ON_MISSING_HANDLER=false ansible-playbook test_handlers_inexistent_notify.yml -i inventory.handlers -v "$@"
|
ANSIBLE_ERROR_ON_MISSING_HANDLER=false ansible-playbook test_handlers_inexistent_notify.yml -i inventory.handlers -v "$@"
|
||||||
|
|
||||||
|
ANSIBLE_ERROR_ON_MISSING_HANDLER=false ansible-playbook test_templating_in_handlers.yml -v "$@"
|
||||||
|
|
||||||
# https://github.com/ansible/ansible/issues/36649
|
# https://github.com/ansible/ansible/issues/36649
|
||||||
output_dir=/tmp
|
output_dir=/tmp
|
||||||
set +e
|
set +e
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
that:
|
that:
|
||||||
- "notify_listen_ran_1_1 is defined"
|
- "notify_listen_ran_1_1 is defined"
|
||||||
- "notify_listen_ran_1_2 is defined"
|
- "notify_listen_ran_1_2 is defined"
|
||||||
|
- "notify_listen_ran_1_3 is undefined"
|
||||||
handlers:
|
handlers:
|
||||||
- name: notify_handler_ran_1_1
|
- name: notify_handler_ran_1_1
|
||||||
set_fact:
|
set_fact:
|
||||||
|
@ -22,6 +23,10 @@
|
||||||
set_fact:
|
set_fact:
|
||||||
notify_listen_ran_1_2: True
|
notify_listen_ran_1_2: True
|
||||||
listen: notify_listen
|
listen: notify_listen
|
||||||
|
- name: notify_handler_ran_1_3
|
||||||
|
set_fact:
|
||||||
|
notify_handler_ran_1_3: True
|
||||||
|
listen: notify_listen2
|
||||||
|
|
||||||
- name: test listen unnamed handlers
|
- name: test listen unnamed handlers
|
||||||
hosts: localhost
|
hosts: localhost
|
||||||
|
@ -38,6 +43,7 @@
|
||||||
that:
|
that:
|
||||||
- "notify_listen_ran_1 is defined"
|
- "notify_listen_ran_1 is defined"
|
||||||
- "notify_listen_ran_2 is defined"
|
- "notify_listen_ran_2 is defined"
|
||||||
|
- "notify_listen_ran_3 is undefined"
|
||||||
handlers:
|
handlers:
|
||||||
- set_fact:
|
- set_fact:
|
||||||
notify_listen_ran_1: True
|
notify_listen_ran_1: True
|
||||||
|
@ -45,6 +51,9 @@
|
||||||
- set_fact:
|
- set_fact:
|
||||||
notify_listen_ran_2: True
|
notify_listen_ran_2: True
|
||||||
listen: notify_listen
|
listen: notify_listen
|
||||||
|
- set_fact:
|
||||||
|
notify_handler_ran_3: True
|
||||||
|
listen: notify_listen2
|
||||||
|
|
||||||
- name: test with mixed notify by name and listen
|
- name: test with mixed notify by name and listen
|
||||||
hosts: localhost
|
hosts: localhost
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
- name: test templated values in handlers
|
||||||
|
hosts: localhost
|
||||||
|
gather_facts: no
|
||||||
|
vars:
|
||||||
|
handler1: name1
|
||||||
|
handler2: name2
|
||||||
|
handler3: name3
|
||||||
|
handler4: name4
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: name1
|
||||||
|
set_fact:
|
||||||
|
non_templated_name: True
|
||||||
|
- name: "{{ handler2 }}"
|
||||||
|
set_fact:
|
||||||
|
templated_name: True
|
||||||
|
- name: testlistener1
|
||||||
|
set_fact:
|
||||||
|
non_templated_listener: True
|
||||||
|
listen: name3
|
||||||
|
- name: testlistener2
|
||||||
|
set_fact:
|
||||||
|
templated_listener: True
|
||||||
|
listen: "{{ handler4 }}"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- command: echo Hello World
|
||||||
|
notify:
|
||||||
|
- "{{ handler1 }}"
|
||||||
|
- "{{ handler2 }}"
|
||||||
|
- "{{ handler3 }}"
|
||||||
|
- "{{ handler4 }}"
|
||||||
|
|
||||||
|
- meta: flush_handlers
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- non_templated_name is defined
|
||||||
|
- templated_name is defined
|
||||||
|
- non_templated_listener is defined
|
||||||
|
- templated_listener is undefined
|
||||||
|
|
||||||
|
- include_role: name=test_templating_in_handlers
|
Loading…
Reference in a new issue