diff --git a/ansible_runner/callbacks/awx_display.py b/ansible_runner/callbacks/awx_display.py deleted file mode 100644 index cf877d95a..000000000 --- a/ansible_runner/callbacks/awx_display.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) 2017 Ansible by Red Hat -# -# This file is part of Ansible Tower, but depends on code imported from Ansible. -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . - -from __future__ import (absolute_import, division, print_function) - - -DOCUMENTATION = ''' - callback: awx_display - short_description: Playbook event dispatcher for ansible-runner - version_added: "2.0" - description: - - This callback is necessary for ansible-runner to work - type: stdout - extends_documentation_fragment: - - default_callback - requirements: - - Set as stdout in config -''' - -# Python -import os # noqa -import sys # noqa - -# Add awx/lib to sys.path. -awx_lib_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -if awx_lib_path not in sys.path: - sys.path.insert(0, awx_lib_path) - -# Tower Display Callback -from display_callback import AWXDefaultCallbackModule # noqa - - -# In order to be recognized correctly, self.__class__.__name__ needs to -# match "CallbackModule" -class CallbackModule(AWXDefaultCallbackModule): - pass diff --git a/ansible_runner/callbacks/minimal.py b/ansible_runner/callbacks/minimal.py deleted file mode 100644 index b170f08ce..000000000 --- a/ansible_runner/callbacks/minimal.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) 2017 Ansible by Red Hat -# -# This file is part of Ansible Tower, but depends on code imported from Ansible. -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . - -from __future__ import (absolute_import, division, print_function) - - -DOCUMENTATION = ''' - callback: minimal - short_description: Ad hoc event dispatcher for ansible-runner - version_added: "2.0" - description: - - This callback is necessary for ansible-runner to work - type: stdout - extends_documentation_fragment: - - default_callback - requirements: - - Set as stdout in config -''' - -# Python -import os # noqa -import sys # noqa - -# Add awx/lib to sys.path. -awx_lib_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -if awx_lib_path not in sys.path: - sys.path.insert(0, awx_lib_path) - -# Tower Display Callback -from display_callback import AWXMinimalCallbackModule # noqa - - -# In order to be recognized correctly, self.__class__.__name__ needs to -# match "CallbackModule" -class CallbackModule(AWXMinimalCallbackModule): - pass diff --git a/ansible_runner/config/_base.py b/ansible_runner/config/_base.py index 38ee9583a..17c822572 100644 --- a/ansible_runner/config/_base.py +++ b/ansible_runner/config/_base.py @@ -39,6 +39,8 @@ from ansible_runner.defaults import registry_auth_prefix from ansible_runner.loader import ArtifactLoader from ansible_runner.utils import ( + callback_mount, + get_callback_dir, open_fifo_write, args2cmdline, sanitize_container_name, @@ -199,8 +201,6 @@ def _prepare_env(self, runner_mode='pexpect'): if self.containerized: self.container_name = "ansible_runner_{}".format(sanitize_container_name(self.ident)) self.env = {} - # Special flags to convey info to entrypoint or process in container - self.env['LAUNCHED_BY_RUNNER'] = '1' if self.process_isolation_executable == 'podman': # A kernel bug in RHEL < 8.5 causes podman to use the fuse-overlayfs driver. This results in errors when @@ -258,17 +258,23 @@ def _prepare_env(self, runner_mode='pexpect'): if not self.containerized: callback_dir = self.env.get('AWX_LIB_DIRECTORY', os.getenv('AWX_LIB_DIRECTORY')) if callback_dir is None: - callback_dir = os.path.join(os.path.split(os.path.abspath(__file__))[0], "..", "callbacks") - python_path = self.env.get('PYTHONPATH', os.getenv('PYTHONPATH', '')) - self.env['PYTHONPATH'] = ':'.join([python_path, callback_dir]) - if python_path and not python_path.endswith(':'): - python_path += ':' + callback_dir = get_callback_dir() self.env['ANSIBLE_CALLBACK_PLUGINS'] = ':'.join(filter(None, (self.env.get('ANSIBLE_CALLBACK_PLUGINS'), callback_dir))) - if 'AD_HOC_COMMAND_ID' in self.env: - self.env['ANSIBLE_STDOUT_CALLBACK'] = 'minimal' - else: - self.env['ANSIBLE_STDOUT_CALLBACK'] = 'awx_display' + # this is an adhoc command if the module is specified, TODO: combine with logic in RunnerConfig class + is_adhoc = bool((getattr(self, 'binary', None) is None) and (getattr(self, 'module', None) is not None)) + + if self.env.get('ANSIBLE_STDOUT_CALLBACK'): + self.env['ORIGINAL_STDOUT_CALLBACK'] = self.env.get('ANSIBLE_STDOUT_CALLBACK') + + if is_adhoc: + # force loading awx_display stdout callback for adhoc commands + self.env["ANSIBLE_LOAD_CALLBACK_PLUGINS"] = '1' + if 'AD_HOC_COMMAND_ID' not in self.env: + self.env['AD_HOC_COMMAND_ID'] = '1' + + self.env['ANSIBLE_STDOUT_CALLBACK'] = 'awx_display' + self.env['ANSIBLE_RETRY_FILES_ENABLED'] = 'False' if 'ANSIBLE_HOST_KEY_CHECKING' not in self.env: self.env['ANSIBLE_HOST_KEY_CHECKING'] = 'False' @@ -479,20 +485,18 @@ def wrap_args_for_containerization(self, args, execution_mode, cmdline_args): dst_mount_path="/runner/artifacts", labels=":Z") - # Mount the entire private_data_dir - # custom show paths inside private_data_dir do not make sense - self._update_volume_mount_paths(new_args, - "{}".format(self.private_data_dir), - dst_mount_path="/runner", - labels=":Z") else: subdir_path = os.path.join(self.private_data_dir, 'artifacts') if not os.path.exists(subdir_path): os.mkdir(subdir_path, 0o700) - # Mount the entire private_data_dir - # custom show paths inside private_data_dir do not make sense - self._update_volume_mount_paths(new_args, "{}".format(self.private_data_dir), dst_mount_path="/runner", labels=":Z") + # Mount the entire private_data_dir + # custom show paths inside private_data_dir do not make sense + self._update_volume_mount_paths(new_args, "{}".format(self.private_data_dir), dst_mount_path="/runner", labels=":Z") + + # Mount the stdout callback plugin from the ansible-runner code base + mount_paths = callback_mount(copy_if_needed=True) + self._update_volume_mount_paths(new_args, mount_paths[0], dst_mount_path=mount_paths[1], labels=":Z") if self.container_auth_data: # Pull in the necessary registry auth info, if there is a container cred diff --git a/ansible_runner/display_callback/__init__.py b/ansible_runner/display_callback/__init__.py deleted file mode 100644 index a02a2ac15..000000000 --- a/ansible_runner/display_callback/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) 2016 Ansible by Red Hat, Inc. -# -# This file is part of Ansible Tower, but depends on code imported from Ansible. -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . - -from __future__ import (absolute_import, division, print_function) - -# AWX Display Callback -from . import display # noqa (wraps ansible.display.Display methods) -from .module import AWXDefaultCallbackModule, AWXMinimalCallbackModule - -__all__ = ['AWXDefaultCallbackModule', 'AWXMinimalCallbackModule'] diff --git a/ansible_runner/callbacks/__init__.py b/ansible_runner/display_callback/callback/__init__.py similarity index 100% rename from ansible_runner/callbacks/__init__.py rename to ansible_runner/display_callback/callback/__init__.py diff --git a/ansible_runner/display_callback/module.py b/ansible_runner/display_callback/callback/awx_display.py similarity index 59% rename from ansible_runner/display_callback/module.py rename to ansible_runner/display_callback/callback/awx_display.py index 1d9309f84..0388a9a94 100644 --- a/ansible_runner/display_callback/module.py +++ b/ansible_runner/display_callback/callback/awx_display.py @@ -17,35 +17,309 @@ from __future__ import (absolute_import, division, print_function) + +DOCUMENTATION = ''' + callback: awx_display + short_description: Playbook event dispatcher for ansible-runner + version_added: "2.0" + description: + - This callback is necessary for ansible-runner to work + type: stdout + extends_documentation_fragment: + - default_callback + requirements: + - Set as stdout in config +''' + # Python +import json +import stat +import multiprocessing +import threading +import base64 +import functools import collections import contextlib import datetime +import os import sys import uuid from copy import copy # Ansible from ansible import constants as C -from ansible.plugins.callback import CallbackBase -from ansible.plugins.callback.default import CallbackModule as DefaultCallbackModule +from ansible.plugins.loader import callback_loader +from ansible.utils.display import Display + +IS_ADHOC = os.getenv('AD_HOC_COMMAND_ID', False) -# AWX Display Callback -from .events import event_context -from .minimal import CallbackModule as MinimalCallbackModule +# Dynamically construct base classes for our callback module, to support custom stdout callbacks. +if os.getenv('ORIGINAL_STDOUT_CALLBACK'): + default_stdout_callback = os.getenv('ORIGINAL_STDOUT_CALLBACK') +elif IS_ADHOC: + default_stdout_callback = 'minimal' +else: + default_stdout_callback = 'default' -CENSORED = "the output has been hidden due to the fact that 'no_log: true' was specified for this result" # noqa +DefaultCallbackModule = callback_loader.get(default_stdout_callback).__class__ + +CENSORED = "the output has been hidden due to the fact that 'no_log: true' was specified for this result" def current_time(): return datetime.datetime.utcnow() -class BaseCallbackModule(CallbackBase): +# use a custom JSON serializer so we can properly handle !unsafe and !vault +# objects that may exist in events emitted by the callback plugin +# see: https://github.com/ansible/ansible/pull/38759 +class AnsibleJSONEncoderLocal(json.JSONEncoder): + ''' + The class AnsibleJSONEncoder exists in Ansible core for this function + this performs a mostly identical function via duck typing + ''' + + def default(self, o): + ''' + Returns JSON-valid representation for special Ansible python objects + which including vault objects and datetime objects + ''' + if getattr(o, 'yaml_tag', None) == '!vault': + encrypted_form = o._ciphertext + if isinstance(encrypted_form, bytes): + encrypted_form = encrypted_form.decode('utf-8') + return {'__ansible_vault': encrypted_form} + elif isinstance(o, (datetime.date, datetime.datetime)): + return o.isoformat() + return super(AnsibleJSONEncoderLocal, self).default(o) + + +class IsolatedFileWrite: + ''' + Class that will write partial event data to a file + ''' + + def __init__(self): + self.private_data_dir = os.getenv('AWX_ISOLATED_DATA_DIR') + + def set(self, key, value): + # Strip off the leading key identifying characters :1:ev- + event_uuid = key[len(':1:ev-'):] + # Write data in a staging area and then atomic move to pickup directory + filename = '{}-partial.json'.format(event_uuid) + if not os.path.exists(os.path.join(self.private_data_dir, 'job_events')): + os.mkdir(os.path.join(self.private_data_dir, 'job_events'), 0o700) + dropoff_location = os.path.join(self.private_data_dir, 'job_events', filename) + write_location = '.'.join([dropoff_location, 'tmp']) + partial_data = json.dumps(value, cls=AnsibleJSONEncoderLocal) + with os.fdopen(os.open(write_location, os.O_WRONLY | os.O_CREAT, stat.S_IRUSR | stat.S_IWUSR), 'w') as f: + f.write(partial_data) + os.rename(write_location, dropoff_location) + + +class EventContext(object): + ''' + Store global and local (per thread/process) data associated with callback + events and other display output methods. + ''' + + def __init__(self): + self.display_lock = multiprocessing.RLock() + self._local = threading.local() + if os.getenv('AWX_ISOLATED_DATA_DIR', False): + self.cache = IsolatedFileWrite() + + def add_local(self, **kwargs): + tls = vars(self._local) + ctx = tls.setdefault('_ctx', {}) + ctx.update(kwargs) + + def remove_local(self, **kwargs): + for key in kwargs.keys(): + self._local._ctx.pop(key, None) + + @contextlib.contextmanager + def set_local(self, **kwargs): + try: + self.add_local(**kwargs) + yield + finally: + self.remove_local(**kwargs) + + def get_local(self): + return getattr(getattr(self, '_local', None), '_ctx', {}) + + def add_global(self, **kwargs): + if not hasattr(self, '_global_ctx'): + self._global_ctx = {} + self._global_ctx.update(kwargs) + + def remove_global(self, **kwargs): + if hasattr(self, '_global_ctx'): + for key in kwargs.keys(): + self._global_ctx.pop(key, None) + + @contextlib.contextmanager + def set_global(self, **kwargs): + try: + self.add_global(**kwargs) + yield + finally: + self.remove_global(**kwargs) + + def get_global(self): + return getattr(self, '_global_ctx', {}) + + def get(self): + ctx = {} + ctx.update(self.get_global()) + ctx.update(self.get_local()) + return ctx + + def get_begin_dict(self): + omit_event_data = os.getenv("RUNNER_OMIT_EVENTS", "False").lower() == "true" + include_only_failed_event_data = os.getenv("RUNNER_ONLY_FAILED_EVENTS", "False").lower() == "true" + event_data = self.get() + event = event_data.pop('event', None) + if not event: + event = 'verbose' + for key in ('debug', 'verbose', 'deprecated', 'warning', 'system_warning', 'error'): + if event_data.get(key, False): + event = key + break + event_dict = dict(event=event) + should_process_event_data = (include_only_failed_event_data and event in ('runner_on_failed', 'runner_on_async_failed', 'runner_on_item_failed')) \ + or not include_only_failed_event_data + if os.getenv('JOB_ID', ''): + event_dict['job_id'] = int(os.getenv('JOB_ID', '0')) + if os.getenv('AD_HOC_COMMAND_ID', ''): + event_dict['ad_hoc_command_id'] = int(os.getenv('AD_HOC_COMMAND_ID', '0')) + if os.getenv('PROJECT_UPDATE_ID', ''): + event_dict['project_update_id'] = int(os.getenv('PROJECT_UPDATE_ID', '0')) + event_dict['pid'] = event_data.get('pid', os.getpid()) + event_dict['uuid'] = event_data.get('uuid', str(uuid.uuid4())) + event_dict['created'] = event_data.get('created', datetime.datetime.utcnow().isoformat()) + if not event_data.get('parent_uuid', None): + for key in ('task_uuid', 'play_uuid', 'playbook_uuid'): + parent_uuid = event_data.get(key, None) + if parent_uuid and parent_uuid != event_data.get('uuid', None): + event_dict['parent_uuid'] = parent_uuid + break + else: + event_dict['parent_uuid'] = event_data.get('parent_uuid', None) + if "verbosity" in event_data.keys(): + event_dict["verbosity"] = event_data.pop("verbosity") + if not omit_event_data and should_process_event_data: + max_res = int(os.getenv("MAX_EVENT_RES", 700000)) + if event not in ('playbook_on_stats',) and "res" in event_data and len(str(event_data['res'])) > max_res: + event_data['res'] = {} + else: + event_data = dict() + event_dict['event_data'] = event_data + return event_dict + + def get_end_dict(self): + return {} + + def dump(self, fileobj, data, max_width=78, flush=False): + b64data = base64.b64encode(json.dumps(data).encode('utf-8')).decode() + with self.display_lock: + # pattern corresponding to OutputEventFilter expectation + fileobj.write(u'\x1b[K') + for offset in range(0, len(b64data), max_width): + chunk = b64data[offset:offset + max_width] + escaped_chunk = u'{}\x1b[{}D'.format(chunk, len(chunk)) + fileobj.write(escaped_chunk) + fileobj.write(u'\x1b[K') + if flush: + fileobj.flush() + + def dump_begin(self, fileobj): + begin_dict = self.get_begin_dict() + self.cache.set(":1:ev-{}".format(begin_dict['uuid']), begin_dict) + self.dump(fileobj, {'uuid': begin_dict['uuid']}) + + def dump_end(self, fileobj): + self.dump(fileobj, self.get_end_dict(), flush=True) + + +event_context = EventContext() + + +def with_context(**context): + global event_context + + def wrap(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + with event_context.set_local(**context): + return f(*args, **kwargs) + return wrapper + return wrap + + +for attr in dir(Display): + if attr.startswith('_') or 'cow' in attr or 'prompt' in attr: + continue + if attr in ('display', 'v', 'vv', 'vvv', 'vvvv', 'vvvvv', 'vvvvvv', 'verbose'): + continue + if not callable(getattr(Display, attr)): + continue + setattr(Display, attr, with_context(**{attr: True})(getattr(Display, attr))) + + +def with_verbosity(f): + global event_context + + @functools.wraps(f) + def wrapper(*args, **kwargs): + host = args[2] if len(args) >= 3 else kwargs.get('host', None) + caplevel = args[3] if len(args) >= 4 else kwargs.get('caplevel', 2) + context = dict(verbose=True, verbosity=(caplevel + 1)) + if host is not None: + context['remote_addr'] = host + with event_context.set_local(**context): + return f(*args, **kwargs) + return wrapper + + +Display.verbose = with_verbosity(Display.verbose) + + +def display_with_context(f): + + @functools.wraps(f) + def wrapper(*args, **kwargs): + log_only = args[5] if len(args) >= 6 else kwargs.get('log_only', False) + stderr = args[3] if len(args) >= 4 else kwargs.get('stderr', False) + event_uuid = event_context.get().get('uuid', None) + with event_context.display_lock: + # If writing only to a log file or there is already an event UUID + # set (from a callback module method), skip dumping the event data. + if log_only or event_uuid: + return f(*args, **kwargs) + try: + fileobj = sys.stderr if stderr else sys.stdout + event_context.add_local(uuid=str(uuid.uuid4())) + event_context.dump_begin(fileobj) + return f(*args, **kwargs) + finally: + event_context.dump_end(fileobj) + event_context.remove_local(uuid=None) + + return wrapper + + +Display.display = display_with_context(Display.display) + + +class CallbackModule(DefaultCallbackModule): ''' Callback module for logging ansible/ansible-playbook events. ''' + CALLBACK_NAME = 'awx_display' + CALLBACK_VERSION = 2.0 CALLBACK_TYPE = 'stdout' @@ -66,7 +340,7 @@ class BaseCallbackModule(CallbackBase): ] def __init__(self): - super(BaseCallbackModule, self).__init__() + super(CallbackModule, self).__init__() self._host_start = {} self.task_uuids = set() self.duplicate_task_counts = collections.defaultdict(lambda: 1) @@ -171,7 +445,7 @@ def v2_playbook_on_start(self, playbook): uuid=self.playbook_uuid, ) with self.capture_event_data('playbook_on_start', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_start(playbook) + super(CallbackModule, self).v2_playbook_on_start(playbook) def v2_playbook_on_vars_prompt(self, varname, private=True, prompt=None, encrypt=None, confirm=False, salt_size=None, @@ -188,7 +462,7 @@ def v2_playbook_on_vars_prompt(self, varname, private=True, prompt=None, unsafe=unsafe, ) with self.capture_event_data('playbook_on_vars_prompt', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_vars_prompt( + super(CallbackModule, self).v2_playbook_on_vars_prompt( varname, private, prompt, encrypt, confirm, salt_size, salt, default, ) @@ -198,9 +472,11 @@ def v2_playbook_on_include(self, included_file): included_file=included_file._filename if included_file is not None else None, ) with self.capture_event_data('playbook_on_include', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_include(included_file) + super(CallbackModule, self).v2_playbook_on_include(included_file) def v2_playbook_on_play_start(self, play): + if IS_ADHOC: + return play_uuid = str(play._uuid) if play_uuid in self.play_uuids: # When this play UUID repeats, it means the play is using the @@ -234,24 +510,27 @@ def v2_playbook_on_play_start(self, play): uuid=str(play._uuid), ) with self.capture_event_data('playbook_on_play_start', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_play_start(play) + super(CallbackModule, self).v2_playbook_on_play_start(play) def v2_playbook_on_import_for_host(self, result, imported_file): # NOTE: Not used by Ansible 2.x. with self.capture_event_data('playbook_on_import_for_host'): - super(BaseCallbackModule, self).v2_playbook_on_import_for_host(result, imported_file) + super(CallbackModule, self).v2_playbook_on_import_for_host(result, imported_file) def v2_playbook_on_not_import_for_host(self, result, missing_file): # NOTE: Not used by Ansible 2.x. with self.capture_event_data('playbook_on_not_import_for_host'): - super(BaseCallbackModule, self).v2_playbook_on_not_import_for_host(result, missing_file) + super(CallbackModule, self).v2_playbook_on_not_import_for_host(result, missing_file) def v2_playbook_on_setup(self): # NOTE: Not used by Ansible 2.x. with self.capture_event_data('playbook_on_setup'): - super(BaseCallbackModule, self).v2_playbook_on_setup() + super(CallbackModule, self).v2_playbook_on_setup() def v2_playbook_on_task_start(self, task, is_conditional): + if IS_ADHOC: + self.set_task(task) + return # FIXME: Flag task path output as vv. task_uuid = str(task._uuid) if task_uuid in self.task_uuids: @@ -277,7 +556,7 @@ def v2_playbook_on_task_start(self, task, is_conditional): uuid=task_uuid, ) with self.capture_event_data('playbook_on_task_start', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_task_start(task, is_conditional) + super(CallbackModule, self).v2_playbook_on_task_start(task, is_conditional) def v2_playbook_on_cleanup_task_start(self, task): # NOTE: Not used by Ansible 2.x. @@ -289,7 +568,7 @@ def v2_playbook_on_cleanup_task_start(self, task): is_conditional=True, ) with self.capture_event_data('playbook_on_task_start', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_cleanup_task_start(task) + super(CallbackModule, self).v2_playbook_on_cleanup_task_start(task) def v2_playbook_on_handler_task_start(self, task): # NOTE: Re-using playbook_on_task_start event for this v2-specific @@ -303,15 +582,15 @@ def v2_playbook_on_handler_task_start(self, task): is_conditional=True, ) with self.capture_event_data('playbook_on_task_start', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_handler_task_start(task) + super(CallbackModule, self).v2_playbook_on_handler_task_start(task) def v2_playbook_on_no_hosts_matched(self): with self.capture_event_data('playbook_on_no_hosts_matched'): - super(BaseCallbackModule, self).v2_playbook_on_no_hosts_matched() + super(CallbackModule, self).v2_playbook_on_no_hosts_matched() def v2_playbook_on_no_hosts_remaining(self): with self.capture_event_data('playbook_on_no_hosts_remaining'): - super(BaseCallbackModule, self).v2_playbook_on_no_hosts_remaining() + super(CallbackModule, self).v2_playbook_on_no_hosts_remaining() def v2_playbook_on_notify(self, handler, host): # NOTE: Not used by Ansible < 2.5. @@ -320,7 +599,7 @@ def v2_playbook_on_notify(self, handler, host): handler=handler.get_name(), ) with self.capture_event_data('playbook_on_notify', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_notify(handler, host) + super(CallbackModule, self).v2_playbook_on_notify(handler, host) ''' ansible_stats is, retoractively, added in 2.2 @@ -341,7 +620,7 @@ def v2_playbook_on_stats(self, stats): ) with self.capture_event_data('playbook_on_stats', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_stats(stats) + super(CallbackModule, self).v2_playbook_on_stats(stats) @staticmethod def _get_event_loop(task): @@ -378,7 +657,7 @@ def v2_runner_on_ok(self, result): event_loop=self._get_event_loop(result._task), ) with self.capture_event_data('runner_on_ok', **event_data): - super(BaseCallbackModule, self).v2_runner_on_ok(result) + super(CallbackModule, self).v2_runner_on_ok(result) def v2_runner_on_failed(self, result, ignore_errors=False): # FIXME: Add verbosity for exception/results output. @@ -395,7 +674,7 @@ def v2_runner_on_failed(self, result, ignore_errors=False): event_loop=self._get_event_loop(result._task), ) with self.capture_event_data('runner_on_failed', **event_data): - super(BaseCallbackModule, self).v2_runner_on_failed(result, ignore_errors) + super(CallbackModule, self).v2_runner_on_failed(result, ignore_errors) def v2_runner_on_skipped(self, result): host_start, end_time, duration = self._get_result_timing_data(result) @@ -409,7 +688,7 @@ def v2_runner_on_skipped(self, result): event_loop=self._get_event_loop(result._task), ) with self.capture_event_data('runner_on_skipped', **event_data): - super(BaseCallbackModule, self).v2_runner_on_skipped(result) + super(CallbackModule, self).v2_runner_on_skipped(result) def v2_runner_on_unreachable(self, result): host_start, end_time, duration = self._get_result_timing_data(result) @@ -423,7 +702,7 @@ def v2_runner_on_unreachable(self, result): res=result._result, ) with self.capture_event_data('runner_on_unreachable', **event_data): - super(BaseCallbackModule, self).v2_runner_on_unreachable(result) + super(CallbackModule, self).v2_runner_on_unreachable(result) def v2_runner_on_no_hosts(self, task): # NOTE: Not used by Ansible 2.x. @@ -431,7 +710,7 @@ def v2_runner_on_no_hosts(self, task): task=task, ) with self.capture_event_data('runner_on_no_hosts', **event_data): - super(BaseCallbackModule, self).v2_runner_on_no_hosts(task) + super(CallbackModule, self).v2_runner_on_no_hosts(task) def v2_runner_on_async_poll(self, result): # NOTE: Not used by Ansible 2.x. @@ -442,7 +721,7 @@ def v2_runner_on_async_poll(self, result): jid=result._result.get('ansible_job_id'), ) with self.capture_event_data('runner_on_async_poll', **event_data): - super(BaseCallbackModule, self).v2_runner_on_async_poll(result) + super(CallbackModule, self).v2_runner_on_async_poll(result) def v2_runner_on_async_ok(self, result): # NOTE: Not used by Ansible 2.x. @@ -453,7 +732,7 @@ def v2_runner_on_async_ok(self, result): jid=result._result.get('ansible_job_id'), ) with self.capture_event_data('runner_on_async_ok', **event_data): - super(BaseCallbackModule, self).v2_runner_on_async_ok(result) + super(CallbackModule, self).v2_runner_on_async_ok(result) def v2_runner_on_async_failed(self, result): # NOTE: Not used by Ansible 2.x. @@ -464,7 +743,7 @@ def v2_runner_on_async_failed(self, result): jid=result._result.get('ansible_job_id'), ) with self.capture_event_data('runner_on_async_failed', **event_data): - super(BaseCallbackModule, self).v2_runner_on_async_failed(result) + super(CallbackModule, self).v2_runner_on_async_failed(result) def v2_runner_on_file_diff(self, result, diff): # NOTE: Not used by Ansible 2.x. @@ -474,7 +753,7 @@ def v2_runner_on_file_diff(self, result, diff): diff=diff, ) with self.capture_event_data('runner_on_file_diff', **event_data): - super(BaseCallbackModule, self).v2_runner_on_file_diff(result, diff) + super(CallbackModule, self).v2_runner_on_file_diff(result, diff) def v2_on_file_diff(self, result): # NOTE: Logged as runner_on_file_diff. @@ -484,7 +763,7 @@ def v2_on_file_diff(self, result): diff=result._result.get('diff'), ) with self.capture_event_data('runner_on_file_diff', **event_data): - super(BaseCallbackModule, self).v2_on_file_diff(result) + super(CallbackModule, self).v2_on_file_diff(result) def v2_runner_item_on_ok(self, result): event_data = dict( @@ -493,7 +772,7 @@ def v2_runner_item_on_ok(self, result): res=result._result, ) with self.capture_event_data('runner_item_on_ok', **event_data): - super(BaseCallbackModule, self).v2_runner_item_on_ok(result) + super(CallbackModule, self).v2_runner_item_on_ok(result) def v2_runner_item_on_failed(self, result): event_data = dict( @@ -502,7 +781,7 @@ def v2_runner_item_on_failed(self, result): res=result._result, ) with self.capture_event_data('runner_item_on_failed', **event_data): - super(BaseCallbackModule, self).v2_runner_item_on_failed(result) + super(CallbackModule, self).v2_runner_item_on_failed(result) def v2_runner_item_on_skipped(self, result): event_data = dict( @@ -511,7 +790,7 @@ def v2_runner_item_on_skipped(self, result): res=result._result, ) with self.capture_event_data('runner_item_on_skipped', **event_data): - super(BaseCallbackModule, self).v2_runner_item_on_skipped(result) + super(CallbackModule, self).v2_runner_item_on_skipped(result) def v2_runner_retry(self, result): event_data = dict( @@ -520,7 +799,7 @@ def v2_runner_retry(self, result): res=result._result, ) with self.capture_event_data('runner_retry', **event_data): - super(BaseCallbackModule, self).v2_runner_retry(result) + super(CallbackModule, self).v2_runner_retry(result) def v2_runner_on_start(self, host, task): event_data = dict( @@ -529,20 +808,4 @@ def v2_runner_on_start(self, host, task): ) self._host_start[host.get_name()] = current_time() with self.capture_event_data('runner_on_start', **event_data): - super(BaseCallbackModule, self).v2_runner_on_start(host, task) - - -class AWXDefaultCallbackModule(BaseCallbackModule, DefaultCallbackModule): - - CALLBACK_NAME = 'awx_display' - - -class AWXMinimalCallbackModule(BaseCallbackModule, MinimalCallbackModule): - - CALLBACK_NAME = 'minimal' - - def v2_playbook_on_play_start(self, play): - pass - - def v2_playbook_on_task_start(self, task, is_conditional): - self.set_task(task) + super(CallbackModule, self).v2_runner_on_start(host, task) diff --git a/ansible_runner/display_callback/display.py b/ansible_runner/display_callback/display.py deleted file mode 100644 index ad5e8ba37..000000000 --- a/ansible_runner/display_callback/display.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright (c) 2016 Ansible by Red Hat, Inc. -# -# This file is part of Ansible Tower, but depends on code imported from Ansible. -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . - -from __future__ import (absolute_import, division, print_function) - -# Python -import functools -import sys -import uuid - -# Ansible -from ansible.utils.display import Display - -# Tower Display Callback -from .events import event_context - -__all__ = [] - - -def with_context(**context): - global event_context - - def wrap(f): - @functools.wraps(f) - def wrapper(*args, **kwargs): - with event_context.set_local(**context): - return f(*args, **kwargs) - return wrapper - return wrap - - -for attr in dir(Display): - if attr.startswith('_') or 'cow' in attr or 'prompt' in attr: - continue - if attr in ('display', 'v', 'vv', 'vvv', 'vvvv', 'vvvvv', 'vvvvvv', 'verbose'): - continue - if not callable(getattr(Display, attr)): - continue - setattr(Display, attr, with_context(**{attr: True})(getattr(Display, attr))) - - -def with_verbosity(f): - global event_context - - @functools.wraps(f) - def wrapper(*args, **kwargs): - host = args[2] if len(args) >= 3 else kwargs.get('host', None) - caplevel = args[3] if len(args) >= 4 else kwargs.get('caplevel', 2) - context = dict(verbose=True, verbosity=(caplevel + 1)) - if host is not None: - context['remote_addr'] = host - with event_context.set_local(**context): - return f(*args, **kwargs) - return wrapper - - -Display.verbose = with_verbosity(Display.verbose) - - -def display_with_context(f): - - @functools.wraps(f) - def wrapper(*args, **kwargs): - log_only = args[5] if len(args) >= 6 else kwargs.get('log_only', False) - stderr = args[3] if len(args) >= 4 else kwargs.get('stderr', False) - event_uuid = event_context.get().get('uuid', None) - with event_context.display_lock: - # If writing only to a log file or there is already an event UUID - # set (from a callback module method), skip dumping the event data. - if log_only or event_uuid: - return f(*args, **kwargs) - try: - fileobj = sys.stderr if stderr else sys.stdout - event_context.add_local(uuid=str(uuid.uuid4())) - event_context.dump_begin(fileobj) - return f(*args, **kwargs) - finally: - event_context.dump_end(fileobj) - event_context.remove_local(uuid=None) - - return wrapper - - -Display.display = display_with_context(Display.display) diff --git a/ansible_runner/display_callback/events.py b/ansible_runner/display_callback/events.py deleted file mode 100644 index f3e067998..000000000 --- a/ansible_runner/display_callback/events.py +++ /dev/null @@ -1,203 +0,0 @@ -# Copyright (c) 2016 Ansible by Red Hat, Inc. -# -# This file is part of Ansible Tower, but depends on code imported from Ansible. -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . - -from __future__ import (absolute_import, division, print_function) - -# Python -import base64 -import contextlib -import datetime -import json -import multiprocessing -import os -import stat -import threading -import uuid - -__all__ = ['event_context'] - - -# use a custom JSON serializer so we can properly handle !unsafe and !vault -# objects that may exist in events emitted by the callback plugin -# see: https://github.com/ansible/ansible/pull/38759 -class AnsibleJSONEncoderLocal(json.JSONEncoder): - ''' - The class AnsibleJSONEncoder exists in Ansible core for this function - this performs a mostly identical function via duck typing - ''' - - def default(self, o): - if getattr(o, 'yaml_tag', None) == '!vault': - encrypted_form = o._ciphertext - if isinstance(encrypted_form, bytes): - encrypted_form = encrypted_form.decode('utf-8') - return {'__ansible_vault': encrypted_form} - elif isinstance(o, (datetime.date, datetime.datetime)): - return o.isoformat() - return super(AnsibleJSONEncoderLocal, self).default(o) - - -class IsolatedFileWrite: - ''' - Class that will write partial event data to a file - ''' - - def __init__(self): - self.private_data_dir = os.getenv('AWX_ISOLATED_DATA_DIR') - - def set(self, key, value): - # Strip off the leading key identifying characters :1:ev- - event_uuid = key[len(':1:ev-'):] - # Write data in a staging area and then atomic move to pickup directory - filename = '{}-partial.json'.format(event_uuid) - if not os.path.exists(os.path.join(self.private_data_dir, 'job_events')): - os.mkdir(os.path.join(self.private_data_dir, 'job_events'), 0o700) - dropoff_location = os.path.join(self.private_data_dir, 'job_events', filename) - write_location = '.'.join([dropoff_location, 'tmp']) - partial_data = json.dumps(value, cls=AnsibleJSONEncoderLocal) - with os.fdopen(os.open(write_location, os.O_WRONLY | os.O_CREAT, stat.S_IRUSR | stat.S_IWUSR), 'w') as f: - f.write(partial_data) - os.rename(write_location, dropoff_location) - - -class EventContext(object): - ''' - Store global and local (per thread/process) data associated with callback - events and other display output methods. - ''' - - def __init__(self): - self.display_lock = multiprocessing.RLock() - self._local = threading.local() - if os.getenv('AWX_ISOLATED_DATA_DIR', False): - self.cache = IsolatedFileWrite() - - def add_local(self, **kwargs): - tls = vars(self._local) - ctx = tls.setdefault('_ctx', {}) - ctx.update(kwargs) - - def remove_local(self, **kwargs): - for key in kwargs.keys(): - self._local._ctx.pop(key, None) - - @contextlib.contextmanager - def set_local(self, **kwargs): - try: - self.add_local(**kwargs) - yield - finally: - self.remove_local(**kwargs) - - def get_local(self): - return getattr(getattr(self, '_local', None), '_ctx', {}) - - def add_global(self, **kwargs): - if not hasattr(self, '_global_ctx'): - self._global_ctx = {} - self._global_ctx.update(kwargs) - - def remove_global(self, **kwargs): - if hasattr(self, '_global_ctx'): - for key in kwargs.keys(): - self._global_ctx.pop(key, None) - - @contextlib.contextmanager - def set_global(self, **kwargs): - try: - self.add_global(**kwargs) - yield - finally: - self.remove_global(**kwargs) - - def get_global(self): - return getattr(self, '_global_ctx', {}) - - def get(self): - ctx = {} - ctx.update(self.get_global()) - ctx.update(self.get_local()) - return ctx - - def get_begin_dict(self): - omit_event_data = os.getenv("RUNNER_OMIT_EVENTS", "False").lower() == "true" - include_only_failed_event_data = os.getenv("RUNNER_ONLY_FAILED_EVENTS", "False").lower() == "true" - event_data = self.get() - event = event_data.pop('event', None) - if not event: - event = 'verbose' - for key in ('debug', 'verbose', 'deprecated', 'warning', 'system_warning', 'error'): - if event_data.get(key, False): - event = key - break - event_dict = dict(event=event) - should_process_event_data = (include_only_failed_event_data and event in ('runner_on_failed', 'runner_on_async_failed', 'runner_on_item_failed')) \ - or not include_only_failed_event_data - if os.getenv('JOB_ID', ''): - event_dict['job_id'] = int(os.getenv('JOB_ID', '0')) - if os.getenv('AD_HOC_COMMAND_ID', ''): - event_dict['ad_hoc_command_id'] = int(os.getenv('AD_HOC_COMMAND_ID', '0')) - if os.getenv('PROJECT_UPDATE_ID', ''): - event_dict['project_update_id'] = int(os.getenv('PROJECT_UPDATE_ID', '0')) - event_dict['pid'] = event_data.get('pid', os.getpid()) - event_dict['uuid'] = event_data.get('uuid', str(uuid.uuid4())) - event_dict['created'] = event_data.get('created', datetime.datetime.utcnow().isoformat()) - if not event_data.get('parent_uuid', None): - for key in ('task_uuid', 'play_uuid', 'playbook_uuid'): - parent_uuid = event_data.get(key, None) - if parent_uuid and parent_uuid != event_data.get('uuid', None): - event_dict['parent_uuid'] = parent_uuid - break - else: - event_dict['parent_uuid'] = event_data.get('parent_uuid', None) - if "verbosity" in event_data.keys(): - event_dict["verbosity"] = event_data.pop("verbosity") - if not omit_event_data and should_process_event_data: - max_res = int(os.getenv("MAX_EVENT_RES", 700000)) - if event not in ('playbook_on_stats',) and "res" in event_data and len(str(event_data['res'])) > max_res: - event_data['res'] = {} - else: - event_data = dict() - event_dict['event_data'] = event_data - return event_dict - - def get_end_dict(self): - return {} - - def dump(self, fileobj, data, max_width=78, flush=False): - b64data = base64.b64encode(json.dumps(data).encode('utf-8')).decode() - with self.display_lock: - # pattern corresponding to OutputEventFilter expectation - fileobj.write(u'\x1b[K') - for offset in range(0, len(b64data), max_width): - chunk = b64data[offset:offset + max_width] - escaped_chunk = u'{}\x1b[{}D'.format(chunk, len(chunk)) - fileobj.write(escaped_chunk) - fileobj.write(u'\x1b[K') - if flush: - fileobj.flush() - - def dump_begin(self, fileobj): - begin_dict = self.get_begin_dict() - self.cache.set(":1:ev-{}".format(begin_dict['uuid']), begin_dict) - self.dump(fileobj, {'uuid': begin_dict['uuid']}) - - def dump_end(self, fileobj): - self.dump(fileobj, self.get_end_dict(), flush=True) - - -event_context = EventContext() diff --git a/ansible_runner/display_callback/minimal.py b/ansible_runner/display_callback/minimal.py deleted file mode 100644 index 98076ba27..000000000 --- a/ansible_runner/display_callback/minimal.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) 2016 Ansible by Red Hat, Inc. -# -# This file is part of Ansible Tower, but depends on code imported from Ansible. -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . - -from __future__ import (absolute_import, division, print_function) - -# Python -import os - -# Ansible -import ansible - -# Because of the way Ansible loads plugins, it's not possible to import -# ansible.plugins.callback.minimal when being loaded as the minimal plugin. Ugh. -minimal_plugin = os.path.join(os.path.dirname(ansible.__file__), 'plugins', 'callback', 'minimal.py') -exec(compile(open(minimal_plugin, "rb").read(), minimal_plugin, 'exec')) diff --git a/ansible_runner/utils/__init__.py b/ansible_runner/utils/__init__.py index 84b2b2a70..251ea5d3b 100644 --- a/ansible_runner/utils/__init__.py +++ b/ansible_runner/utils/__init__.py @@ -11,6 +11,8 @@ import subprocess import base64 import threading +from pathlib import Path +import pwd import pipes import uuid import codecs @@ -46,6 +48,42 @@ def register_for_cleanup(folder): atexit.register(cleanup_folder, folder) +def get_plugin_dir(): + return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "display_callback")) + + +def get_callback_dir(): + return os.path.join(get_plugin_dir(), 'callback') + + +def is_dir_owner(directory): + '''Returns True if current user is the owner of directory''' + current_user = pwd.getpwuid(os.geteuid()).pw_name + callback_owner = Path(directory).owner() + return bool(current_user == callback_owner) + + +def callback_mount(copy_if_needed=False): + ''' + Return a tuple that gives mount points for the standard out callback + in the form of (, ) + if copy_if_needed is set, and the install is owned by another user, + it will copy the plugin to a tmpdir for the mount in anticipation of SELinux problems + ''' + container_dot_ansible = '/home/runner/.ansible' + rel_path = ('callback', '',) + host_path = os.path.join(get_plugin_dir(), *rel_path) + if copy_if_needed: + callback_dir = get_callback_dir() + if not is_dir_owner(callback_dir): + tmp_path = tempfile.mkdtemp(prefix='ansible_runner_plugins_') + register_for_cleanup(tmp_path) + host_path = os.path.join(tmp_path, 'callback') + shutil.copytree(callback_dir, host_path) + container_path = os.path.join(container_dot_ansible, 'plugins', *rel_path) + return (host_path, container_path) + + class Bunch(object): ''' diff --git a/docs/ansible_runner.callbacks.rst b/docs/ansible_runner.callbacks.rst deleted file mode 100644 index 4a50bd48b..000000000 --- a/docs/ansible_runner.callbacks.rst +++ /dev/null @@ -1,19 +0,0 @@ -ansible_runner.callbacks package -================================ - -Submodules ----------- - -ansible_runner.callbacks.awx_display module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. automodule:: ansible_runner.callbacks.awx_display - :members: - :undoc-members: - -ansible_runner.callbacks.minimal module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. automodule:: ansible_runner.callbacks.minimal - :members: - :undoc-members: diff --git a/docs/ansible_runner.display_callback.rst b/docs/ansible_runner.display_callback.rst index 4ad24c802..835ecf2a0 100644 --- a/docs/ansible_runner.display_callback.rst +++ b/docs/ansible_runner.display_callback.rst @@ -4,33 +4,10 @@ ansible_runner.display_callback package Submodules ---------- -ansible_runner.display_callback.display module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +ansible_runner.display_callback.callback.awx_display module +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. automodule:: ansible_runner.display_callback.display - :members: - :undoc-members: - :show-inheritance: - -ansible_runner.display_callback.events module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. automodule:: ansible_runner.display_callback.events - :members: - :undoc-members: - :show-inheritance: - -ansible_runner.display_callback.minimal module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. automodule:: ansible_runner.display_callback.minimal - :members: - :undoc-members: - -ansible_runner.display_callback.module module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. automodule:: ansible_runner.display_callback.module +.. automodule:: ansible_runner.display_callback.callback.awx_display :members: :undoc-members: diff --git a/docs/ansible_runner.rst b/docs/ansible_runner.rst index baf6391aa..cec321ae1 100644 --- a/docs/ansible_runner.rst +++ b/docs/ansible_runner.rst @@ -6,7 +6,6 @@ Subpackages .. toctree:: - ansible_runner.callbacks ansible_runner.config ansible_runner.display_callback diff --git a/setup.cfg b/setup.cfg index 09ba02e7d..a1a7cca53 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,3 +22,5 @@ data-files = # W503 - Line break occurred before a binary operator ignore=W503 max-line-length=160 +per-file-ignores = + ansible_runner/display_callback/callback/awx_display.py:E402 diff --git a/test/integration/containerized/test_container_management.py b/test/integration/containerized/test_container_management.py index c2a6316a6..8d71a972a 100644 --- a/test/integration/containerized/test_container_management.py +++ b/test/integration/containerized/test_container_management.py @@ -75,6 +75,25 @@ def test_cancel_will_remove_container(project_fixtures, runtime, cli): ), 'Found a running container, they should have all been stopped' +@pytest.mark.test_all_runtimes +def test_non_owner_install(mocker, project_fixtures, runtime): + """Simulates a run on a conputer where ansible-runner install is not owned by current user""" + mocker.patch('ansible_runner.utils.is_dir_owner', return_value=False) + + private_data_dir = project_fixtures / 'debug' + res = run( + private_data_dir=private_data_dir, + playbook='debug.yml', + settings={ + 'process_isolation_executable': runtime, + 'process_isolation': True + } + ) + stdout = res.stdout.read() + assert res.rc == 0, stdout + assert res.status == 'successful' + + @pytest.mark.test_all_runtimes def test_invalid_registry_host(tmp_path, runtime): pdd_path = tmp_path / 'private_data_dir' diff --git a/test/integration/test_config.py b/test/integration/test_config.py index b79eab16f..855014099 100644 --- a/test/integration/test_config.py +++ b/test/integration/test_config.py @@ -1,7 +1,39 @@ from ansible_runner.config._base import BaseConfig +from ansible_runner.interface import run + +import os def test_combine_python_and_file_settings(project_fixtures): rc = BaseConfig(private_data_dir=str(project_fixtures / 'job_env'), settings={'job_timeout': 40}) rc._prepare_env() assert rc.settings == {'job_timeout': 40, 'process_isolation': True} + + +def test_default_ansible_callback(project_fixtures): + """This is the reference case for stdout customization tests, assures default stdout callback is used""" + res = run(private_data_dir=str(project_fixtures / 'debug'), playbook='debug.yml') + stdout = res.stdout.read() + assert res.rc == 0, stdout + + assert 'ok: [host_1] => {' in stdout, stdout + assert '"msg": "Hello world!"' in stdout, stdout + + +def test_custom_stdout_callback_via_host_environ(project_fixtures, mocker): + mocker.patch.dict(os.environ, {'ANSIBLE_STDOUT_CALLBACK': 'minimal'}) + res = run(private_data_dir=str(project_fixtures / 'debug'), playbook='debug.yml') + stdout = res.stdout.read() + assert res.rc == 0, stdout + + assert 'host_1 | SUCCESS => {' in stdout, stdout + assert '"msg": "Hello world!"' in stdout, stdout + + +def test_custom_stdout_callback_via_envvars(project_fixtures, mocker): + res = run(private_data_dir=str(project_fixtures / 'debug'), playbook='debug.yml', envvars={'ANSIBLE_STDOUT_CALLBACK': 'minimal'}) + stdout = res.stdout.read() + assert res.rc == 0, stdout + + assert 'host_1 | SUCCESS => {' in stdout, stdout + assert '"msg": "Hello world!"' in stdout, stdout diff --git a/test/integration/test_runner.py b/test/integration/test_runner.py index 627936c55..c3ccfa656 100644 --- a/test/integration/test_runner.py +++ b/test/integration/test_runner.py @@ -188,7 +188,7 @@ def test_run_command_ansible(rc): assert exitcode == 0 assert list(runner.events) != [] assert runner.stats != {} - assert list(runner.host_events('localhost')) != [] + assert list(runner.host_events('localhost')) != [], repr(list(runner.events)) stdout = runner.stdout assert stdout.read() != "" diff --git a/test/unit/config/test__base.py b/test/unit/config/test__base.py index 04f3d86ee..5c2c8c407 100644 --- a/test/unit/config/test__base.py +++ b/test/unit/config/test__base.py @@ -13,6 +13,7 @@ from ansible_runner.config._base import BaseConfig, BaseExecutionMode from ansible_runner.loader import ArtifactLoader from ansible_runner.exceptions import ConfigurationError +from ansible_runner.utils import callback_mount try: Pattern = re._pattern_type @@ -176,7 +177,6 @@ def test_prepare_env_defaults(): def test_prepare_env_ansible_vars(mocker, tmp_path): mocker.patch.dict('os.environ', { - 'PYTHONPATH': '/python_path_via_environ', 'AWX_LIB_DIRECTORY': '/awx_lib_directory_via_environ', }) @@ -195,14 +195,6 @@ def test_prepare_env_ansible_vars(mocker, tmp_path): assert rc.env['ANSIBLE_RETRY_FILES_ENABLED'] == 'False' assert rc.env['ANSIBLE_HOST_KEY_CHECKING'] == 'False' assert rc.env['AWX_ISOLATED_DATA_DIR'] == artifact_dir.joinpath(rc.ident).as_posix() - assert rc.env['PYTHONPATH'] == '/python_path_via_environ:/awx_lib_directory_via_environ', \ - "PYTHONPATH is the union of the env PYTHONPATH and AWX_LIB_DIRECTORY" - - del rc.env['PYTHONPATH'] - os.environ['PYTHONPATH'] = "/foo/bar/python_path_via_environ" - rc._prepare_env() - assert rc.env['PYTHONPATH'] == "/foo/bar/python_path_via_environ:/awx_lib_directory_via_environ", \ - "PYTHONPATH is the union of the explicit env['PYTHONPATH'] override and AWX_LIB_DIRECTORY" def test_prepare_with_ssh_key(mocker, tmp_path): @@ -325,6 +317,7 @@ def test_containerization_settings(tmp_path, runtime, mocker): expected_command_start.extend([ '-v', '{}/artifacts/:/runner/artifacts/:Z'.format(rc.private_data_dir), '-v', '{}/:/runner/:Z'.format(rc.private_data_dir), + '-v', '{0}:{1}:Z'.format(*callback_mount()), '--env-file', '{}/env.list'.format(rc.artifact_dir), ]) diff --git a/test/unit/config/test_ansible_cfg.py b/test/unit/config/test_ansible_cfg.py index ecc96bea7..da7e7e0cb 100644 --- a/test/unit/config/test_ansible_cfg.py +++ b/test/unit/config/test_ansible_cfg.py @@ -6,7 +6,7 @@ from ansible_runner.config.ansible_cfg import AnsibleCfgConfig from ansible_runner.config._base import BaseExecutionMode from ansible_runner.exceptions import ConfigurationError -from ansible_runner.utils import get_executable_path +from ansible_runner.utils import get_executable_path, callback_mount def test_ansible_cfg_init_defaults(tmp_path, patch_private_data_dir): @@ -91,6 +91,7 @@ def test_prepare_config_command_with_containerization(tmp_path, runtime, mocker) expected_command_start.extend([ '-v', '{}/artifacts/:/runner/artifacts/:Z'.format(rc.private_data_dir), '-v', '{}/:/runner/:Z'.format(rc.private_data_dir), + '-v', '{0}:{1}:Z'.format(*callback_mount()), '--env-file', '{}/env.list'.format(rc.artifact_dir), ]) diff --git a/test/unit/config/test_command.py b/test/unit/config/test_command.py index 75bc2db64..5c2071ea6 100644 --- a/test/unit/config/test_command.py +++ b/test/unit/config/test_command.py @@ -6,6 +6,7 @@ from ansible_runner.config.command import CommandConfig from ansible_runner.config._base import BaseExecutionMode from ansible_runner.exceptions import ConfigurationError +from ansible_runner.utils import callback_mount def test_ansible_config_defaults(tmp_path, patch_private_data_dir): @@ -105,6 +106,7 @@ def test_prepare_run_command_with_containerization(tmp_path, runtime, mocker): expected_command_start.extend([ '-v', '{}/artifacts/:/runner/artifacts/:Z'.format(rc.private_data_dir), '-v', '{}/:/runner/:Z'.format(rc.private_data_dir), + '-v', '{0}:{1}:Z'.format(*callback_mount()), '--env-file', '{}/env.list'.format(rc.artifact_dir), ]) diff --git a/test/unit/config/test_doc.py b/test/unit/config/test_doc.py index 1058dac19..a1dfd24f1 100755 --- a/test/unit/config/test_doc.py +++ b/test/unit/config/test_doc.py @@ -6,7 +6,7 @@ from ansible_runner.config.doc import DocConfig from ansible_runner.config._base import BaseExecutionMode from ansible_runner.exceptions import ConfigurationError -from ansible_runner.utils import get_executable_path +from ansible_runner.utils import get_executable_path, callback_mount def test_ansible_doc_defaults(tmp_path, patch_private_data_dir): @@ -101,6 +101,7 @@ def test_prepare_plugin_docs_command_with_containerization(tmp_path, runtime, mo expected_command_start.extend([ '-v', '{}/artifacts/:/runner/artifacts/:Z'.format(rc.private_data_dir), '-v', '{}/:/runner/:Z'.format(rc.private_data_dir), + '-v', '{0}:{1}:Z'.format(*callback_mount()), '--env-file', '{}/env.list'.format(rc.artifact_dir), ]) @@ -169,6 +170,7 @@ def test_prepare_plugin_list_command_with_containerization(tmp_path, runtime, mo expected_command_start.extend([ '-v', '{}/artifacts/:/runner/artifacts/:Z'.format(rc.private_data_dir), '-v', '{}/:/runner/:Z'.format(rc.private_data_dir), + '-v', '{0}:{1}:Z'.format(*callback_mount()), '--env-file', '{}/env.list'.format(rc.artifact_dir), ]) diff --git a/test/unit/config/test_inventory.py b/test/unit/config/test_inventory.py index 65fbe5d34..dd81027f9 100644 --- a/test/unit/config/test_inventory.py +++ b/test/unit/config/test_inventory.py @@ -6,7 +6,7 @@ from ansible_runner.config.inventory import InventoryConfig from ansible_runner.config._base import BaseExecutionMode from ansible_runner.exceptions import ConfigurationError -from ansible_runner.utils import get_executable_path +from ansible_runner.utils import get_executable_path, callback_mount def test_ansible_inventory_init_defaults(tmp_path, patch_private_data_dir): @@ -126,6 +126,7 @@ def test_prepare_inventory_command_with_containerization(tmp_path, runtime, mock expected_command_start.extend([ '-v', '{}/artifacts/:/runner/artifacts/:Z'.format(rc.private_data_dir), '-v', '{}/:/runner/:Z'.format(rc.private_data_dir), + '-v', '{0}:{1}:Z'.format(*callback_mount()), '--env-file', '{}/env.list'.format(rc.artifact_dir), ]) diff --git a/test/unit/config/test_runner.py b/test/unit/config/test_runner.py index 2940712fa..725a7fde5 100644 --- a/test/unit/config/test_runner.py +++ b/test/unit/config/test_runner.py @@ -14,6 +14,7 @@ from ansible_runner.interface import init_runner from ansible_runner.loader import ArtifactLoader from ansible_runner.exceptions import ConfigurationError +from ansible_runner.utils import callback_mount try: Pattern = re._pattern_type @@ -405,7 +406,6 @@ def test_prepare_with_defaults(mocker): def test_prepare(mocker): mocker.patch.dict('os.environ', { - 'PYTHONPATH': '/python_path_via_environ', 'AWX_LIB_DIRECTORY': '/awx_lib_directory_via_environ', }) mocker.patch('os.makedirs', return_value=True) @@ -431,14 +431,6 @@ def test_prepare(mocker): assert rc.env['ANSIBLE_RETRY_FILES_ENABLED'] == 'False' assert rc.env['ANSIBLE_HOST_KEY_CHECKING'] == 'False' assert rc.env['AWX_ISOLATED_DATA_DIR'] == '/' - assert rc.env['PYTHONPATH'] == '/python_path_via_environ:/awx_lib_directory_via_environ', \ - "PYTHONPATH is the union of the env PYTHONPATH and AWX_LIB_DIRECTORY" - - del rc.env['PYTHONPATH'] - os.environ['PYTHONPATH'] = "/foo/bar/python_path_via_environ" - rc.prepare() - assert rc.env['PYTHONPATH'] == "/foo/bar/python_path_via_environ:/awx_lib_directory_via_environ", \ - "PYTHONPATH is the union of the explicit env['PYTHONPATH'] override and AWX_LIB_DIRECTORY" def test_prepare_with_ssh_key(mocker): @@ -740,6 +732,7 @@ def test_containerization_settings(tmp_path, runtime, mocker): expected_command_start = [runtime, 'run', '--rm', '--tty', '--interactive', '--workdir', '/runner/project'] + \ ['-v', '{}/:/runner/:Z'.format(rc.private_data_dir)] + \ + ['-v', '{0}:{1}:Z'.format(*callback_mount())] + \ ['-v', '/host1/:/container1/', '-v', '/host2/:/container2/'] + \ ['--env-file', '{}/env.list'.format(rc.artifact_dir)] + \ extra_container_args + \ diff --git a/utils/entrypoint.sh b/utils/entrypoint.sh index 2fb2bdcff..4cbf25dab 100755 --- a/utils/entrypoint.sh +++ b/utils/entrypoint.sh @@ -24,14 +24,6 @@ EOF fi -if [[ -n "${LAUNCHED_BY_RUNNER}" ]]; then - RUNNER_CALLBACKS=$(python3 -c "import ansible_runner.callbacks; print(ansible_runner.callbacks.__file__)") - - # TODO: respect user callback settings via - # env ANSIBLE_CALLBACK_PLUGINS or ansible.cfg - export ANSIBLE_CALLBACK_PLUGINS="$(dirname $RUNNER_CALLBACKS)" -fi - if [[ -d ${AWX_ISOLATED_DATA_DIR} ]]; then if output=$(ansible-galaxy collection list --format json 2> /dev/null); then echo $output > ${AWX_ISOLATED_DATA_DIR}/collections.json