mirror of
https://github.com/ansible/awx.git
synced 2026-10-29 22:03:20 -02:30
[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:
co-authored by
Cursor
parent
5333d0d1f9
commit
53ee6a4a52
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user