diff --git a/changelogs/fragments/end_role.yml b/changelogs/fragments/end_role.yml new file mode 100644 index 00000000000..702199207de --- /dev/null +++ b/changelogs/fragments/end_role.yml @@ -0,0 +1,2 @@ +minor_changes: + - Add a new meta task ``end_role`` (https://github.com/ansible/ansible/issues/22286) diff --git a/lib/ansible/modules/meta.py b/lib/ansible/modules/meta.py index 0baea37d677..91b3f0403f9 100644 --- a/lib/ansible/modules/meta.py +++ b/lib/ansible/modules/meta.py @@ -33,7 +33,12 @@ options: - V(end_host) (added in Ansible 2.8) is a per-host variation of V(end_play). Causes the play to end for the current host without failing it. - V(end_batch) (added in Ansible 2.12) causes the current batch (see C(serial)) to end without failing the host(s). Note that with C(serial=0) or undefined this behaves the same as V(end_play). - choices: [ clear_facts, clear_host_errors, end_host, end_play, flush_handlers, noop, refresh_inventory, reset_connection, end_batch ] + - V(end_role) (added in Ansible 2.18) causes the currently executing role to end without failing the host(s). + Effectively all tasks from within a role after V(end_role) is executed are ignored. Since handlers live in a global, + play scope, all handlers added via the role are unaffected and are still executed if notified. It is an error + to call V(end_role) from outside of a role or from a handler. Note that V(end_role) does not have an effect to + the parent roles or roles that depend (via dependencies in meta/main.yml) on a role executing V(end_role). + choices: [ clear_facts, clear_host_errors, end_host, end_play, flush_handlers, noop, refresh_inventory, reset_connection, end_batch, end_role ] required: true extends_documentation_fragment: - action_common_attributes diff --git a/lib/ansible/playbook/helpers.py b/lib/ansible/playbook/helpers.py index 91ca06f07ec..f0ef498d19f 100644 --- a/lib/ansible/playbook/helpers.py +++ b/lib/ansible/playbook/helpers.py @@ -293,8 +293,12 @@ def load_list_of_tasks(ds, play, block=None, role=None, task_include=None, use_h else: if use_handlers: t = Handler.load(task_ds, block=block, role=role, task_include=task_include, variable_manager=variable_manager, loader=loader) + if t.action in C._ACTION_META and t.args.get('_raw_params') == "end_role": + raise AnsibleParserError("Cannot execute 'end_role' from a handler") else: t = Task.load(task_ds, block=block, role=role, task_include=task_include, variable_manager=variable_manager, loader=loader) + if t.action in C._ACTION_META and t.args.get('_raw_params') == "end_role" and role is None: + raise AnsibleParserError("Cannot execute 'end_role' from outside of a role") task_list.append(t) diff --git a/lib/ansible/plugins/strategy/__init__.py b/lib/ansible/plugins/strategy/__init__.py index bb8087d78bd..1d8af833616 100644 --- a/lib/ansible/plugins/strategy/__init__.py +++ b/lib/ansible/plugins/strategy/__init__.py @@ -1020,13 +1020,23 @@ class StrategyBase: # TODO: Nix msg here? Left for historical reasons, but skip_reason exists now. msg = "end_host conditional evaluated to false, continuing execution for %s" % target_host.name elif meta_action == 'role_complete': - # Allow users to use this in a play as reported in https://github.com/ansible/ansible/issues/22286? - # How would this work with allow_duplicates?? if task.implicit: role_obj = self._get_cached_role(task, iterator._play) if target_host.name in role_obj._had_task_run: role_obj._completed[target_host.name] = True msg = 'role_complete for %s' % target_host.name + elif meta_action == 'end_role': + if _evaluate_conditional(target_host): + while True: + state, task = iterator.get_next_task_for_host(target_host, peek=True) + if task.action in C._ACTION_META and task.args.get("_raw_params") == "role_complete": + break + iterator.set_state_for_host(target_host.name, state) + display.debug("'%s' skipped because role has been ended via 'end_role'" % task) + msg = 'ending role %s for %s' % (task._role.get_name(), target_host.name) + else: + skipped = True + skip_reason += 'continuing role %s for %s' % (task._role.get_name(), target_host.name) elif meta_action == 'reset_connection': all_vars = self._variable_manager.get_vars(play=iterator._play, host=target_host, task=task, _hosts=self._hosts_cache, _hosts_all=self._hosts_cache_all) diff --git a/test/integration/targets/roles/end_role.yml b/test/integration/targets/roles/end_role.yml new file mode 100644 index 00000000000..90c920d712d --- /dev/null +++ b/test/integration/targets/roles/end_role.yml @@ -0,0 +1,35 @@ +- hosts: localhost + gather_facts: false + pre_tasks: + - set_fact: + play_checkpoint: 1 + roles: + - end_role_inside + tasks: + - set_fact: + play_checkpoint: "{{ play_checkpoint|int + 1 }}" + + - import_role: + name: end_role_inside + allow_duplicates: true + + - set_fact: + play_checkpoint: "{{ play_checkpoint|int + 1 }}" + + - include_role: + name: end_role_inside + allow_duplicates: false + + - set_fact: + play_checkpoint: "{{ play_checkpoint|int + 1 }}" + post_tasks: + - assert: + that: + - role_executed|int == 2 + - after_end_role is undefined + - play_checkpoint|int == 4 + - role_handler_ran is defined + + - name: when running this playbook check this appears on stdout to ensure the above assert wasn't skipped + debug: + msg: CHECKPOINT diff --git a/test/integration/targets/roles/end_role_handler_error.yml b/test/integration/targets/roles/end_role_handler_error.yml new file mode 100644 index 00000000000..75247a9ab16 --- /dev/null +++ b/test/integration/targets/roles/end_role_handler_error.yml @@ -0,0 +1,9 @@ +- hosts: localhost + gather_facts: false + tasks: + - debug: + changed_when: true + notify: invalid_handler + handlers: + - name: invalid_handler + meta: end_role diff --git a/test/integration/targets/roles/end_role_nested.yml b/test/integration/targets/roles/end_role_nested.yml new file mode 100644 index 00000000000..ea79c4a9d05 --- /dev/null +++ b/test/integration/targets/roles/end_role_nested.yml @@ -0,0 +1,6 @@ +- hosts: host1,host2 + gather_facts: false + tasks: + - include_role: + name: end_role_inside + tasks_from: nested.yml diff --git a/test/integration/targets/roles/roles/end_role_inside/handlers/main.yml b/test/integration/targets/roles/roles/end_role_inside/handlers/main.yml new file mode 100644 index 00000000000..a140340ef05 --- /dev/null +++ b/test/integration/targets/roles/roles/end_role_inside/handlers/main.yml @@ -0,0 +1,3 @@ +- name: role_handler + set_fact: + role_handler_ran: true diff --git a/test/integration/targets/roles/roles/end_role_inside/tasks/main.yml b/test/integration/targets/roles/roles/end_role_inside/tasks/main.yml new file mode 100644 index 00000000000..210c9a363fd --- /dev/null +++ b/test/integration/targets/roles/roles/end_role_inside/tasks/main.yml @@ -0,0 +1,10 @@ +- set_fact: + role_executed: "{{ role_executed|default(0)|int + 1 }}" + +- command: echo + notify: role_handler + +- meta: end_role + +- set_fact: + after_end_role: true diff --git a/test/integration/targets/roles/roles/end_role_inside/tasks/nested.yml b/test/integration/targets/roles/roles/end_role_inside/tasks/nested.yml new file mode 100644 index 00000000000..b6d4f2bb39f --- /dev/null +++ b/test/integration/targets/roles/roles/end_role_inside/tasks/nested.yml @@ -0,0 +1,22 @@ +- set_fact: + end_role_cond: "{{ inventory_hostname == 'host1' }}" + +- include_role: + name: end_role_inside_nested + +- debug: + msg: CHECKPOINT + +- assert: + that: + - after_end_role is undefined + when: inventory_hostname == "host1" + +- assert: + that: + - after_end_role + when: inventory_hostname == "host2" + +- name: when running this playbook check this appears on stdout to ensure the above assert wasn't skipped + debug: + msg: CHECKPOINT diff --git a/test/integration/targets/roles/roles/end_role_inside_nested/tasks/import_tasks.yml b/test/integration/targets/roles/roles/end_role_inside_nested/tasks/import_tasks.yml new file mode 100644 index 00000000000..27fc5e86fb3 --- /dev/null +++ b/test/integration/targets/roles/roles/end_role_inside_nested/tasks/import_tasks.yml @@ -0,0 +1,5 @@ +- meta: end_role + when: end_role_cond + +- set_fact: + after_end_role: true diff --git a/test/integration/targets/roles/roles/end_role_inside_nested/tasks/include_tasks.yml b/test/integration/targets/roles/roles/end_role_inside_nested/tasks/include_tasks.yml new file mode 100644 index 00000000000..2fd7fb956ba --- /dev/null +++ b/test/integration/targets/roles/roles/end_role_inside_nested/tasks/include_tasks.yml @@ -0,0 +1 @@ +- import_tasks: import_tasks.yml diff --git a/test/integration/targets/roles/roles/end_role_inside_nested/tasks/main.yml b/test/integration/targets/roles/roles/end_role_inside_nested/tasks/main.yml new file mode 100644 index 00000000000..6acbb76e9dd --- /dev/null +++ b/test/integration/targets/roles/roles/end_role_inside_nested/tasks/main.yml @@ -0,0 +1 @@ +- include_tasks: include_tasks.yml diff --git a/test/integration/targets/roles/runme.sh b/test/integration/targets/roles/runme.sh index 5227e42ed86..2cb75dc3e86 100755 --- a/test/integration/targets/roles/runme.sh +++ b/test/integration/targets/roles/runme.sh @@ -53,3 +53,11 @@ ansible-playbook role_dep_chain.yml -i ../../inventory "$@" ANSIBLE_PRIVATE_ROLE_VARS=1 ansible-playbook privacy.yml -e @vars/privacy_vars.yml "$@" ANSIBLE_PRIVATE_ROLE_VARS=0 ansible-playbook privacy.yml -e @vars/privacy_vars.yml "$@" ansible-playbook privacy.yml -e @vars/privacy_vars.yml "$@" + +for strategy in linear free; do + [ "$(ANSIBLE_STRATEGY=$strategy ansible-playbook end_role.yml | grep -c CHECKPOINT)" = "1" ] + [ "$(ANSIBLE_STRATEGY=$strategy ansible-playbook -i host1,host2 end_role_nested.yml | grep -c CHECKPOINT)" = "4" ] +done + +[ "$(ansible localhost -m meta -a end_role 2>&1 | grep -c "ERROR! Cannot execute 'end_role' from outside of a role")" = "1" ] +[ "$(ansible-playbook end_role_handler_error.yml 2>&1 | grep -c "ERROR! Cannot execute 'end_role' from a handler")" = "1" ]