diff --git a/awx/main/conf.py b/awx/main/conf.py index 85b640c83f..1babd1b70c 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -34,6 +34,16 @@ register( category_slug='system', ) +register( + 'INDIRECT_NODE_COUNTING_ENABLED', + field_class=fields.BooleanField, + default=True, + label=_('Enable Indirect Node Counting'), + help_text=_('Controls whether Controller processes indirect managed nodes reported by jobs.'), + category=_('System'), + category_slug='system', +) + register( 'ORG_ADMINS_CAN_SEE_ALL_USERS', field_class=fields.BooleanField, diff --git a/awx/main/management/commands/set_indirect_node_counting.py b/awx/main/management/commands/set_indirect_node_counting.py new file mode 100644 index 0000000000..ca4caaf74d --- /dev/null +++ b/awx/main/management/commands/set_indirect_node_counting.py @@ -0,0 +1,21 @@ +from django.conf import settings +from django.core.management.base import BaseCommand + +from awx.main.tasks.system import clear_setting_cache + + +class Command(BaseCommand): + help = 'Enable or disable indirect node counting.' + + def add_arguments(self, parser): + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--enable', action='store_true', help='Enable indirect node counting.') + group.add_argument('--disable', action='store_true', help='Disable indirect node counting.') + + def handle(self, **options): + enabled = options['enable'] + + settings.INDIRECT_NODE_COUNTING_ENABLED = enabled + clear_setting_cache.delay(['INDIRECT_NODE_COUNTING_ENABLED']) + state = 'enabled' if enabled else 'disabled' + self.stdout.write(f'Indirect node counting is {state}.') diff --git a/awx/main/tasks/callback.py b/awx/main/tasks/callback.py index 266fd22015..6e86718a5f 100644 --- a/awx/main/tasks/callback.py +++ b/awx/main/tasks/callback.py @@ -20,8 +20,6 @@ from awx.main.constants import MINIMAL_EVENTS, ANSIBLE_RUNNER_NEEDS_UPDATE_MESSA from awx.main.utils.update_model import update_model from awx.main.queue import CallbackQueueDispatcher -from flags.state import flag_enabled - logger = logging.getLogger('awx.main.tasks.callback') @@ -284,7 +282,7 @@ class RunnerCallback: else: logger.warning(f'The file {COLLECTION_FILENAME} unexpectedly did not contain ansible_version') - if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"): + if settings.INDIRECT_NODE_COUNTING_ENABLED: self.delay_update(event_queries_processed=False) collections_info = collect_queries(query_file_contents) for collection, data in collections_info.items(): diff --git a/awx/main/tasks/host_indirect.py b/awx/main/tasks/host_indirect.py index 1d7f8919d6..88e9bb1a93 100644 --- a/awx/main/tasks/host_indirect.py +++ b/awx/main/tasks/host_indirect.py @@ -9,9 +9,6 @@ from django.utils.timezone import now, timedelta from django.conf import settings from django.db import transaction -# Django flags -from flags.state import flag_enabled - from dispatcherd.publish import task from awx.main.dispatch import get_task_queuename from awx.main.models.indirect_managed_node_audit import IndirectManagedNodeAudit @@ -166,6 +163,10 @@ def cleanup_old_indirect_host_entries() -> None: @task(queue=get_task_queuename, timeout=3600 * 5) def save_indirect_host_entries(job_id: int, wait_for_events: bool = True) -> None: + if not settings.INDIRECT_NODE_COUNTING_ENABLED: + Job.objects.filter(id=job_id, event_queries_processed=False).update(event_queries_processed=True) + return + try: job = Job.objects.get(id=job_id) except Job.DoesNotExist: @@ -203,7 +204,7 @@ def save_indirect_host_entries(job_id: int, wait_for_events: bool = True) -> Non @task(queue=get_task_queuename, timeout=3600 * 5) def cleanup_and_save_indirect_host_entries_fallback() -> None: - if not flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"): + if not settings.INDIRECT_NODE_COUNTING_ENABLED: return try: diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 3a6b5a3064..15fa91906f 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -428,7 +428,7 @@ class BaseTask(object): # Copy vendor collections to private_data_dir for indirect node counting # This makes external query files available to the callback plugin in EEs - if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"): + if settings.INDIRECT_NODE_COUNTING_ENABLED: vendor_src = '/var/lib/awx/vendor_collections' vendor_dest = os.path.join(private_data_dir, 'vendor_collections') if os.path.exists(vendor_src): @@ -1195,7 +1195,7 @@ class RunJob(SourceControlMixin, BaseTask): if 'callbacks_enabled' in config_values: env['ANSIBLE_CALLBACKS_ENABLED'] += ',' + config_values['callbacks_enabled'] - if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"): + if settings.INDIRECT_NODE_COUNTING_ENABLED: env['AWX_COLLECT_HOST_QUERIES'] = '1' # Add vendor collections path for external query file discovery vendor_collections_path = os.path.join(CONTAINER_ROOT, 'vendor_collections') diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index 08419a80fe..362ff45c29 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -44,8 +44,6 @@ from django.utils.timezone import now, timedelta from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_noop -# Django flags -from flags.state import flag_enabled from rest_framework.exceptions import PermissionDenied # AWX @@ -435,7 +433,10 @@ def events_processed_hook(unified_job): after the playbook_on_stats/EOF event is processed and final status is saved Either one of these events could happen before the other, or there may be no events""" unified_job.send_notification_templates('succeeded' if unified_job.status == 'successful' else 'failed') - if isinstance(unified_job, Job) and flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"): + if isinstance(unified_job, Job): + if not settings.INDIRECT_NODE_COUNTING_ENABLED: + Job.objects.filter(id=unified_job.id, event_queries_processed=False).update(event_queries_processed=True) + return if unified_job.event_queries_processed is True: # If this is called from callback receiver, it likely does not have updated model data # a refresh now is formally robust diff --git a/awx/main/tests/functional/commands/test_set_indirect_node_counting.py b/awx/main/tests/functional/commands/test_set_indirect_node_counting.py new file mode 100644 index 0000000000..887a208a65 --- /dev/null +++ b/awx/main/tests/functional/commands/test_set_indirect_node_counting.py @@ -0,0 +1,23 @@ +import pytest +from django.conf import settings +from django.core.management import call_command + +from awx.conf.models import Setting + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ('option', 'expected'), + [ + ('--enable', True), + ('--disable', False), + ], +) +def test_set_indirect_node_counting_persists_setting(option, expected, mocker): + mocker.patch('awx.main.management.commands.set_indirect_node_counting.clear_setting_cache') + + call_command('set_indirect_node_counting', option) + + assert Setting.objects.get(key='INDIRECT_NODE_COUNTING_ENABLED', user=None).value is expected + settings._awx_conf_memoizedcache.clear() + assert settings.INDIRECT_NODE_COUNTING_ENABLED is expected diff --git a/awx/main/tests/functional/dab_feature_flags/test_feature_flags_api.py b/awx/main/tests/functional/dab_feature_flags/test_feature_flags_api.py index fb483000fb..7071179da0 100644 --- a/awx/main/tests/functional/dab_feature_flags/test_feature_flags_api.py +++ b/awx/main/tests/functional/dab_feature_flags/test_feature_flags_api.py @@ -21,7 +21,7 @@ def test_feature_flags_list_endpoint_override(get, flag_val): bob = User.objects.create(username='bob', password='test_user', is_superuser=True) AAPFlag.objects.all().delete() - flag_name = "FEATURE_INDIRECT_NODE_COUNTING_ENABLED" + flag_name = "FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED" setattr(settings, flag_name, flag_val) seed_feature_flags() url = "/api/v2/feature_flags/states/" diff --git a/awx/main/tests/functional/tasks/test_host_indirect.py b/awx/main/tests/functional/tasks/test_host_indirect.py index 798d6cbe4f..549b2c07fe 100644 --- a/awx/main/tests/functional/tasks/test_host_indirect.py +++ b/awx/main/tests/functional/tasks/test_host_indirect.py @@ -15,6 +15,7 @@ from awx.main.tasks.host_indirect import ( save_indirect_host_entries, cleanup_and_save_indirect_host_entries_fallback, ) +from awx.main.tasks.system import events_processed_hook from awx.main.models.event_query import EventQuery from awx.main.models.indirect_managed_node_audit import IndirectManagedNodeAudit @@ -90,6 +91,32 @@ def create_audit_record(name, job, organization, created=now()): return record +@pytest.mark.django_db +def test_save_indirect_host_entries_skips_when_disabled(bare_job, event_query, settings): + create_registered_event(bare_job) + settings.INDIRECT_NODE_COUNTING_ENABLED = False + + save_indirect_host_entries(bare_job.id, wait_for_events=False) + + bare_job.refresh_from_db() + assert bare_job.event_queries_processed is True + assert not IndirectManagedNodeAudit.objects.filter(job=bare_job).exists() + + +@pytest.mark.django_db +def test_events_processed_hook_discards_pending_counting_when_disabled(bare_job, settings, mocker): + settings.INDIRECT_NODE_COUNTING_ENABLED = False + mock_delay = mocker.patch('awx.main.tasks.system.save_indirect_host_entries.delay') + mock_refresh = mocker.patch.object(bare_job, 'refresh_from_db') + mocker.patch('awx.main.models.notifications.JobNotificationMixin.send_notification_templates') + + events_processed_hook(bare_job) + + assert Job.objects.get(id=bare_job.id).event_queries_processed is True + mock_refresh.assert_not_called() + mock_delay.assert_not_called() + + @pytest.fixture def event_query(): "This is ordinarily created by the artifacts callback" diff --git a/awx/main/tests/live/tests/test_indirect_counting_external_queries.py b/awx/main/tests/live/tests/test_indirect_counting_external_queries.py index 003b7d7da2..4fed0eca9a 100644 --- a/awx/main/tests/live/tests/test_indirect_counting_external_queries.py +++ b/awx/main/tests/live/tests/test_indirect_counting_external_queries.py @@ -18,7 +18,6 @@ import time import yaml import pytest -from flags.state import enable_flag, disable_flag, flag_enabled from awx.main.tests.live.tests.conftest import wait_for_events, unified_job_stdout from awx.main.tasks.host_indirect import save_indirect_host_entries @@ -49,23 +48,6 @@ VENDOR_COLLECTIONS_BASE = '/var/lib/awx/vendor_collections' # --- Fixtures --- -@pytest.fixture -def enable_indirect_host_counting(): - """Enable FEATURE_INDIRECT_NODE_COUNTING_ENABLED flag for the test. - - Only creates a FlagState DB record if the flag isn't already enabled - (e.g. via development_defaults.py), to avoid UniqueViolation errors - and to avoid leaking state to other tests. - """ - flag_name = "FEATURE_INDIRECT_NODE_COUNTING_ENABLED" - was_enabled = flag_enabled(flag_name) - if not was_enabled: - enable_flag(flag_name) - yield - if not was_enabled: - disable_flag(flag_name) - - @pytest.fixture def vendor_collections_dir(): """Set up mock redhat.indirect_accounting collection at /var/lib/awx/vendor_collections/. @@ -166,7 +148,7 @@ def wait_for_indirect_processing(job, expect_records=True, timeout=5): # --- AC8.1: External query populates IndirectManagedNodeAudit correctly --- -def test_external_query_populates_audit_table(live_tmp_folder, run_job_from_playbook, enable_indirect_host_counting, vendor_collections_dir): +def test_external_query_populates_audit_table(live_tmp_folder, run_job_from_playbook, vendor_collections_dir): """AC8.1: Job using demo.external.example with external query file populates IndirectManagedNodeAudit table correctly. @@ -198,7 +180,7 @@ def test_external_query_populates_audit_table(live_tmp_folder, run_job_from_play # --- AC8.2: Precedence - embedded query takes precedence over external --- -def test_embedded_query_takes_precedence(live_tmp_folder, run_job_from_playbook, enable_indirect_host_counting, vendor_collections_dir): +def test_embedded_query_takes_precedence(live_tmp_folder, run_job_from_playbook, vendor_collections_dir): """AC8.2: When collection has both embedded and external query files, the embedded query takes precedence. @@ -225,7 +207,7 @@ def test_embedded_query_takes_precedence(live_tmp_folder, run_job_from_playbook, # --- AC8.3: Version fallback to compatible version --- -def test_fallback_to_compatible_version(live_tmp_folder, run_job_from_playbook, enable_indirect_host_counting, vendor_collections_dir): +def test_fallback_to_compatible_version(live_tmp_folder, run_job_from_playbook, vendor_collections_dir): """AC8.3: Job using collection version with no exact query file falls back correctly to compatible version. @@ -257,7 +239,7 @@ def test_fallback_to_compatible_version(live_tmp_folder, run_job_from_playbook, # --- AC8.4: Fallback queries don't overcount --- -def test_fallback_does_not_overcount(live_tmp_folder, run_job_from_playbook, enable_indirect_host_counting, vendor_collections_dir): +def test_fallback_does_not_overcount(live_tmp_folder, run_job_from_playbook, vendor_collections_dir): """AC8.4: Fallback queries don't count MORE nodes than exact-version queries. Runs two jobs: @@ -298,7 +280,7 @@ def test_fallback_does_not_overcount(live_tmp_folder, run_job_from_playbook, ena # --- AC8.5: Warning logs contain correct version information --- -def test_fallback_log_contains_version_info(live_tmp_folder, run_job_from_playbook, enable_indirect_host_counting, vendor_collections_dir): +def test_fallback_log_contains_version_info(live_tmp_folder, run_job_from_playbook, vendor_collections_dir): """AC8.5: Warning logs contain correct version information when fallback is used. Runs a job with verbosity=1 so callback plugin verbose output is captured. @@ -326,7 +308,7 @@ def test_fallback_log_contains_version_info(live_tmp_folder, run_job_from_playbo # --- AC8.6: No counting when no compatible fallback exists --- -def test_no_counting_without_compatible_fallback(live_tmp_folder, run_job_from_playbook, enable_indirect_host_counting, vendor_collections_dir): +def test_no_counting_without_compatible_fallback(live_tmp_folder, run_job_from_playbook, vendor_collections_dir): """AC8.6: No counting occurs when no compatible fallback exists. Uses demo.external v3.0.0 with only v1.x external query files available. diff --git a/awx/main/tests/live/tests/test_indirect_host_counting.py b/awx/main/tests/live/tests/test_indirect_host_counting.py index 2843eaeba5..75f3666ba6 100644 --- a/awx/main/tests/live/tests/test_indirect_host_counting.py +++ b/awx/main/tests/live/tests/test_indirect_host_counting.py @@ -1,6 +1,8 @@ import yaml import time +from django.core.management import call_command + from awx.main.tests.live.tests.conftest import wait_for_events from awx.main.tasks.host_indirect import build_indirect_host_data, save_indirect_host_entries from awx.main.models.indirect_managed_node_audit import IndirectManagedNodeAudit @@ -64,3 +66,55 @@ def test_indirect_host_counting(live_tmp_folder, run_job_from_playbook): assert host_audit.canonical_facts == canonical_facts assert host_audit.facts == facts assert host_audit.organization == job.organization + + +def test_indirect_host_counting_runtime_toggle(live_tmp_folder, run_job_from_playbook): + def run_job(test_name, project=None): + result = run_job_from_playbook( + test_name, + 'run_task.yml', + scm_url=f'file://{live_tmp_folder}/test_host_query', + proj=project, + ) + job = result['job'] + wait_for_events(job) + job.refresh_from_db() + if job.event_queries_processed is False: + save_indirect_host_entries.delay(job.id, wait_for_events=False) + return job, result['project'] + + def assert_audit_records(job, expected): + if expected: + for _ in range(25): + if IndirectManagedNodeAudit.objects.filter(job=job).exists(): + return + time.sleep(0.2) + assert IndirectManagedNodeAudit.objects.filter(job=job).exists() + else: + for _ in range(15): + assert not IndirectManagedNodeAudit.objects.filter(job=job).exists() + time.sleep(0.2) + + def wait_for_setting_propagation(): + # The dispatcher broadcasts setting cache invalidations to every worker. + # Match the wait used by the live-test setting fixture before scheduling + # a job that must observe the new value. + time.sleep(5) + + call_command('set_indirect_node_counting', '--enable') + wait_for_setting_propagation() + try: + enabled_job, project = run_job('indirect_counting_enabled') + assert_audit_records(enabled_job, expected=True) + + call_command('set_indirect_node_counting', '--disable') + wait_for_setting_propagation() + disabled_job, project = run_job('indirect_counting_disabled', project) + assert_audit_records(disabled_job, expected=False) + + call_command('set_indirect_node_counting', '--enable') + wait_for_setting_propagation() + reenabled_job, _ = run_job('indirect_counting_reenabled', project) + assert_audit_records(reenabled_job, expected=True) + finally: + call_command('set_indirect_node_counting', '--enable') diff --git a/awx/main/tests/unit/management/commands/test_set_indirect_node_counting.py b/awx/main/tests/unit/management/commands/test_set_indirect_node_counting.py new file mode 100644 index 0000000000..a5a15afec2 --- /dev/null +++ b/awx/main/tests/unit/management/commands/test_set_indirect_node_counting.py @@ -0,0 +1,25 @@ +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError + + +@pytest.mark.parametrize( + ('option', 'expected'), + [ + ('--enable', True), + ('--disable', False), + ], +) +def test_set_indirect_node_counting(option, expected, mocker): + mock_settings = mocker.patch('awx.main.management.commands.set_indirect_node_counting.settings') + mock_clear_cache = mocker.patch('awx.main.management.commands.set_indirect_node_counting.clear_setting_cache') + + call_command('set_indirect_node_counting', option) + + assert mock_settings.INDIRECT_NODE_COUNTING_ENABLED is expected + mock_clear_cache.delay.assert_called_once_with(['INDIRECT_NODE_COUNTING_ENABLED']) + + +def test_set_indirect_node_counting_requires_an_option(): + with pytest.raises(CommandError, match='one of the arguments'): + call_command('set_indirect_node_counting') diff --git a/awx/main/tests/unit/tasks/test_runner_callback.py b/awx/main/tests/unit/tasks/test_runner_callback.py index 54c964cc1c..2bbeca27d2 100644 --- a/awx/main/tests/unit/tasks/test_runner_callback.py +++ b/awx/main/tests/unit/tasks/test_runner_callback.py @@ -67,27 +67,25 @@ SAMPLE_ANSIBLE_DATA = { class TestTryLoadQueryFile: - def test_loads_file_without_feature_flag(self): + def test_loads_file_when_indirect_node_counting_disabled(self): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, 'ansible_data.json') with open(path, 'w') as f: json.dump(SAMPLE_ANSIBLE_DATA, f) - with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False): - success, data = try_load_query_file(tmpdir) + success, data = try_load_query_file(tmpdir) assert success is True assert data['ansible_version'] == '2.16.0' assert 'ansible.builtin' in data['installed_collections'] - def test_loads_file_with_feature_flag(self): + def test_loads_file_when_indirect_node_counting_enabled(self): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, 'ansible_data.json') with open(path, 'w') as f: json.dump(SAMPLE_ANSIBLE_DATA, f) - with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=True): - success, data = try_load_query_file(tmpdir) + success, data = try_load_query_file(tmpdir) assert success is True assert data == SAMPLE_ANSIBLE_DATA @@ -101,14 +99,14 @@ class TestTryLoadQueryFile: class TestArtifactsHandler: - def test_always_persists_metadata_when_flag_off(self, mock_me): + def test_always_persists_metadata_when_indirect_node_counting_disabled(self, mock_me): rc = RunnerCallback() with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, 'ansible_data.json') with open(path, 'w') as f: json.dump(SAMPLE_ANSIBLE_DATA, f) - with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False): + with mock.patch('awx.main.tasks.callback.settings.INDIRECT_NODE_COUNTING_ENABLED', False): rc.artifacts_handler(tmpdir) assert rc.extra_update_fields['installed_collections'] == SAMPLE_ANSIBLE_DATA['installed_collections'] @@ -117,14 +115,14 @@ class TestArtifactsHandler: assert rc.artifacts_processed is True @mock.patch('awx.main.tasks.callback.EventQuery') - def test_creates_event_queries_when_flag_on(self, mock_event_query, mock_me): + def test_creates_event_queries_when_indirect_node_counting_enabled(self, mock_event_query, mock_me): rc = RunnerCallback() with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, 'ansible_data.json') with open(path, 'w') as f: json.dump(SAMPLE_ANSIBLE_DATA, f) - with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=True): + with mock.patch('awx.main.tasks.callback.settings.INDIRECT_NODE_COUNTING_ENABLED', True): rc.artifacts_handler(tmpdir) assert rc.extra_update_fields['installed_collections'] == SAMPLE_ANSIBLE_DATA['installed_collections'] @@ -133,14 +131,14 @@ class TestArtifactsHandler: mock_event_query.assert_called_once() @mock.patch('awx.main.tasks.callback.EventQuery') - def test_no_event_queries_when_flag_off(self, mock_event_query, mock_me): + def test_no_event_queries_when_indirect_node_counting_disabled(self, mock_event_query, mock_me): rc = RunnerCallback() with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, 'ansible_data.json') with open(path, 'w') as f: json.dump(SAMPLE_ANSIBLE_DATA, f) - with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False): + with mock.patch('awx.main.tasks.callback.settings.INDIRECT_NODE_COUNTING_ENABLED', False): rc.artifacts_handler(tmpdir) mock_event_query.assert_not_called() @@ -148,8 +146,7 @@ class TestArtifactsHandler: def test_handles_missing_artifact_file(self, mock_me): rc = RunnerCallback() with tempfile.TemporaryDirectory() as tmpdir: - with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False): - rc.artifacts_handler(tmpdir) + rc.artifacts_handler(tmpdir) assert 'installed_collections' not in rc.extra_update_fields assert 'ansible_version' not in rc.extra_update_fields diff --git a/awx/main/tests/unit/test_indirect_query_discovery.py b/awx/main/tests/unit/test_indirect_query_discovery.py index 3694cf8466..3a6a4f178a 100644 --- a/awx/main/tests/unit/test_indirect_query_discovery.py +++ b/awx/main/tests/unit/test_indirect_query_discovery.py @@ -419,14 +419,13 @@ class TestExternalQueryDiscovery: class TestPrivateDataDirIntegration: """Tests for vendor collection copying (AC7.10-AC7.11).""" - @mock.patch('awx.main.tasks.jobs.flag_enabled') + @mock.patch('awx.main.tasks.jobs.settings.INDIRECT_NODE_COUNTING_ENABLED', True) @mock.patch('awx.main.tasks.jobs.shutil.copytree') @mock.patch('awx.main.tasks.jobs.os.path.exists') - def test_vendor_collections_copied(self, mock_exists, mock_copytree, mock_flag): + def test_vendor_collections_copied(self, mock_exists, mock_copytree): """AC7.10: build_private_data_files() copies vendor collections to private_data_dir.""" from awx.main.tasks.jobs import BaseTask - mock_flag.return_value = True mock_exists.return_value = True task = BaseTask() @@ -439,15 +438,14 @@ class TestPrivateDataDirIntegration: mock_copytree.assert_called_once_with('/var/lib/awx/vendor_collections', f'{private_data_dir}/vendor_collections') - @mock.patch('awx.main.tasks.jobs.flag_enabled') + @mock.patch('awx.main.tasks.jobs.settings.INDIRECT_NODE_COUNTING_ENABLED', True) @mock.patch('awx.main.tasks.jobs.logger') @mock.patch('awx.main.tasks.jobs.shutil.copytree') @mock.patch('awx.main.tasks.jobs.os.path.exists') - def test_missing_source_handled_gracefully(self, mock_exists, mock_copytree, mock_logger, mock_flag): + def test_missing_source_handled_gracefully(self, mock_exists, mock_copytree, mock_logger): """AC7.11: Collection copy handles missing source directory gracefully.""" from awx.main.tasks.jobs import BaseTask - mock_flag.return_value = True mock_exists.return_value = False task = BaseTask() diff --git a/awx/main/tests/unit/test_settings.py b/awx/main/tests/unit/test_settings.py index ee517d6a87..f0e09d6808 100644 --- a/awx/main/tests/unit/test_settings.py +++ b/awx/main/tests/unit/test_settings.py @@ -8,8 +8,6 @@ LOCAL_SETTINGS = ( 'CACHES', 'DEBUG', 'NAMED_URL_GRAPH', - # Platform flags are managed by the platform flags system and have environment-specific defaults - 'FEATURE_INDIRECT_NODE_COUNTING_ENABLED', ) @@ -70,24 +68,3 @@ def test_merge_application_name(): result = merge_application_name(settings)["DATABASES__default__OPTIONS__application_name"] assert result.startswith("awx-") assert "test-cluster" in result - - -def test_development_defaults_feature_flags(monkeypatch): - """Ensure that development_defaults.py sets the correct feature flags.""" - monkeypatch.setenv('AWX_MODE', 'development') - - # Import the development_defaults module directly to trigger coverage of the new lines - import importlib.util - import os - - spec = importlib.util.spec_from_file_location("development_defaults", os.path.join(os.path.dirname(__file__), "../../../settings/development_defaults.py")) - development_defaults = importlib.util.module_from_spec(spec) - spec.loader.exec_module(development_defaults) - - # Also import through the development settings to ensure both paths are tested - from awx.settings.development import FEATURE_INDIRECT_NODE_COUNTING_ENABLED - - # Verify the feature flags are set correctly in both the module and settings - assert hasattr(development_defaults, 'FEATURE_INDIRECT_NODE_COUNTING_ENABLED') - assert development_defaults.FEATURE_INDIRECT_NODE_COUNTING_ENABLED is True - assert FEATURE_INDIRECT_NODE_COUNTING_ENABLED is True diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 7d0744e5bc..cdc98bacac 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -965,7 +965,7 @@ class TestCallbacksEnabled(TestJobExecution): assert env['ANSIBLE_CALLBACKS_ENABLED'] == 'indirect_instance_count,my_callback' - def test_collect_host_queries_set_when_flag_on(self, patch_Job, private_data_dir, execution_environment, mock_me): + def test_collect_host_queries_set_when_indirect_node_counting_enabled(self, patch_Job, private_data_dir, execution_environment, mock_me): job = Job(project=Project(), inventory=Inventory()) job.execution_environment = execution_environment @@ -974,12 +974,12 @@ class TestCallbacksEnabled(TestJobExecution): task._write_extra_vars_file = mock.Mock() with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True): - with mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=True): + with mock.patch('awx.main.tasks.jobs.settings.INDIRECT_NODE_COUNTING_ENABLED', True): env = task.build_env(job, private_data_dir) assert env['AWX_COLLECT_HOST_QUERIES'] == '1' - def test_collect_host_queries_not_set_when_flag_off(self, patch_Job, private_data_dir, execution_environment, mock_me): + def test_collect_host_queries_not_set_when_indirect_node_counting_disabled(self, patch_Job, private_data_dir, execution_environment, mock_me): job = Job(project=Project(), inventory=Inventory()) job.execution_environment = execution_environment @@ -988,7 +988,8 @@ class TestCallbacksEnabled(TestJobExecution): task._write_extra_vars_file = mock.Mock() with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True): - env = task.build_env(job, private_data_dir) + with mock.patch('awx.main.tasks.jobs.settings.INDIRECT_NODE_COUNTING_ENABLED', False): + env = task.build_env(job, private_data_dir) assert 'AWX_COLLECT_HOST_QUERIES' not in env diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 3f0540b1fb..bc43a5504a 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -1121,6 +1121,7 @@ SYSTEM_USERNAME = None # For indirect host query processing # if a job is not immediently confirmed to have all events processed # it will be eligable for processing after this number of minutes +INDIRECT_NODE_COUNTING_ENABLED = True INDIRECT_HOST_QUERY_FALLBACK_MINUTES = 60 # If an error happens in event collection, give up after this time @@ -1147,7 +1148,6 @@ OPA_REQUEST_TIMEOUT = 1.5 # The number of seconds after which the connection to OPA_REQUEST_RETRIES = 2 # The number of retry attempts for connecting to the OPA server. Default is 2. # feature flags -FEATURE_INDIRECT_NODE_COUNTING_ENABLED = False FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED = False # Dispatcher worker lifetime. If set to None, workers will never be retired diff --git a/awx/settings/development_defaults.py b/awx/settings/development_defaults.py index 49cb1a68b6..88ad1bb070 100644 --- a/awx/settings/development_defaults.py +++ b/awx/settings/development_defaults.py @@ -68,4 +68,4 @@ AWX_DISABLE_TASK_MANAGERS = False # Needed for launching runserver in debug mode # ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!================================= -FEATURE_INDIRECT_NODE_COUNTING_ENABLED = True +INDIRECT_NODE_COUNTING_ENABLED = True diff --git a/docs/docsite/rst/rest_api/awx-manage.rst b/docs/docsite/rst/rest_api/awx-manage.rst index bb52052d3b..38e24d22d7 100644 --- a/docs/docsite/rst/rest_api/awx-manage.rst +++ b/docs/docsite/rst/rest_api/awx-manage.rst @@ -68,6 +68,27 @@ Cluster management Do not run other ``awx-manage`` commands unless instructed by Red Hat Ansible personnel. +Indirect node counting +~~~~~~~~~~~~~~~~~~~~~~ + +Red Hat Support can temporarily disable indirect node counting when diagnosing +Controller performance issues. Indirect node counting is enabled by default. + +:: + + awx-manage set_indirect_node_counting --disable + +To resume processing indirect managed nodes, run: + +:: + + awx-manage set_indirect_node_counting --enable + +The command updates the controller setting and clears the setting cache across +the cluster. Allow the change to propagate to workers before launching a job +that must use the new value. A service restart is not required. + + .. _ag_token_utility: Session management