Compare commits

...

16 Commits

Author SHA1 Message Date
Dirk Julich
d2912c3bdd AAP-63318 Internal/Developer Documentation for Indirect Query Files (#7273)
Add documentation for indirect query files
2026-03-03 15:21:24 +01:00
Stevenson Michel
d1f4fc3e97 [Devel] Addition of logic to trigger worflow dispatch on release_4.6 and stable-2.6 (#16315)
* Added worflow dispatch to trigger ci on release_4.6 and stable-2.6

* fix error cascading to subsequent jobs for cron

* made compose_tag resolve to the correct branch name
2026-03-02 18:48:42 -05:00
Lila Yasin
0f2692b504 AAP-58452 Add version fallback for external query files (#16309)
Forward port of 5c4653fbbca4b542fea05f070efea0396b034839 from stable-2.6.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:08:27 -05:00
Alan Rominger
e1e2c60f2e AAP-66379 Include scaledown fix from dispatcherd (#16305)
Include scaledown fix from dispatcherd
2026-02-27 14:45:57 -05:00
Alan Rominger
d8a2aa1dc3 Do not write to dispatcher.log from AWX application code (#16302) 2026-02-27 14:45:27 -05:00
Alan Rominger
9d61e42ede Bump action version (#16306) 2026-02-27 09:52:38 -05:00
Seth Foster
2c71bcda32 Improve transactional integrity for starting controller jobs in dispatcherd (#16300)
Remove SELECT FOR UPDATE from job dispatch to reduce transaction rollbacks
                                                                                                                                                                                                                                                                                           
  Move status transition from BaseTask.transition_status (which used
  SELECT FOR UPDATE inside transaction.atomic()) into                                                                                                                                                                                                                                      
  dispatch_waiting_jobs. The new approach uses filter().update() which                                                                                                                                                                                                                     
  is atomic at the database level without requiring explicit row locks,
  reducing transaction contention and rollbacks observed in perfscale
  testing.

  The transition_status method was an artifact of the feature flag era
  where we needed to support both old and new code paths. Since
  dispatch_waiting_jobs is already a singleton
  (on_duplicate='queue_one') scoped to the local node, the
  de-duplication logic is unnecessary.

  Status is updated after task submission to dispatcherd, so the job's
  UUID is in the dispatch pipeline before being marked running —
  preventing the reaper from incorrectly reaping jobs during the
  handoff window. RunJob.run() handles the race where a worker picks
  up the task before the status update lands by accepting waiting and
  transitioning it to running itself.

  Signed-off-by: Seth Foster <fosterbseth@gmail.com>
  Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-26 14:16:36 -05:00
Stevenson Michel
a21f9fbdb8 Addition of Cron Schedule (#16301)
Added cron schedule for ci jobs
2026-02-23 16:31:34 -05:00
Daniel Finca Martínez
2a35ce5524 AAP-62693 Integrate workload identity client to request JWTs (#16296)
* Add retrieve_workload_identity_jwt to jobs.py and tests

* Apply linting

* Add precondition to client retrieval

* Add test case for client not configured

* Remove trailing period in match string
2026-02-19 09:13:32 -05:00
Alan Rominger
567a980a03 Give error details of sliced jobs if they error in live tests (#16273) 2026-02-18 15:12:12 -05:00
Alan Rominger
9059cfbda6 Fix some pytest warnings using Opus 4.6 (#16269)
* Fix some pytest warnings using Opus 4.6

* Fix review comments

* Use raw-strings and regex markers for matching exception pattern

Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) <wk.cvs.github@sydorenko.org.ua>

* Make regex work

* Undo always true assertion edit

---------

Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) <wk.cvs.github@sydorenko.org.ua>
2026-02-18 15:11:41 -05:00
Jake Jackson
d8fd953732 Update PR template to add steps to repro (#16294)
* Update PR template to add steps to repro

* updated bottom section to include steps to reproduce to help with
  debugging and triage
2026-02-18 15:01:36 -05:00
Stevenson Michel
39851c392a [Devel] Removal of Token Auth From Devel (#16293)
* Revert "[Devel][AAP-65384]Restoration of Token Authentication for AWX CLI (#16281)"

This reverts commit 994a2b3c04.
2026-02-18 14:32:44 -05:00
Chris Meyers
aeba4a1a3f Revert "Change remote host finding logic"
This reverts commit 08f1507f70.
2026-02-17 14:46:45 -05:00
Adrià Sala
915deca78c fix: awxkit user creation through gw 2026-02-17 15:38:20 +01:00
Peter Braun
1a79e853fe do not add optional survey fields with empty strings that are not bac… (#16289)
* do not add optional survey fields with empty strings that are not backed by extra_vars

* exclude password fields from skipping if not defined
2026-02-17 12:59:38 +01:00
26 changed files with 554 additions and 449 deletions

View File

@@ -24,7 +24,7 @@ in as the first entry for your PR title.
##### ADDITIONAL INFORMATION
##### STEPS TO REPRODUCE AND EXTRA INFO
<!---
Include additional information to help people understand the change here.
For bugs that don't have a linked bug report, a step-by-step reproduction

View File

@@ -4,14 +4,46 @@ env:
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
CI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEV_DOCKER_OWNER: ${{ github.repository_owner }}
COMPOSE_TAG: ${{ github.base_ref || 'devel' }}
COMPOSE_TAG: ${{ github.base_ref || github.ref_name || 'devel' }}
UPSTREAM_REPOSITORY_ID: 91594105
on:
pull_request:
push:
branches:
- devel # needed to publish code coverage post-merge
schedule:
- cron: '0 12,18 * * 1-5'
workflow_dispatch: {}
jobs:
trigger-release-branches:
name: "Dispatch CI to release branches"
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Trigger CI on release_4.6
id: dispatch_release_46
continue-on-error: true
run: gh workflow run ci.yml --ref release_4.6
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- name: Trigger CI on stable-2.6
id: dispatch_stable_26
continue-on-error: true
run: gh workflow run ci.yml --ref stable-2.6
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- name: Check dispatch results
if: steps.dispatch_release_46.outcome == 'failure' || steps.dispatch_stable_26.outcome == 'failure'
run: |
echo "One or more dispatches failed:"
echo " release_4.6: ${{ steps.dispatch_release_46.outcome }}"
echo " stable-2.6: ${{ steps.dispatch_stable_26.outcome }}"
exit 1
common-tests:
name: ${{ matrix.tests.name }}
runs-on: ubuntu-latest
@@ -122,7 +154,7 @@ jobs:
&& github.event_name == 'push'
&& env.UPSTREAM_REPOSITORY_ID == github.repository_id
&& github.ref_name == github.event.repository.default_branch
uses: ansible/gh-action-record-test-results@cd5956ead39ec66351d0779470c8cff9638dd2b8
uses: ansible/gh-action-record-test-results@fc552f81bf7e734cdebe6d04f9f608e2e2b4759e
with:
aggregation-server-url: ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}
http-auth-password: >-
@@ -296,7 +328,7 @@ jobs:
&& github.event_name == 'push'
&& env.UPSTREAM_REPOSITORY_ID == github.repository_id
&& github.ref_name == github.event.repository.default_branch
uses: ansible/gh-action-record-test-results@cd5956ead39ec66351d0779470c8cff9638dd2b8
uses: ansible/gh-action-record-test-results@fc552f81bf7e734cdebe6d04f9f608e2e2b4759e
with:
aggregation-server-url: ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}
http-auth-password: >-

View File

@@ -55,6 +55,7 @@ from wsgiref.util import FileWrapper
from drf_spectacular.utils import extend_schema_view, extend_schema
# django-ansible-base
from ansible_base.lib.utils.requests import get_remote_hosts
from ansible_base.rbac.models import RoleEvaluation
from ansible_base.lib.utils.schema import extend_schema_if_available
@@ -97,7 +98,6 @@ from awx.main.utils import (
from awx.main.utils.encryption import encrypt_value
from awx.main.utils.filters import SmartFilter
from awx.main.utils.plugins import compute_cloud_inventory_sources
from awx.main.utils.proxy import get_first_remote_host_from_headers
from awx.main.utils.common import memoize
from awx.main.redact import UriCleaner
from awx.api.permissions import (
@@ -2877,8 +2877,7 @@ class JobTemplateCallback(GenericAPIView):
host for the current request.
"""
# Find the list of remote host names/IPs to check.
# Only consider the first entry from each header (for comma-separated values like X-Forwarded-For)
remote_hosts = get_first_remote_host_from_headers(self.request, settings.REMOTE_HOST_HEADERS)
remote_hosts = set(get_remote_hosts(self.request))
# Add the reverse lookup of IP addresses.
for rh in list(remote_hosts):
try:

View File

@@ -27,6 +27,10 @@ def get_dispatcherd_config(for_service: bool = False, mock_publish: bool = False
"pool_kwargs": {
"min_workers": settings.JOB_EVENT_WORKERS,
"max_workers": max_workers,
# This must be less than max_workers to make sense, which is usually 4
# With reserve of 1, after a burst of tasks, load needs to down to 4-1=3
# before we return to min_workers
"scaledown_reserve": 1,
},
"main_kwargs": {"node_id": settings.CLUSTER_HOST_ID},
"process_manager_cls": "ForkServerManager",

View File

@@ -188,6 +188,16 @@ class SurveyJobTemplateMixin(models.Model):
runtime_extra_vars.pop(variable_key)
if default is not None:
# do not add variables that contain an empty string, are not required and are not present in extra_vars
# password fields must be skipped, because default values have special behaviour
if (
default == ''
and not survey_element.get('required')
and survey_element.get('type') != 'password'
and variable_key not in runtime_extra_vars
):
continue
decrypted_default = default
if survey_element['type'] == "password" and isinstance(decrypted_default, str) and decrypted_default.startswith('$encrypted$'):
decrypted_default = decrypt_value(get_encryption_key('value', pk=None), decrypted_default)

View File

@@ -17,7 +17,6 @@ import urllib.parse as urlparse
# Django
from django.conf import settings
from django.db import transaction
# Shared code for the AWX platform
from awx_plugins.interfaces._temporary_private_container_api import CONTAINER_ROOT, get_incontainer_path
@@ -96,6 +95,10 @@ from flags.state import flag_enabled
# Workload Identity
from ansible_base.lib.workload_identity.controller import AutomationControllerJobScope
from ansible_base.resource_registry.workload_identity_client import (
get_workload_identity_client,
)
logger = logging.getLogger('awx.main.tasks.jobs')
@@ -163,6 +166,18 @@ def populate_claims_for_workload(unified_job) -> dict:
return claims
def retrieve_workload_identity_jwt(unified_job: UnifiedJob, audience: str, scope: str) -> str:
"""Retrieve JWT token from workload claims.
Raises:
RuntimeError: if the workload identity client is not configured.
"""
client = get_workload_identity_client()
if client is None:
raise RuntimeError("Workload identity client is not configured")
claims = populate_claims_for_workload(unified_job)
return client.request_workload_jwt(claims=claims, scope=scope, audience=audience).jwt
def with_path_cleanup(f):
@functools.wraps(f)
def _wrapped(self, *args, **kwargs):
@@ -189,6 +204,7 @@ def dispatch_waiting_jobs(binder):
if not kwargs:
kwargs = {}
binder.control('run', data={'task': serialize_task(uj._get_task_class()), 'args': [uj.id], 'kwargs': kwargs, 'uuid': uj.celery_task_id})
UnifiedJob.objects.filter(pk=uj.pk, status='waiting').update(status='running', start_args='')
class BaseTask(object):
@@ -535,48 +551,32 @@ class BaseTask(object):
def should_use_fact_cache(self):
return False
def transition_status(self, pk: int) -> bool:
"""Atomically transition status to running, if False returned, another process got it"""
with transaction.atomic():
# Explanation of parts for the fetch:
# .values - avoid loading a full object, this is known to lead to deadlocks due to signals
# the signals load other related rows which another process may be locking, and happens in practice
# of=('self',) - keeps FK tables out of the lock list, another way deadlocks can happen
# .get - just load the single job
instance_data = UnifiedJob.objects.select_for_update(of=('self',)).values('status', 'cancel_flag').get(pk=pk)
# If status is not waiting (obtained under lock) then this process does not have clearence to run
if instance_data['status'] == 'waiting':
if instance_data['cancel_flag']:
updated_status = 'canceled'
else:
updated_status = 'running'
# Explanation of the update:
# .filter - again, do not load the full object
# .update - a bulk update on just that one row, avoid loading unintended data
UnifiedJob.objects.filter(pk=pk).update(status=updated_status, start_args='')
elif instance_data['status'] == 'running':
logger.info(f'Job {pk} is being ran by another process, exiting')
return False
return True
@with_path_cleanup
@with_signal_handling
def run(self, pk, **kwargs):
"""
Run the job/task and capture its output.
"""
if not self.instance: # Used to skip fetch for local runs
if not self.transition_status(pk):
logger.info(f'Job {pk} is being ran by another process, exiting')
return
# Load the instance
self.instance = self.update_model(pk)
if not self.instance: # Used to skip fetch for local runs
# Load the instance
self.instance = self.update_model(pk)
# status should be "running" from dispatch_waiting_jobs,
# but may still be "waiting" if the worker picked this up before the status update landed.
if self.instance.status == 'waiting':
UnifiedJob.objects.filter(pk=pk).update(status="running", start_args='')
self.instance.refresh_from_db()
if self.instance.status != 'running':
logger.error(f'Not starting {self.instance.status} task pk={pk} because its status "{self.instance.status}" is not expected')
return
if self.instance.cancel_flag:
self.instance = self.update_model(pk, status='canceled')
self.instance.websocket_emit_status('canceled')
return
self.instance.websocket_emit_status("running")
status, rc = 'error', None
self.runner_callback.event_ct = 0

View File

@@ -485,47 +485,3 @@ class TestJobTemplateCallbackProxyIntegration:
expect=400,
**headers
)
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=[])
def test_only_first_entry_in_comma_separated_header_is_considered(self, job_template, admin_user, post):
"""
Test that only the first entry in a comma-separated header value is used for host matching.
This is important for X-Forwarded-For style headers where the format is "client, proxy1, proxy2".
Only the original client (first entry) should be matched against inventory hosts.
"""
# Create host that matches the SECOND entry in the comma-separated list
job_template.inventory.hosts.create(name='second-host.example.com')
headers = {
# First entry is 'first-host.example.com', second is 'second-host.example.com'
# Only the first should be considered, so this should NOT match
'HTTP_X_FROM_THE_LOAD_BALANCER': 'first-host.example.com, second-host.example.com',
'REMOTE_ADDR': 'unrelated-addr',
'REMOTE_HOST': 'unrelated-host',
}
# Should return 400 because only 'first-host.example.com' is considered,
# and that host is NOT in the inventory
r = post(
url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}), data={'host_config_key': 'abcd'}, user=admin_user, expect=400, **headers
)
assert r.data['msg'] == 'No matching host could be found!'
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=[])
def test_first_entry_in_comma_separated_header_matches(self, job_template, admin_user, post):
"""
Test that the first entry in a comma-separated header value correctly matches an inventory host.
"""
# Create host that matches the FIRST entry in the comma-separated list
job_template.inventory.hosts.create(name='first-host.example.com')
headers = {
# First entry is 'first-host.example.com', second is 'second-host.example.com'
# The first entry matches the inventory host
'HTTP_X_FROM_THE_LOAD_BALANCER': 'first-host.example.com, second-host.example.com',
'REMOTE_ADDR': 'unrelated-addr',
'REMOTE_HOST': 'unrelated-host',
}
# Should return 201 because 'first-host.example.com' is the first entry and matches
post(url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}), data={'host_config_key': 'abcd'}, user=admin_user, expect=201, **headers)

View File

@@ -1,4 +1,3 @@
from datetime import date
from unittest import mock
import pytest
@@ -253,7 +252,7 @@ def test_user_verify_attribute_created(admin, get):
resp = get(reverse('api:user_detail', kwargs={'pk': admin.pk}), admin)
assert resp.data['created'] == admin.date_joined
past = date(2020, 1, 1).isoformat()
past = "2020-01-01T00:00:00Z"
for op, count in (('gt', 1), ('lt', 0)):
resp = get(reverse('api:user_list') + f'?created__{op}={past}', admin)
assert resp.data['count'] == count

View File

@@ -29,3 +29,30 @@ def test_cancel_flag_on_start(jt_linked, caplog):
job = Job.objects.get(id=job.id)
assert job.status == 'canceled'
@pytest.mark.django_db
def test_runjob_run_can_accept_waiting_status(jt_linked, mocker):
"""Test that RunJob.run() can accept a job in 'waiting' status and transition it to 'running'
before the pre_run_hook is called"""
job = jt_linked.create_unified_job()
job.status = 'waiting'
job.save()
status_at_pre_run = None
def capture_status(instance, private_data_dir):
nonlocal status_at_pre_run
instance.refresh_from_db()
status_at_pre_run = instance.status
mock_pre_run = mocker.patch.object(RunJob, 'pre_run_hook', side_effect=capture_status)
task = RunJob()
try:
task.run(job.id)
except Exception:
pass
mock_pre_run.assert_called_once()
assert status_at_pre_run == 'running'

View File

@@ -18,7 +18,7 @@ from awx.main.tests.functional.conftest import * # noqa
from awx.main.tests.conftest import load_all_credentials # noqa: F401; pylint: disable=unused-import
from awx.main.tests import data
from awx.main.models import Project, JobTemplate, Organization, Inventory
from awx.main.models import Project, JobTemplate, Organization, Inventory, WorkflowJob, UnifiedJob
from awx.main.tasks.system import clear_setting_cache
logger = logging.getLogger(__name__)
@@ -100,6 +100,21 @@ def wait_for_events(uj, timeout=2):
def unified_job_stdout(uj):
if type(uj) is UnifiedJob:
uj = uj.get_real_instance()
if isinstance(uj, WorkflowJob):
outputs = []
for node in uj.workflow_job_nodes.all().select_related('job').order_by('id'):
if node.job is None:
continue
outputs.append(
'workflow node {node_id} job {job_id} output:\n{output}'.format(
node_id=node.id,
job_id=node.job.id,
output=unified_job_stdout(node.job),
)
)
return '\n'.join(outputs)
wait_for_events(uj)
return '\n'.join([event.stdout for event in uj.get_event_queryset().order_by('created')])

View File

@@ -176,22 +176,22 @@ def test_display_survey_spec_encrypts_default(survey_spec_factory):
@pytest.mark.survey
@pytest.mark.parametrize(
"question_type,default,min,max,expect_use,expect_value",
"question_type,default,min,max,expect_valid,expect_use,expect_value",
[
("text", "", 0, 0, True, ''), # default used
("text", "", 1, 0, False, 'N/A'), # value less than min length
("password", "", 1, 0, False, 'N/A'), # passwords behave the same as text
("multiplechoice", "", 0, 0, False, 'N/A'), # historical bug
("multiplechoice", "zeb", 0, 0, False, 'N/A'), # zeb not in choices
("multiplechoice", "coffee", 0, 0, True, 'coffee'),
("multiselect", None, 0, 0, False, 'N/A'), # NOTE: Behavior is arguable, value of [] may be prefered
("multiselect", "", 0, 0, False, 'N/A'),
("multiselect", ["zeb"], 0, 0, False, 'N/A'),
("multiselect", ["milk"], 0, 0, True, ["milk"]),
("multiselect", ["orange\nmilk"], 0, 0, False, 'N/A'), # historical bug
("text", "", 0, 0, True, False, 'N/A'), # valid but empty default not sent for optional question
("text", "", 1, 0, False, False, 'N/A'), # value less than min length
("password", "", 1, 0, False, False, 'N/A'), # passwords behave the same as text
("multiplechoice", "", 0, 0, False, False, 'N/A'), # historical bug
("multiplechoice", "zeb", 0, 0, False, False, 'N/A'), # zeb not in choices
("multiplechoice", "coffee", 0, 0, True, True, 'coffee'),
("multiselect", None, 0, 0, False, False, 'N/A'), # NOTE: Behavior is arguable, value of [] may be prefered
("multiselect", "", 0, 0, False, False, 'N/A'),
("multiselect", ["zeb"], 0, 0, False, False, 'N/A'),
("multiselect", ["milk"], 0, 0, True, True, ["milk"]),
("multiselect", ["orange\nmilk"], 0, 0, False, False, 'N/A'), # historical bug
],
)
def test_optional_survey_question_defaults(survey_spec_factory, question_type, default, min, max, expect_use, expect_value):
def test_optional_survey_question_defaults(survey_spec_factory, question_type, default, min, max, expect_valid, expect_use, expect_value):
spec = survey_spec_factory(
[
{
@@ -208,7 +208,7 @@ def test_optional_survey_question_defaults(survey_spec_factory, question_type, d
jt = JobTemplate(name="test-jt", survey_spec=spec, survey_enabled=True)
defaulted_extra_vars = jt._update_unified_job_kwargs({}, {})
element = spec['spec'][0]
if expect_use:
if expect_valid:
assert jt._survey_element_validation(element, {element['variable']: element['default']}) == []
else:
assert jt._survey_element_validation(element, {element['variable']: element['default']})
@@ -218,6 +218,28 @@ def test_optional_survey_question_defaults(survey_spec_factory, question_type, d
assert 'c' not in defaulted_extra_vars['extra_vars']
@pytest.mark.survey
def test_optional_survey_empty_default_with_runtime_extra_var(survey_spec_factory):
"""When a user explicitly provides an empty string at runtime for an optional
survey question, the variable should still be included in extra_vars."""
spec = survey_spec_factory(
[
{
"required": False,
"default": "",
"choices": "",
"variable": "c",
"min": 0,
"max": 0,
"type": "text",
},
]
)
jt = JobTemplate(name="test-jt", survey_spec=spec, survey_enabled=True)
defaulted_extra_vars = jt._update_unified_job_kwargs({}, {'extra_vars': json.dumps({'c': ''})})
assert json.loads(defaulted_extra_vars['extra_vars'])['c'] == ''
@pytest.mark.survey
@pytest.mark.parametrize(
"question_type,default,maxlen,kwargs,expected",

View File

@@ -427,3 +427,59 @@ def test_populate_claims_for_adhoc_command(workload_attrs, expected_claims):
claims = jobs.populate_claims_for_workload(adhoc_command)
assert claims == expected_claims
@mock.patch('awx.main.tasks.jobs.get_workload_identity_client')
def test_retrieve_workload_identity_jwt_returns_jwt_from_client(mock_get_client):
"""retrieve_workload_identity_jwt returns the JWT string from the client."""
mock_client = mock.MagicMock()
mock_response = mock.MagicMock()
mock_response.jwt = 'eyJ.test.jwt'
mock_client.request_workload_jwt.return_value = mock_response
mock_get_client.return_value = mock_client
unified_job = Job()
unified_job.id = 42
unified_job.name = 'Test Job'
unified_job.launch_type = 'manual'
unified_job.organization = Organization(id=1, name='Test Org')
unified_job.unified_job_template = None
unified_job.instance_group = None
result = jobs.retrieve_workload_identity_jwt(unified_job, audience='https://api.example.com', scope='aap_controller_automation_job')
assert result == 'eyJ.test.jwt'
mock_client.request_workload_jwt.assert_called_once()
call_kwargs = mock_client.request_workload_jwt.call_args[1]
assert call_kwargs['audience'] == 'https://api.example.com'
assert call_kwargs['scope'] == 'aap_controller_automation_job'
assert 'claims' in call_kwargs
assert call_kwargs['claims'][AutomationControllerJobScope.CLAIM_JOB_ID] == 42
assert call_kwargs['claims'][AutomationControllerJobScope.CLAIM_JOB_NAME] == 'Test Job'
@mock.patch('awx.main.tasks.jobs.get_workload_identity_client')
def test_retrieve_workload_identity_jwt_passes_audience_and_scope(mock_get_client):
"""retrieve_workload_identity_jwt passes audience and scope to the client."""
mock_client = mock.MagicMock()
mock_client.request_workload_jwt.return_value = mock.MagicMock(jwt='token')
mock_get_client.return_value = mock_client
unified_job = mock.MagicMock()
audience = 'custom_audience'
scope = 'custom_scope'
with mock.patch('awx.main.tasks.jobs.populate_claims_for_workload', return_value={'job_id': 1}):
jobs.retrieve_workload_identity_jwt(unified_job, audience=audience, scope=scope)
mock_client.request_workload_jwt.assert_called_once_with(claims={'job_id': 1}, scope=scope, audience=audience)
@mock.patch('awx.main.tasks.jobs.get_workload_identity_client')
def test_retrieve_workload_identity_jwt_raises_when_client_not_configured(mock_get_client):
"""retrieve_workload_identity_jwt raises RuntimeError when client is None."""
mock_get_client.return_value = None
unified_job = mock.MagicMock()
with pytest.raises(RuntimeError, match="Workload identity client is not configured"):
jobs.retrieve_workload_identity_jwt(unified_job, audience='test_audience', scope='test_scope')

View File

@@ -330,17 +330,13 @@ class TestHostnameRegexValidator:
def test_bad_call(self, regex_expr, re_flags):
h = HostnameRegexValidator(regex=regex_expr, flags=re_flags)
try:
with pytest.raises(ValidationError, match=r"^\['illegal characters detected in hostname=@#\$%\)\$#\(TUFAS_DG. Please verify.'\]$"):
h("@#$%)$#(TUFAS_DG")
except ValidationError as e:
assert e.message is not None
def test_good_call_with_inverse(self, regex_expr, re_flags, inverse_match=True):
h = HostnameRegexValidator(regex=regex_expr, flags=re_flags, inverse_match=inverse_match)
try:
with pytest.raises(ValidationError, match=r"^\['Enter a valid value.'\]$"):
h("1.2.3.4")
except ValidationError as e:
assert e.message is not None
def test_bad_call_with_inverse(self, regex_expr, re_flags, inverse_match=True):
h = HostnameRegexValidator(regex=regex_expr, flags=re_flags, inverse_match=inverse_match)

View File

@@ -1,207 +0,0 @@
# Copyright (c) 2024 Ansible, Inc.
# All Rights Reserved.
from unittest import mock
from awx.main.utils.proxy import get_first_remote_host_from_headers, is_proxy_in_headers
class TestGetFirstRemoteHostFromHeaders:
"""Tests for get_first_remote_host_from_headers function."""
def _make_mock_request(self, environ):
"""Create a mock request with the given environ dict."""
request = mock.MagicMock()
request.environ = environ
return request
def test_single_value_headers(self):
"""Test extraction from headers with single values (no commas)."""
request = self._make_mock_request(
{
"REMOTE_ADDR": "192.168.1.1",
"REMOTE_HOST": "client.example.com",
}
)
headers = ["REMOTE_ADDR", "REMOTE_HOST"]
result = get_first_remote_host_from_headers(request, headers)
assert result == {"192.168.1.1", "client.example.com"}
def test_comma_separated_only_first_entry(self):
"""Test that only the first entry is extracted from comma-separated values."""
request = self._make_mock_request(
{
"HTTP_X_FORWARDED_FOR": "10.0.0.1, 192.168.1.1, 172.16.0.1",
}
)
headers = ["HTTP_X_FORWARDED_FOR"]
result = get_first_remote_host_from_headers(request, headers)
# Only the first IP should be included
assert result == {"10.0.0.1"}
# Subsequent IPs should NOT be included
assert "192.168.1.1" not in result
assert "172.16.0.1" not in result
def test_comma_separated_with_whitespace(self):
"""Test that whitespace is properly stripped from first entry."""
request = self._make_mock_request(
{
"HTTP_X_FORWARDED_FOR": " 10.0.0.1 , 192.168.1.1",
}
)
headers = ["HTTP_X_FORWARDED_FOR"]
result = get_first_remote_host_from_headers(request, headers)
assert result == {"10.0.0.1"}
def test_multiple_headers_with_comma_separated(self):
"""Test multiple headers where some have comma-separated values."""
request = self._make_mock_request(
{
"HTTP_X_FORWARDED_FOR": "client.example.com, proxy1.example.com, proxy2.example.com",
"REMOTE_ADDR": "172.16.0.1",
"REMOTE_HOST": "proxy2.example.com",
}
)
headers = ["HTTP_X_FORWARDED_FOR", "REMOTE_ADDR", "REMOTE_HOST"]
result = get_first_remote_host_from_headers(request, headers)
# Should have first entry from X-Forwarded-For plus the single values from other headers
assert result == {"client.example.com", "172.16.0.1", "proxy2.example.com"}
# Should NOT have subsequent entries from X-Forwarded-For
assert "proxy1.example.com" not in result
def test_empty_header_value(self):
"""Test handling of empty header values."""
request = self._make_mock_request(
{
"HTTP_X_FORWARDED_FOR": "",
"REMOTE_ADDR": "192.168.1.1",
}
)
headers = ["HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"]
result = get_first_remote_host_from_headers(request, headers)
assert result == {"192.168.1.1"}
def test_missing_header(self):
"""Test handling of headers that don't exist in environ."""
request = self._make_mock_request(
{
"REMOTE_ADDR": "192.168.1.1",
}
)
headers = ["HTTP_X_FORWARDED_FOR", "REMOTE_ADDR", "REMOTE_HOST"]
result = get_first_remote_host_from_headers(request, headers)
assert result == {"192.168.1.1"}
def test_empty_headers_list(self):
"""Test with no headers specified."""
request = self._make_mock_request(
{
"REMOTE_ADDR": "192.168.1.1",
}
)
headers = []
result = get_first_remote_host_from_headers(request, headers)
assert result == set()
def test_whitespace_only_first_entry(self):
"""Test handling when first entry is whitespace only."""
request = self._make_mock_request(
{
"HTTP_X_FORWARDED_FOR": " , 192.168.1.1",
}
)
headers = ["HTTP_X_FORWARDED_FOR"]
result = get_first_remote_host_from_headers(request, headers)
# Empty/whitespace first entry should be skipped
assert result == set()
def test_single_entry_with_trailing_comma(self):
"""Test single entry that happens to have a trailing comma."""
request = self._make_mock_request(
{
"HTTP_X_FORWARDED_FOR": "10.0.0.1,",
}
)
headers = ["HTTP_X_FORWARDED_FOR"]
result = get_first_remote_host_from_headers(request, headers)
assert result == {"10.0.0.1"}
class TestIsProxyInHeaders:
"""Tests for is_proxy_in_headers function."""
def _make_mock_request(self, environ):
"""Create a mock request with the given environ dict."""
request = mock.MagicMock()
request.environ = environ
return request
def test_proxy_found_in_single_value(self):
"""Test proxy detection in single-value header."""
request = self._make_mock_request(
{
"REMOTE_ADDR": "192.168.1.1",
}
)
result = is_proxy_in_headers(request, ["192.168.1.1"], ["REMOTE_ADDR"])
assert result is True
def test_proxy_found_in_comma_separated(self):
"""Test proxy detection in comma-separated header value."""
request = self._make_mock_request(
{
"HTTP_X_FORWARDED_FOR": "10.0.0.1, 192.168.1.1, 172.16.0.1",
}
)
result = is_proxy_in_headers(request, ["192.168.1.1"], ["HTTP_X_FORWARDED_FOR"])
assert result is True
def test_proxy_not_found(self):
"""Test when proxy is not in any header."""
request = self._make_mock_request(
{
"REMOTE_ADDR": "10.0.0.1",
}
)
result = is_proxy_in_headers(request, ["192.168.1.1"], ["REMOTE_ADDR"])
assert result is False
def test_multiple_proxies_one_match(self):
"""Test with multiple allowed proxies, one matches."""
request = self._make_mock_request(
{
"REMOTE_HOST": "proxy.example.com",
}
)
result = is_proxy_in_headers(
request,
["proxy1.example.com", "proxy.example.com", "proxy2.example.com"],
["REMOTE_HOST"],
)
assert result is True

View File

@@ -48,15 +48,16 @@ def could_be_playbook(project_path, dir_path, filename):
# show up.
matched = False
try:
for n, line in enumerate(codecs.open(playbook_path, 'r', encoding='utf-8', errors='ignore')):
if valid_playbook_re.match(line):
matched = True
break
# Any YAML file can also be encrypted with vault;
# allow these to be used as the main playbook.
elif n == 0 and line.startswith('$ANSIBLE_VAULT;'):
matched = True
break
with codecs.open(playbook_path, 'r', encoding='utf-8', errors='ignore') as f:
for n, line in enumerate(f):
if valid_playbook_re.match(line):
matched = True
break
# Any YAML file can also be encrypted with vault;
# allow these to be used as the main playbook.
elif n == 0 and line.startswith('$ANSIBLE_VAULT;'):
matched = True
break
except IOError:
return None
if not matched:

View File

@@ -45,38 +45,3 @@ def delete_headers_starting_with_http(request: Request, headers: list[str]):
for header in headers:
if header.startswith('HTTP_'):
request.environ.pop(header, None)
def get_first_remote_host_from_headers(request: Request, headers: list[str]) -> set[str]:
"""
Extract remote host addresses from headers, considering only the first entry
in comma-separated values.
For headers like X-Forwarded-For that may contain multiple IPs (e.g., "client, proxy1, proxy2"),
only the first entry (the original client) is considered.
Example:
request.environ = {
"HTTP_X_FORWARDED_FOR": "10.0.0.1, 192.168.1.1, 172.16.0.1",
"REMOTE_ADDR": "192.168.1.1",
"REMOTE_HOST": "proxy.example.com"
}
headers = ["HTTP_X_FORWARDED_FOR", "REMOTE_ADDR", "REMOTE_HOST"]
Returns: {"10.0.0.1", "192.168.1.1", "proxy.example.com"}
(Only the first IP "10.0.0.1" from X-Forwarded-For, not the full chain)
request: The DRF/Django request. request.environ dict will be used for extracting hosts
headers: A list of header keys to check for remote host values
"""
remote_hosts = set()
for header in headers:
header_value = request.environ.get(header, '')
if header_value:
# Only take the first entry if comma-separated
first_value = header_value.split(',')[0].strip()
if first_value:
remote_hosts.add(first_value)
return remote_hosts

View File

@@ -21,8 +21,11 @@ DOCUMENTATION = '''
import os
import json
import re
from importlib.resources import files
from packaging.version import Version, InvalidVersion
from ansible.plugins.callback import CallbackBase
# NOTE: in Ansible 1.2 or later general logging is available without
@@ -52,6 +55,91 @@ def list_collections(artifacts_manager=None):
return collections
# External query path constants
EXTERNAL_QUERY_COLLECTION = 'ansible_collections.redhat.indirect_accounting'
EXTERNAL_QUERY_PATH = 'extensions/audit/external_queries'
def list_external_queries(namespace, name):
"""List all available external query versions for a collection.
Returns a list of Version objects for all available query files
matching the namespace.name pattern.
"""
versions = []
try:
queries_dir = files(EXTERNAL_QUERY_COLLECTION) / 'extensions' / 'audit' / 'external_queries'
except ModuleNotFoundError:
return versions
# Pattern: namespace.name.X.Y.Z.yml where X.Y.Z is the version
pattern = re.compile(rf'^{re.escape(namespace)}\.{re.escape(name)}\.(.+)\.yml$')
for query_file in queries_dir.iterdir():
match = pattern.match(query_file.name)
if match:
version_str = match.group(1)
try:
versions.append(Version(version_str))
except InvalidVersion:
# Skip files with invalid version strings
pass
return versions
def find_external_query_with_fallback(namespace, name, installed_version, display=None):
"""Find external query file with semantic version fallback.
Args:
namespace: Collection namespace (e.g., 'community')
name: Collection name (e.g., 'vmware')
installed_version: Version string of installed collection (e.g., '4.5.0')
display: Ansible display object for logging
Returns:
Tuple of (query_content, fallback_used, fallback_version) or (None, False, None)
- query_content: The query file content if found
- fallback_used: True if a fallback version was used instead of exact match
- fallback_version: The version string used (for logging)
"""
try:
installed_version_object = Version(installed_version)
except InvalidVersion:
# Invalid version string - can't do version comparison
return None, False, None
try:
queries_dir = files(EXTERNAL_QUERY_COLLECTION) / 'extensions' / 'audit' / 'external_queries'
except ModuleNotFoundError:
return None, False, None
# 1. Try exact version match first (AC5.2)
exact_file = queries_dir / f'{namespace}.{name}.{installed_version}.yml'
if exact_file.exists():
with exact_file.open('r') as f:
return f.read(), False, installed_version
# 2. Find compatible fallback (same major version, nearest lower version)
available_versions = list_external_queries(namespace, name)
if not available_versions:
return None, False, None
# Filter to same major version and versions <= installed version (AC5.3, AC5.5)
compatible_versions = [v for v in available_versions if v.major == installed_version_object.major and v <= installed_version_object]
if not compatible_versions:
# No compatible fallback exists (AC5.7)
return None, False, None
# Select nearest lower version - highest compatible version (AC5.4)
fallback_version_object = max(compatible_versions)
fallback_version_str = str(fallback_version_object)
fallback_file = queries_dir / f'{namespace}.{name}.{fallback_version_str}.yml'
if fallback_file.exists():
with fallback_file.open('r') as f:
return f.read(), True, fallback_version_str
return None, False, None
class CallbackModule(CallbackBase):
"""
logs playbook results, per host, in /var/log/ansible/hosts
@@ -81,6 +169,17 @@ class CallbackModule(CallbackBase):
if query_file.exists():
with query_file.open('r') as f:
collection_print['host_query'] = f.read()
self._display.vv(f"Using embedded query for {candidate.fqcn} v{candidate.ver}")
else:
# 2. Check for external query file with version fallback
query_content, fallback_used, version_used = find_external_query_with_fallback(candidate.namespace, candidate.name, candidate.ver)
if query_content:
collection_print['host_query'] = query_content
if fallback_used:
# AC5.6: Log when fallback is used
self._display.v(f"Using external query {version_used} for {candidate.fqcn} v{candidate.ver}.")
else:
self._display.v(f"Using external query for {candidate.fqcn} v{candidate.ver}")
collections_print[candidate.fqcn] = collection_print

View File

@@ -774,7 +774,7 @@ LOGGING = {
'awx.conf.settings': {'handlers': ['null'], 'level': 'WARNING'},
'awx.main': {'handlers': ['null']},
'awx.main.commands.run_callback_receiver': {'handlers': ['callback_receiver'], 'level': 'INFO'}, # very noisey debug-level logs
'awx.main.dispatch': {'handlers': ['dispatcher']},
'awx.main.dispatch': {'handlers': ['task_system']},
'awx.main.consumers': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'INFO'},
'awx.main.rsyslog_configurer': {'handlers': ['rsyslog_configurer']},
'awx.main.cache_clear': {'handlers': ['cache_clear']},

View File

@@ -12,15 +12,6 @@ class ConnectionException(exc.Common):
pass
class TokenAuth(requests.auth.AuthBase):
def __init__(self, token):
self.token = token
def __call__(self, request):
request.headers['Authorization'] = 'Bearer {0.token}'.format(self)
return request
def log_elapsed(r, *args, **kwargs): # requests hook to display API elapsed time
log.debug('"{0.request.method} {0.url}" elapsed: {0.elapsed}'.format(r))
@@ -46,7 +37,7 @@ class Connection(object):
self.get(config.api_base_path) # this causes a cookie w/ the CSRF token to be set
return dict(next=next)
def login(self, username=None, password=None, token=None, **kwargs):
def login(self, username=None, password=None, **kwargs):
if username and password:
_next = kwargs.get('next')
if _next:
@@ -58,11 +49,14 @@ class Connection(object):
self.session_cookie_name = historical_response.headers.get('X-API-Session-Cookie-Name')
self.session_id = self.session.cookies.get(self.session_cookie_name, None)
if self.session_id is None and config.get("api_base_path") == "/api/controller/":
# Use gateway session cookie name if controller session cookie name is not found
self.session_cookie_name = "gateway_sessionid"
self.session_id = self.session.cookies.get(self.session_cookie_name, None)
self.uses_session_cookie = True
else:
self.session.auth = (username, password)
elif token:
self.session.auth = TokenAuth(token)
else:
self.session.auth = None

View File

@@ -31,7 +31,23 @@ class User(HasCreate, base.Base):
payload = self.create_payload(username=username, password=password, **kwargs)
self.password = payload.password
self.update_identity(Users(self.connection).post(payload))
ctrl_users_api = Users(self.connection)
# Check if API base path is set to controller, then use gateway endpoint
if config.get("api_base_path") == "/api/controller/":
# Use gateway endpoint for user creation
gw_users_api = Users(self.connection)
gw_users_api.endpoint = "/api/gateway/v1/users/"
# Cleanup controller attributes
payload["is_platform_auditor"] = payload.get("is_system_auditor")
payload.pop("is_system_auditor")
# Create gw user
gw_user = gw_users_api.post(payload)
user = ctrl_users_api.get(username=gw_user.username).results.pop()
user.json["password"] = payload.password
self.update_identity(user)
else:
# Use default endpoint
self.update_identity(ctrl_users_api.post(payload))
if organization:
organization.add_user(self)

View File

@@ -83,23 +83,12 @@ class CLI(object):
def authenticate(self):
"""Configure the current session for authentication.
Authentication priority:
1. Token authentication (if --conf.token provided)
2. Basic authentication (if AWXKIT_FORCE_BASIC_AUTH=true)
3. Session-based authentication (default)
Uses Basic authentication when AWXKIT_FORCE_BASIC_AUTH environment variable
is set to true, otherwise defaults to session-based authentication.
For AAP Gateway environments, set AWXKIT_FORCE_BASIC_AUTH=true to bypass
session login restrictions when using username/password.
session login restrictions.
"""
# Token authentication (if token is provided)
token = self.get_config('token')
if token:
config.use_sessions = False
self.root.connection.login(None, None, token=token)
return
# Check if Basic auth is forced via environment variable
if config.get('force_basic_auth', False):
config.use_sessions = False

View File

@@ -59,12 +59,6 @@ def add_authentication_arguments(parser, env):
default=env.get('CONTROLLER_PASSWORD', env.get('TOWER_PASSWORD', config_password)),
metavar='TEXT',
)
auth.add_argument(
'--conf.token',
default=env.get('CONTROLLER_OAUTH_TOKEN', env.get('TOWER_OAUTH_TOKEN', None)),
metavar='TEXT',
help='OAuth2 token for authentication (takes precedence over username/password)',
)
auth.add_argument(
'-k',

View File

@@ -44,48 +44,6 @@ def setup_session_auth(cli_args: Optional[List[str]] = None) -> Tuple[CLI, Mock,
return cli, mock_root, mock_load_session
def setup_token_auth(cli_args: Optional[List[str]] = None) -> Tuple[CLI, Mock, Mock]:
"""Set up CLI with mocked connection for Token auth testing"""
cli = CLI()
cli.parse_args(cli_args or ['awx', '--conf.token', 'test-token-abc123'])
mock_root = Mock()
mock_connection = Mock()
mock_root.connection = mock_connection
cli.root = mock_root
return cli, mock_root, mock_connection
def test_token_auth_preserved(monkeypatch):
"""
REGRESSION TEST: Token authentication must still work (existed in 4.6.12)
This test documents the customer's working scenario from 4.6.12:
awx login --conf.host URL --conf.username USER --conf.password PASS
# Returns: {"token": "E*******J"}
awx --conf.host URL --conf.token E*******J job_templates launch ...
# This WORKED in 4.6.12
BREAKING CHANGE: Version 4.6.21 removed token authentication entirely,
causing customer to report: "neither token no username/password are working"
This test will FAIL with current code and PASS once fixed.
"""
cli, mock_root, mock_connection = setup_token_auth(['awx', '--conf.host', 'https://aap-sbx.testbank.com', '--conf.token', 'E1234567890J'])
monkeypatch.setattr(config, 'force_basic_auth', False)
# Execute authentication
cli.authenticate()
# Token auth should call login with token parameter
mock_connection.login.assert_called_once_with(None, None, token='E1234567890J')
# Should NOT use sessions when token is provided
assert not config.use_sessions
def test_basic_auth_enabled(monkeypatch):
"""Test that AWXKIT_FORCE_BASIC_AUTH=true enables Basic authentication"""
cli, mock_root, mock_connection = setup_basic_auth()

View File

@@ -0,0 +1,195 @@
# External Query Files for Indirect Node Counting
This document describes how to create query files for the Indirect Node Counting feature. Query files define how to extract managed node information from Ansible module execution results.
## Overview
When Ansible modules interact with external systems (VMware, cloud providers, network devices, etc.), they may manage nodes that aren't in the Ansible inventory. Query files tell the Controller how to extract information about these "indirect" managed nodes from module execution data.
## Query File Types
There are two types of query files:
1. **Embedded Query Files**: Shipped within a collection at `extensions/audit/event_query.yml`
2. **External Query Files**: Shipped in the `redhat.indirect_accounting` collection at `extensions/audit/external_queries/<namespace>.<name>.<version>.yml`
Embedded queries take precedence over external queries. External queries support version fallback within the same major version.
## File Format
Query files are YAML documents that map fully-qualified module names to jq expressions.
### Basic Structure
```yaml
---
<namespace>.<collection>.<module_name>:
query: >-
<jq_expression>
```
### Example
```yaml
---
community.vmware.vmware_guest:
query: >-
{name: .instance.hw_name, canonical_facts: {host_name: .instance.hw_name, uuid: .instance.hw_product_uuid}, facts: {guest_id: .instance.hw_guest_id}}
```
## jq Expression Requirements
The jq expression processes the module's result data (`event_data.res`) and must output a JSON object with the following fields:
### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Display name of the indirect managed node |
| `canonical_facts` | object | Facts used for node deduplication across jobs |
### Optional Fields
| Field | Type | Description |
|-------|------|-------------|
| `facts` | object | Additional information about the managed node |
### canonical_facts
The `canonical_facts` object should contain fields that uniquely identify the managed node. Common examples:
- `host_name`: The hostname of the managed node
- `uuid`: A unique identifier (VM UUID, device serial number, etc.)
- `ip_address`: IP address if it uniquely identifies the node
These facts are used to deduplicate nodes across multiple job runs. Choose facts that remain stable across the node's lifecycle.
### facts
The `facts` object contains additional metadata that doesn't affect deduplication:
- `device_type`: Type of device (e.g., "virtual_machine", "network_switch")
- `guest_id`: Guest OS identifier
- `platform`: Platform information
## jq Expression Input
The jq expression receives the module's result data as input. This is the `res` field from Ansible's job event data, which typically contains:
- The module's return values
- Any registered variables
- Status information
To understand what data is available, examine the module's documentation or run a test playbook and inspect the job events.
## Module Matching
### Exact Match
Queries are matched by fully-qualified module name:
```yaml
community.vmware.vmware_guest:
query: >-
...
```
This matches only `community.vmware.vmware_guest` module invocations.
### Wildcard Match
You can use wildcards to match all modules in a collection:
```yaml
community.vmware.*:
query: >-
...
```
Exact matches take precedence over wildcard matches.
## External Query File Naming
External query files must follow this naming convention:
```
<namespace>.<collection_name>.<version>.yml
```
Examples:
- `community.vmware.4.5.0.yml`
- `cisco.ios.8.0.0.yml`
- `amazon.aws.7.2.1.yml`
## Version Fallback
When no exact version match exists for an external query, the system falls back to the nearest compatible version:
1. Only versions with the **same major version** are considered
2. The **highest version less than or equal to** the installed version is selected
3. Major version boundaries are never crossed
### Examples
| Installed Version | Available Queries | Query Used | Reason |
|-------------------|-------------------|------------|--------|
| 4.5.0 | 4.0.0, 4.1.0, 5.0.0 | 4.1.0 | Highest v4.x <= 4.5.0 |
| 4.0.5 | 4.0.0, 4.1.0, 5.0.0 | 4.0.0 | 4.1.0 > 4.0.5, so 4.0.0 |
| 5.2.0 | 4.0.0, 4.1.0, 5.0.0 | 5.0.0 | Highest v5.x <= 5.2.0 |
| 3.8.0 | 4.0.0, 4.1.0, 5.0.0 | None | No v3.x queries available |
| 6.0.0 | 4.0.0, 4.1.0, 5.0.0 | None | No v6.x queries available |
## Complete Example
Here's a complete external query file for `community.vmware` version 4.5.0:
**File**: `extensions/audit/external_queries/community.vmware.4.5.0.yml`
```yaml
---
# Query for vmware_guest module - extracts VM information
community.vmware.vmware_guest:
query: >-
{name: .instance.hw_name, canonical_facts: {host_name: .instance.hw_name, uuid: .instance.hw_product_uuid}, facts: {guest_id: .instance.hw_guest_id, num_cpus: .instance.hw_processor_count}}
# Query for vmware_guest_info module
community.vmware.vmware_guest_info:
query: >-
{name: .instance.hw_name, canonical_facts: {host_name: .instance.hw_name, uuid: .instance.hw_product_uuid}, facts: {power_state: .instance.hw_power_status}}
```
## Testing Query Files
To test a query file:
1. Run a playbook that uses the target module
2. Examine the job events to see the module's result data
3. Test your jq expression against the result data using the `jq` command-line tool
4. Verify the output contains valid `name` and `canonical_facts` fields
Example testing with jq:
```bash
# Sample module result data (from job event)
echo '{"instance": {"hw_name": "test-vm", "hw_product_uuid": "abc-123"}}' | \
jq '{name: .instance.hw_name, canonical_facts: {host_name: .instance.hw_name, uuid: .instance.hw_product_uuid}}'
```
## Troubleshooting
### Query Not Being Applied
1. Verify the file is in the correct location
2. Check the file naming matches the collection namespace, name, and version exactly
3. Ensure the module name in the query matches the fully-qualified module name
### No Indirect Nodes Counted
1. Verify the jq expression produces valid output with `canonical_facts`
2. Check the Controller logs for jq parsing errors
3. Ensure the module's result data contains the expected fields
### Version Fallback Not Working
1. Verify the fallback version has the same major version as the installed collection
2. Check that the fallback version is less than or equal to the installed version

View File

@@ -35,10 +35,6 @@ filterwarnings =
# FIXME: and is no longer imported at runtime.
once:CoreAPI compatibility is deprecated and will be removed in DRF 3.17:rest_framework.RemovedInDRF317Warning:rest_framework.schemas.coreapi
# FIXME: Delete this entry once naive dates aren't passed to DB lookup
# FIXME: methods. Not sure where, might be in awx's views or in DAB.
once:DateTimeField User.date_joined received a naive datetime .2020-01-01 00.00.00. while time zone support is active.:RuntimeWarning:django.db.models.fields
# FIXME: Delete this entry once the deprecation is acted upon.
# Note: RemovedInDjango51Warning may not exist in newer Django versions
ignore:'index_together' is deprecated. Use 'Meta.indexes' in 'main.\w+' instead.
@@ -47,12 +43,6 @@ filterwarnings =
# Note: RemovedInDjango50Warning may not exist in newer Django versions
ignore:Using QuerySet.iterator.. after prefetch_related.. without specifying chunk_size is deprecated.
# FIXME: Delete this entry once the **broken** always-true assertions in the
# FIXME: following tests are fixed:
# * `awx/main/tests/unit/utils/test_common.py::TestHostnameRegexValidator::test_good_call`
# * `awx/main/tests/unit/utils/test_common.py::TestHostnameRegexValidator::test_bad_call_with_inverse`
once:assertion is always true, perhaps remove parentheses\?:pytest.PytestAssertRewriteWarning:
# FIXME: Figure this out, fix and then delete the entry. It's not entirely
# FIXME: clear what emits it and where.
once:Pagination may yield inconsistent results with an unordered object_list. .class 'awx.main.models.workflow.WorkflowJobTemplateNode'. QuerySet.:django.core.paginator.UnorderedObjectListWarning:django.core.paginator
@@ -60,11 +50,6 @@ filterwarnings =
# FIXME: Figure this out, fix and then delete the entry.
once::django.core.paginator.UnorderedObjectListWarning:rest_framework.pagination
# FIXME: Use `codecs.open()` via a context manager
# FIXME: in `awx/main/utils/ansible.py` to close hanging file descriptors
# FIXME: and then delete the entry.
once:unclosed file <_io.BufferedReader name='[^']+'>:ResourceWarning:awx.main.utils.ansible
# FIXME: Use `open()` via a context manager
# FIXME: in `awx/main/tests/unit/test_tasks.py` to close hanging file
# FIXME: descriptors and then delete the entry.

View File

@@ -116,7 +116,7 @@ cython==3.1.3
# via -r /awx_devel/requirements/requirements.in
daphne==4.2.1
# via -r /awx_devel/requirements/requirements.in
dispatcherd[pg-notify]==2026.01.27
dispatcherd[pg-notify]==2026.02.26
# via -r /awx_devel/requirements/requirements.in
distro==1.9.0
# via -r /awx_devel/requirements/requirements.in