[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 <cursoragent@cursor.com>

* [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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Djebran Lezzoum
2026-10-27 10:11:33 -04:00
committed by GitHub
co-authored by Cursor
parent 5333d0d1f9
commit 53ee6a4a52
5 changed files with 494 additions and 8 deletions
+14
View File
@@ -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:
+34 -6
View File
@@ -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.
@@ -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
@@ -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
+159
View File
@@ -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):