From 53ee6a4a527c69eb7eb33be49fbb287f3ec56665 Mon Sep 17 00:00:00 2001 From: Djebran Lezzoum Date: Tue, 27 Oct 2026 15:11:33 +0100 Subject: [PATCH] [AAP-76923] Honor AWX_TASK_ENV on job pods and ansible-runner event flags (#16720) * [AAP-76923] Honor AWX_TASK_ENV on job pods and ansible-runner event flags Jobs Extra Environment Variables already reached playbooks via envvars, but automation-job pods did not list them, and ansible-runner overwrote RUNNER_OMIT_EVENTS unless omit_event_data was set on the runner params. Issue: https://redhat.atlassian.net/browse/AAP-76923 Co-authored-by: Cursor * [AAP-76923] Keep project-update event metadata when omit flags are set Project updates still need task_action and scm_version for SCM credential redaction and revision tracking, so BaseTask.run leaves both runner omission kwargs off for ProjectUpdate even when AWX_TASK_ENV enables them. Issue: https://redhat.atlassian.net/browse/AAP-76923 Co-authored-by: Cursor --------- Co-authored-by: Cursor --- awx/main/tasks/jobs.py | 14 ++ awx/main/tasks/receptor.py | 40 +++- .../task_management/test_container_groups.py | 220 +++++++++++++++++- .../tests/unit/tasks/test_runner_callback.py | 69 +++++- awx/main/tests/unit/test_tasks.py | 159 +++++++++++++ 5 files changed, 494 insertions(+), 8 deletions(-) diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index ee0e12f04e..7edbe261d0 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -241,6 +241,15 @@ def _finalize_job_run(model, pk, runner_callback, status, extra_fields=None): return instance +def env_flag_enabled(env, name): + """Return True when env maps name to a common truthy string. + + Missing and unrecognized values are False (does not raise). Truthy spellings + match distutils.util.strtobool: 1, true, yes, on, y, t. + """ + return str(env.get(name, '')).strip().lower() in ('1', 'true', 'yes', 'on', 'y', 't') + + class BaseTask(object): model = None event_model = None @@ -753,6 +762,11 @@ class BaseTask(object): 'passwords': expect_passwords, 'suppress_env_files': getattr(settings, 'AWX_RUNNER_OMIT_ENV_FILES', True), 'envvars': env, + # ansible-runner overwrites RUNNER_OMIT_EVENTS / RUNNER_ONLY_FAILED_EVENTS from + # these kwargs after merging envvars, so AWX_TASK_ENV flags only take effect here. + # Project updates need event metadata for SCM credential redaction and revision tracking. + 'omit_event_data': (not isinstance(self.instance, ProjectUpdate) and env_flag_enabled(env, 'RUNNER_OMIT_EVENTS')), + 'only_failed_event_data': (not isinstance(self.instance, ProjectUpdate) and env_flag_enabled(env, 'RUNNER_ONLY_FAILED_EVENTS')), } if ssh_key_data is not None: diff --git a/awx/main/tasks/receptor.py b/awx/main/tasks/receptor.py index 168e678a8f..32c871d339 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -53,6 +53,28 @@ __RECEPTOR_CONF_LOCKFILE = f'{__RECEPTOR_CONF}.lock' RECEPTOR_ACTIVE_STATES = ('Pending', 'Running') +def upsert_pod_container_env(container, name, value): + """Set a container env var by name, replacing any existing entry with that name. + + A matching valueFrom entry is replaced with a literal value so the object is + not invalid. Escapes `$` as `$$` so kubelet does not expand dependent env vars, + matching the subprocess envvars path. All matching names are removed so a later + duplicate in pod_spec_override cannot win; the replacement stays at the first + match index so kubelet $(VAR) ordering of other entries is preserved. + """ + env_list = container.get('env') + if not isinstance(env_list, list): + env_list = [] + container['env'] = env_list + entry = {'name': name, 'value': str(value).replace('$', '$$')} + matches = [index for index, existing in enumerate(env_list) if isinstance(existing, dict) and existing.get('name') == name] + if any('valueFrom' in env_list[index] for index in matches): + logger.warning('Replacing valueFrom env entry %s with a literal value on the job pod spec', name) + for index in reversed(matches): + del env_list[index] + env_list.insert(matches[0] if matches else len(env_list), entry) + + class ReceptorConnectionType(Enum): DATAGRAM = 0 STREAM = 1 @@ -689,6 +711,7 @@ class AWXReceptorJob: @property def pod_definition(self): + """Return the worker pod spec, with AWX_TASK_ENV applied on the container.""" ee = self.task.instance.execution_environment default_pod_spec = get_default_pod_spec() @@ -702,18 +725,23 @@ class AWXReceptorJob: # defaults they don't want to change pod_spec = deepmerge(default_pod_spec, pod_spec_override) - pod_spec['spec']['containers'][0]['image'] = ee.image - pod_spec['spec']['containers'][0]['args'] = ['ansible-runner', 'worker', '--private-data-dir=/runner'] - + # AWX_TASK_ENV already rides in runner envvars for the playbook. Set it on the + # worker container too so kubelet / os.environ see it (proxies, visibility). + # After pod_spec_override so Jobs extra env wins on name collision; keepalive last + # so AWX_RUNNER_KEEPALIVE_SECONDS still wins. + container = pod_spec['spec']['containers'][0] + container['image'] = ee.image + container['args'] = ['ansible-runner', 'worker', '--private-data-dir=/runner'] + for key, value in settings.AWX_TASK_ENV.items(): + upsert_pod_container_env(container, key, value) if settings.AWX_RUNNER_KEEPALIVE_SECONDS: - pod_spec['spec']['containers'][0].setdefault('env', []) - pod_spec['spec']['containers'][0]['env'].append({'name': 'ANSIBLE_RUNNER_KEEPALIVE_SECONDS', 'value': str(settings.AWX_RUNNER_KEEPALIVE_SECONDS)}) + upsert_pod_container_env(container, 'ANSIBLE_RUNNER_KEEPALIVE_SECONDS', settings.AWX_RUNNER_KEEPALIVE_SECONDS) # Enforce EE Pull Policy pull_options = {"always": "Always", "missing": "IfNotPresent", "never": "Never"} if self.task and self.task.instance.execution_environment: if self.task.instance.execution_environment.pull: - pod_spec['spec']['containers'][0]['imagePullPolicy'] = pull_options[self.task.instance.execution_environment.pull] + container['imagePullPolicy'] = pull_options[self.task.instance.execution_environment.pull] # This allows the user to also expose the isolated path list # to EEs running in k8s/ocp environments, i.e. container groups. diff --git a/awx/main/tests/functional/task_management/test_container_groups.py b/awx/main/tests/functional/task_management/test_container_groups.py index 360e8e2df2..1ef23efcac 100644 --- a/awx/main/tests/functional/task_management/test_container_groups.py +++ b/awx/main/tests/functional/task_management/test_container_groups.py @@ -1,11 +1,12 @@ import subprocess import base64 +import json from collections import namedtuple from unittest import mock # noqa import pytest -from awx.main.tasks.receptor import AWXReceptorJob +from awx.main.tasks.receptor import AWXReceptorJob, upsert_pod_container_env from awx.main.utils import ( create_temporary_fifo, ) @@ -101,3 +102,220 @@ def test_kubectl_ssl_verification(containerized_job, default_job_execution_envir receptor_job = AWXReceptorJob(rj, runner_params={'settings': {}}) ca_data = receptor_job.kube_config['clusters'][0]['cluster']['certificate-authority-data'] assert cert.stdout == base64.b64decode(ca_data.encode()) + + +def _pod_env_map(pod_spec): + """Map worker-container env names to literal values (valueFrom entries map to None).""" + return {item['name']: item.get('value') for item in pod_spec['spec']['containers'][0].get('env', [])} + + +def _receptor_job_for_containerized(containerized_job): + """Build an AWXReceptorJob around a container-group job with empty runner settings.""" + RunJob = namedtuple('RunJob', ['instance', 'build_execution_environment_params']) + rj = RunJob(instance=containerized_job, build_execution_environment_params=lambda x: {}) + return AWXReceptorJob(rj, runner_params={'settings': {}}) + + +def test_upsert_pod_container_env_appends_and_replaces(): + """New names are appended; a second write of the same name replaces in the original position.""" + container = {} + upsert_pod_container_env(container, 'HTTP_PROXY', 'http://first:3128') + upsert_pod_container_env(container, 'HTTPS_PROXY', 1) + upsert_pod_container_env(container, 'HTTP_PROXY', 'http://second:3128') + assert container['env'] == [ + {'name': 'HTTP_PROXY', 'value': 'http://second:3128'}, + {'name': 'HTTPS_PROXY', 'value': '1'}, + ] + + +def test_upsert_pod_container_env_removes_duplicate_names(): + """Matching names are collapsed to one entry at the first-match index.""" + container = { + 'env': [ + {'name': 'HTTP_PROXY', 'value': 'http://first:3128'}, + {'name': 'KEEP_ME', 'value': '1'}, + {'name': 'HTTP_PROXY', 'value': 'http://second:3128'}, + ] + } + upsert_pod_container_env(container, 'HTTP_PROXY', 'http://global-proxy:3128') + assert container['env'] == [ + {'name': 'HTTP_PROXY', 'value': 'http://global-proxy:3128'}, + {'name': 'KEEP_ME', 'value': '1'}, + ] + + +def test_upsert_pod_container_env_normalizes_non_list_env(): + """env that is missing, null, or not a list is replaced with a list so upsert can proceed.""" + containers = [{}, {'env': None}, {'env': {'HTTP_PROXY': 'nope'}}] + for container in containers: + upsert_pod_container_env(container, 'HTTP_PROXY', 'http://p:3128') + assert container['env'] == [{'name': 'HTTP_PROXY', 'value': 'http://p:3128'}] + + +def test_upsert_pod_container_env_escapes_kubelet_expansion(): + """Every $ is doubled so kubelet does not expand $(VAR); $$ in the settings value becomes $$$$, then kubelet restores $$.""" + container = {} + upsert_pod_container_env(container, 'PATH_PREFIX', '$(HOME)/bin') + upsert_pod_container_env(container, 'ALREADY_ESCAPED', '$$(HOME)') + assert container['env'] == [ + {'name': 'PATH_PREFIX', 'value': '$$(HOME)/bin'}, + {'name': 'ALREADY_ESCAPED', 'value': '$$$$(HOME)'}, + ] + + +@pytest.mark.django_db +def test_pod_definition_includes_awx_task_env(containerized_job, default_job_execution_environment, settings): + """AWX_TASK_ENV keys appear on the worker container; keepalive is omitted when disabled.""" + containerized_job.execution_environment = default_job_execution_environment + settings.AWX_TASK_ENV = {'RUNNER_OMIT_EVENTS': 'True', 'HTTP_PROXY': 'http://proxy:3128'} + settings.AWX_RUNNER_KEEPALIVE_SECONDS = 0 + + pod_spec = _receptor_job_for_containerized(containerized_job).pod_definition + env_map = _pod_env_map(pod_spec) + assert env_map['RUNNER_OMIT_EVENTS'] == 'True' + assert env_map['HTTP_PROXY'] == 'http://proxy:3128' + assert 'ANSIBLE_RUNNER_KEEPALIVE_SECONDS' not in env_map + + +@pytest.mark.django_db +def test_pod_definition_empty_awx_task_env_omits_env(containerized_job, default_job_execution_environment, settings): + """No env list is created when AWX_TASK_ENV is empty and keepalive is off.""" + containerized_job.execution_environment = default_job_execution_environment + settings.AWX_TASK_ENV = {} + settings.AWX_RUNNER_KEEPALIVE_SECONDS = 0 + + pod_spec = _receptor_job_for_containerized(containerized_job).pod_definition + assert 'env' not in pod_spec['spec']['containers'][0] + + +@pytest.mark.django_db +def test_pod_definition_keepalive_and_task_env(containerized_job, default_job_execution_environment, settings): + """Keepalive seconds and AWX_TASK_ENV are both present on the worker container.""" + containerized_job.execution_environment = default_job_execution_environment + settings.AWX_TASK_ENV = {'RUNNER_OMIT_EVENTS': 'True'} + settings.AWX_RUNNER_KEEPALIVE_SECONDS = 30 + + env_map = _pod_env_map(_receptor_job_for_containerized(containerized_job).pod_definition) + assert env_map['RUNNER_OMIT_EVENTS'] == 'True' + assert env_map['ANSIBLE_RUNNER_KEEPALIVE_SECONDS'] == '30' + + +@pytest.mark.django_db +def test_pod_definition_keepalive_wins_over_task_env(containerized_job, default_job_execution_environment, settings): + """AWX_RUNNER_KEEPALIVE_SECONDS overwrites the same name from AWX_TASK_ENV.""" + containerized_job.execution_environment = default_job_execution_environment + settings.AWX_TASK_ENV = {'ANSIBLE_RUNNER_KEEPALIVE_SECONDS': '5'} + settings.AWX_RUNNER_KEEPALIVE_SECONDS = 30 + + env_list = _receptor_job_for_containerized(containerized_job).pod_definition['spec']['containers'][0]['env'] + assert [item for item in env_list if item['name'] == 'ANSIBLE_RUNNER_KEEPALIVE_SECONDS'] == [ + {'name': 'ANSIBLE_RUNNER_KEEPALIVE_SECONDS', 'value': '30'}, + ] + + +@pytest.mark.django_db +def test_pod_definition_awx_task_env_when_override_env_is_null(containerized_job, default_job_execution_environment, settings): + """A pod_spec_override env of null does not prevent AWX_TASK_ENV from being applied.""" + containerized_job.execution_environment = default_job_execution_environment + containerized_job.instance_group.pod_spec_override = json.dumps({'spec': {'containers': [{'name': 'worker', 'env': None}]}}) + containerized_job.instance_group.save() + settings.AWX_TASK_ENV = {'HTTP_PROXY': 'http://p:3128'} + settings.AWX_RUNNER_KEEPALIVE_SECONDS = 0 + + env_map = _pod_env_map(_receptor_job_for_containerized(containerized_job).pod_definition) + assert env_map['HTTP_PROXY'] == 'http://p:3128' + + +@pytest.mark.django_db +def test_pod_definition_awx_task_env_overrides_pod_spec(containerized_job, default_job_execution_environment, settings): + """Jobs settings extra env wins over instance-group pod_spec_override on the same name.""" + containerized_job.execution_environment = default_job_execution_environment + containerized_job.instance_group.pod_spec_override = json.dumps( + { + 'spec': { + 'containers': [ + { + 'name': 'worker', + 'env': [ + {'name': 'HTTP_PROXY', 'value': 'http://ig-proxy:3128'}, + {'name': 'KEEP_ME', 'value': '1'}, + ], + } + ] + } + } + ) + containerized_job.instance_group.save() + settings.AWX_TASK_ENV = {'HTTP_PROXY': 'http://global-proxy:3128'} + settings.AWX_RUNNER_KEEPALIVE_SECONDS = 0 + + pod_spec = _receptor_job_for_containerized(containerized_job).pod_definition + env_list = pod_spec['spec']['containers'][0]['env'] + env_map = _pod_env_map(pod_spec) + assert env_map['HTTP_PROXY'] == 'http://global-proxy:3128' + assert env_map['KEEP_ME'] == '1' + names = [item['name'] for item in env_list] + assert names.index('HTTP_PROXY') < names.index('KEEP_ME') + + +@pytest.mark.django_db +def test_pod_definition_awx_task_env_wins_over_duplicate_override(containerized_job, default_job_execution_environment, settings): + """AWX_TASK_ENV wins even when pod_spec_override lists the same name more than once.""" + containerized_job.execution_environment = default_job_execution_environment + containerized_job.instance_group.pod_spec_override = json.dumps( + { + 'spec': { + 'containers': [ + { + 'name': 'worker', + 'env': [ + {'name': 'HTTP_PROXY', 'value': 'http://first:3128'}, + {'name': 'KEEP_ME', 'value': '1'}, + {'name': 'HTTP_PROXY', 'value': 'http://second:3128'}, + ], + } + ] + } + } + ) + containerized_job.instance_group.save() + settings.AWX_TASK_ENV = {'HTTP_PROXY': 'http://global-proxy:3128'} + settings.AWX_RUNNER_KEEPALIVE_SECONDS = 0 + + env_list = _receptor_job_for_containerized(containerized_job).pod_definition['spec']['containers'][0]['env'] + assert [item for item in env_list if item.get('name') == 'HTTP_PROXY'] == [{'name': 'HTTP_PROXY', 'value': 'http://global-proxy:3128'}] + assert any(item.get('name') == 'KEEP_ME' and item.get('value') == '1' for item in env_list) + + +@pytest.mark.django_db +def test_pod_definition_awx_task_env_replaces_value_from(containerized_job, default_job_execution_environment, settings, caplog): + """A colliding AWX_TASK_ENV key replaces a valueFrom entry with a plain value.""" + containerized_job.execution_environment = default_job_execution_environment + containerized_job.instance_group.pod_spec_override = json.dumps( + { + 'spec': { + 'containers': [ + { + 'name': 'worker', + 'env': [ + { + 'name': 'HTTP_PROXY', + 'valueFrom': {'secretKeyRef': {'name': 'proxy-secret', 'key': 'http_proxy'}}, + }, + {'name': 'KEEP_ME', 'value': '1'}, + ], + } + ] + } + } + ) + containerized_job.instance_group.save() + settings.AWX_TASK_ENV = {'HTTP_PROXY': 'http://global-proxy:3128'} + settings.AWX_RUNNER_KEEPALIVE_SECONDS = 0 + + with caplog.at_level('WARNING', logger='awx.main.tasks.receptor'): + env_list = _receptor_job_for_containerized(containerized_job).pod_definition['spec']['containers'][0]['env'] + proxy_entry = next(item for item in env_list if item['name'] == 'HTTP_PROXY') + assert proxy_entry == {'name': 'HTTP_PROXY', 'value': 'http://global-proxy:3128'} + assert any(item.get('name') == 'KEEP_ME' and item.get('value') == '1' for item in env_list) + assert 'valueFrom' in caplog.text diff --git a/awx/main/tests/unit/tasks/test_runner_callback.py b/awx/main/tests/unit/tasks/test_runner_callback.py index 2bbeca27d2..8ded864c59 100644 --- a/awx/main/tests/unit/tasks/test_runner_callback.py +++ b/awx/main/tests/unit/tasks/test_runner_callback.py @@ -1,13 +1,35 @@ import json import os import tempfile +from collections import deque from unittest import mock -from awx.main.tasks.callback import RunnerCallback, try_load_query_file +import pytest + +from awx.main.tasks.callback import RunnerCallback, RunnerCallbackForProjectUpdate, try_load_query_file from awx.main.constants import ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE +from awx.main.redact import UriCleaner from django.utils.translation import gettext_lazy as _ +SCM_USERNAME = 'scmuser' +SCM_PASSWORD = 'scmpass' +SCM_URL = f'https://{SCM_USERNAME}:{SCM_PASSWORD}@git.example.com/org/repo.git' + + +def _project_update_callback(): + cb = RunnerCallbackForProjectUpdate() + cb.instance = mock.Mock(id=1) + cb.event_data_key = 'project_update_id' + cb.wrapup_event_type = 'playbook_on_stats' + cb.job_created = '2026-01-01T00:00:00Z' + cb.parent_workflow_job_id = None + cb.host_map = {} + cb.recent_event_timings = deque(maxlen=0) + cb.dispatcher = mock.Mock() + cb.guid = 'test-guid' + return cb + def test_delay_update(mock_me): rc = RunnerCallback() @@ -151,3 +173,48 @@ class TestArtifactsHandler: assert 'installed_collections' not in rc.extra_update_fields assert 'ansible_version' not in rc.extra_update_fields assert rc.artifacts_processed is True + + +@pytest.mark.parametrize('task_action', ['git', 'svn', 'ansible.builtin.git', 'ansible.builtin.svn']) +def test_project_update_redacts_scm_credentials(task_action, mock_me): + """Git/SVN events redact HTTP basic-auth from dispatched project-update payloads.""" + cb = _project_update_callback() + cb.event_handler( + { + 'event': 'runner_on_failed', + 'stdout': f'failed: {SCM_URL}', + 'event_data': {'task_action': task_action, 'res': {'msg': f'Failed to clone {SCM_URL}'}}, + } + ) + dispatched = json.dumps(cb.dispatcher.dispatch.call_args[0][0]) + assert SCM_USERNAME not in dispatched + assert SCM_PASSWORD not in dispatched + assert UriCleaner.REPLACE_STR in dispatched + + +def test_project_update_skips_scm_redaction_without_task_action(mock_me): + """Omitted event_data has no task_action, so UriCleaner never runs on SCM stdout.""" + cb = _project_update_callback() + cb.event_handler({'event': 'runner_on_failed', 'stdout': f'failed: {SCM_URL}', 'event_data': {}}) + dispatched = cb.dispatcher.dispatch.call_args[0][0] + assert SCM_PASSWORD in dispatched['stdout'] + + +@pytest.mark.parametrize('task_action', ['set_fact', 'ansible.builtin.set_fact']) +def test_project_update_captures_scm_version(task_action, mock_me): + """Successful set_fact events still populate playbook_new_revision.""" + cb = _project_update_callback() + cb.event_handler( + { + 'event': 'runner_on_ok', + 'event_data': {'task_action': task_action, 'res': {'ansible_facts': {'scm_version': 'abc123def'}}}, + } + ) + assert cb.playbook_new_revision == 'abc123def' + + +def test_project_update_skips_revision_capture_without_task_action(mock_me): + """Omitted successful-event payloads never expose scm_version to the callback.""" + cb = _project_update_callback() + cb.event_handler({'event': 'runner_on_ok', 'event_data': {}}) + assert cb.playbook_new_revision is None diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 251f7ffaec..6ff1664766 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -32,6 +32,7 @@ from awx.main.models import ( build_safe_env, ) from awx.main.models.credential import HIDDEN_PASSWORD, ManagedCredentialType +from awx.main.redact import UriCleaner from awx.main.tasks import jobs, system, receptor from awx.main.utils import encrypt_field, encrypt_value @@ -558,6 +559,164 @@ class TestGenericRun: env = task.build_env(job, private_data_dir) assert env['FOO'] == 'BAR' + @pytest.mark.parametrize( + 'value,expected', + [ + ('True', True), + ('true', True), + ('1', True), + ('yes', True), + ('on', True), + ('y', True), + ('t', True), + ('False', False), + ('0', False), + ('', False), + ], + ) + def test_env_flag_enabled(self, value, expected): + """Truthy spellings match strtobool; other strings are False.""" + assert jobs.env_flag_enabled({'RUNNER_OMIT_EVENTS': value}, 'RUNNER_OMIT_EVENTS') is expected + + def test_env_flag_enabled_missing_key(self): + """A missing or unrelated key is False, not an error.""" + assert jobs.env_flag_enabled({}, 'RUNNER_OMIT_EVENTS') is False + assert jobs.env_flag_enabled({'RUNNER_ONLY_FAILED_EVENTS': 'True'}, 'RUNNER_OMIT_EVENTS') is False + + @pytest.mark.django_db + @pytest.mark.parametrize( + 'task_env,omit_expected,only_failed_expected', + [ + ({'RUNNER_OMIT_EVENTS': 'True', 'RUNNER_ONLY_FAILED_EVENTS': 'yes'}, True, True), + ({'RUNNER_OMIT_EVENTS': 'False', 'RUNNER_ONLY_FAILED_EVENTS': '0'}, False, False), + ({}, False, False), + ], + ) + def test_run_passes_omit_event_flags_to_receptor( + self, patch_Job, execution_environment, mock_me, mock_create_partition, private_data_dir, task_env, omit_expected, only_failed_expected + ): + """AWX_TASK_ENV runner flags must be forwarded as ansible-runner constructor params.""" + job = Job(pk=1, id=1, status='running', inventory=Inventory(), project=Project(local_path='/projects/_23_foo'), playbook='site.yml') + job.websocket_emit_status = mock.Mock() + job.send_notification_templates = mock.Mock() + job.log_lifecycle = mock.Mock() + job.execution_environment = execution_environment + job.resolve_execution_environment = mock.Mock(return_value=execution_environment) + job.cancel_flag = False + + task = jobs.RunJob() + task.instance = job + task.update_model = mock.Mock(return_value=job) + task.build_private_data_dir = mock.Mock(return_value=private_data_dir) + task.build_project_dir = mock.Mock() + task.build_extra_vars_file = mock.Mock() + task.build_inventory = mock.Mock(return_value=os.path.join(private_data_dir, 'inventory', 'hosts')) + task.post_run_hook = mock.Mock() + + receptor_job = mock.Mock() + receptor_job.run.return_value = mock.Mock(status='successful', rc=0) + receptor_job.unit_id = 'wu-1' + receptor_job.receptor_ctl = None + + with ( + mock.patch('awx.main.tasks.jobs.settings.AWX_TASK_ENV', task_env), + mock.patch('awx.main.tasks.jobs.settings.AWX_ISOLATION_BASE_PATH', private_data_dir), + mock.patch('awx.main.tasks.jobs.evaluate_policy'), + mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=False), + mock.patch('awx.main.tasks.jobs.AWXReceptorJob', return_value=receptor_job) as mock_receptor_cls, + mock.patch('awx.main.tasks.jobs._finalize_job_run', return_value=job), + mock.patch.object(task, 'build_credentials_list', return_value=[]), + mock.patch.object(task.model.objects, 'get', return_value=job), + ): + task.run(1) + + mock_receptor_cls.assert_called_once() + params = mock_receptor_cls.call_args[0][1] + assert params['omit_event_data'] is omit_expected + assert params['only_failed_event_data'] is only_failed_expected + for key, value in task_env.items(): + assert params['envvars'][key] == value + + @pytest.mark.django_db + @pytest.mark.usefixtures('patch_Organization') + @pytest.mark.parametrize( + 'task_env', + [ + {'RUNNER_OMIT_EVENTS': 'True'}, + {'RUNNER_ONLY_FAILED_EVENTS': 'True'}, + {'RUNNER_OMIT_EVENTS': 'True', 'RUNNER_ONLY_FAILED_EVENTS': 'yes'}, + ], + ) + def test_run_keeps_omit_event_flags_disabled_for_project_update(self, execution_environment, mock_me, mock_create_partition, private_data_dir, task_env): + """Project updates keep event metadata even when global omission flags are enabled.""" + project_update = ProjectUpdate(pk=1, id=1, status='running', project=Project(pk=1, organization=Organization(pk=1))) + project_update.websocket_emit_status = mock.Mock() + project_update.send_notification_templates = mock.Mock() + project_update.log_lifecycle = mock.Mock() + project_update.execution_environment = execution_environment + project_update.resolve_execution_environment = mock.Mock(return_value=execution_environment) + project_update.cancel_flag = False + project_update.scm_type = 'git' + + task = jobs.RunProjectUpdate() + task.instance = project_update + task.update_model = mock.Mock(return_value=project_update) + task.build_private_data_dir = mock.Mock(return_value=private_data_dir) + task.build_project_dir = mock.Mock() + task.build_extra_vars_file = mock.Mock() + task.build_private_data_files = mock.Mock(return_value=({}, None)) + task.pre_run_hook = mock.Mock() + task.post_run_hook = mock.Mock() + + receptor_job = mock.Mock() + receptor_job.run.return_value = mock.Mock(status='successful', rc=0) + receptor_job.unit_id = 'wu-1' + receptor_job.receptor_ctl = None + + with ( + mock.patch('awx.main.tasks.jobs.settings.AWX_TASK_ENV', task_env), + mock.patch('awx.main.tasks.jobs.settings.AWX_ISOLATION_BASE_PATH', private_data_dir), + mock.patch('awx.main.tasks.jobs.evaluate_policy'), + mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=False), + mock.patch('awx.main.tasks.jobs.AWXReceptorJob', return_value=receptor_job) as mock_receptor_cls, + mock.patch('awx.main.tasks.jobs._finalize_job_run', return_value=project_update), + mock.patch.object(task, 'build_credentials_list', return_value=[]), + mock.patch.object(task.model.objects, 'get', return_value=project_update), + ): + task.run(1) + + mock_receptor_cls.assert_called_once() + params = mock_receptor_cls.call_args[0][1] + assert params['omit_event_data'] is False + assert params['only_failed_event_data'] is False + for key, value in task_env.items(): + assert params['envvars'][key] == value + + # Flags stay off so the project-update callback still sees task_action / scm_version. + task.runner_callback.dispatcher = mock.Mock() + task.runner_callback.event_data_key = 'project_update_id' + task.runner_callback.wrapup_event_type = 'playbook_on_stats' + scm_url = 'https://scmuser:scmpass@git.example.com/org/repo.git' + task.runner_callback.event_handler( + { + 'event': 'runner_on_failed', + 'stdout': f'failed: {scm_url}', + 'event_data': {'task_action': 'git', 'res': {'msg': f'Failed to clone {scm_url}'}}, + } + ) + dispatched = json.dumps(task.runner_callback.dispatcher.dispatch.call_args[0][0]) + assert 'scmuser' not in dispatched + assert 'scmpass' not in dispatched + assert UriCleaner.REPLACE_STR in dispatched + + task.runner_callback.event_handler( + { + 'event': 'runner_on_ok', + 'event_data': {'task_action': 'set_fact', 'res': {'ansible_facts': {'scm_version': 'abc123def'}}}, + } + ) + assert task.runner_callback.playbook_new_revision == 'abc123def' + @pytest.mark.django_db class TestAdhocRun(TestJobExecution):