diff --git a/.github/workflows/sonarcloud_pr.yml b/.github/workflows/sonarcloud_pr.yml index 19eecd8b03..380118d7b5 100644 --- a/.github/workflows/sonarcloud_pr.yml +++ b/.github/workflows/sonarcloud_pr.yml @@ -152,11 +152,27 @@ jobs: echo "All changed files in PR:" echo "$files" - # Convert to comma-separated list for sonar.inclusions + # Filter out files that are excluded by .coveragerc to avoid coverage conflicts + # This prevents SonarCloud from analyzing files that have no coverage data if [ -n "$files" ]; then - inclusions=$(echo "$files" | tr '\n' ',' | sed 's/,$//') - echo "SONAR_INCLUSIONS=$inclusions" >> $GITHUB_ENV - echo "└── Result: ✅ Will scan these files: $inclusions" + # Filter out files matching .coveragerc omit patterns + filtered_files=$(echo "$files" | grep -v "settings/.*_defaults\.py$" | grep -v "settings/defaults\.py$" | grep -v "main/migrations/") + + # Show which files were filtered out for transparency + excluded_files=$(echo "$files" | grep -E "(settings/.*_defaults\.py$|settings/defaults\.py$|main/migrations/)" || true) + if [ -n "$excluded_files" ]; then + echo "├── Filtered out (coverage-excluded): $(echo "$excluded_files" | wc -l) file(s)" + echo "$excluded_files" | sed 's/^/│ - /' + fi + + if [ -n "$filtered_files" ]; then + inclusions=$(echo "$filtered_files" | tr '\n' ',' | sed 's/,$//') + echo "SONAR_INCLUSIONS=$inclusions" >> $GITHUB_ENV + echo "└── Result: ✅ Will scan these files (excluding coverage-omitted files): $inclusions" + else + echo "└── Result: ✅ All changed files are excluded by coverage config, running full SonarCloud analysis" + # Don't set SONAR_INCLUSIONS, let it scan everything per sonar-project.properties + fi else echo "└── Result: ✅ Running SonarCloud analysis" fi 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 490b3ac6e1..7601e4ddc4 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 @@ -1,36 +1,30 @@ import pytest -from django.test import override_settings - +from flags.state import get_flags, flag_state +from ansible_base.feature_flags.models import AAPFlag +from ansible_base.feature_flags.utils import create_initial_data as seed_feature_flags +from django.conf import settings from awx.main.models import User -@override_settings(FLAGS={}) @pytest.mark.django_db def test_feature_flags_list_endpoint(get): - bob = User.objects.create(username='bob', password='test_user', is_superuser=False) - - url = "/api/v2/feature_flags_state/" + bob = User.objects.create(username='bob', password='test_user', is_superuser=True) + url = "/api/v2/feature_flags/states/" response = get(url, user=bob, expect=200) - assert len(response.data) == 0 + assert len(get_flags()) > 0 + assert len(response.data["results"]) == len(get_flags()) -@override_settings( - FLAGS={ - "FEATURE_SOME_PLATFORM_FLAG_ENABLED": [ - {"condition": "boolean", "value": False}, - {"condition": "before date", "value": "2022-06-01T12:00Z"}, - ], - "FEATURE_SOME_PLATFORM_FLAG_FOO_ENABLED": [ - {"condition": "boolean", "value": True}, - ], - } -) @pytest.mark.django_db -def test_feature_flags_list_endpoint_override(get): - bob = User.objects.create(username='bob', password='test_user', is_superuser=False) +@pytest.mark.parametrize('flag_val', (True, False)) +def test_feature_flags_list_endpoint_override(get, flag_val): + bob = User.objects.create(username='bob', password='test_user', is_superuser=True) - url = "/api/v2/feature_flags_state/" + AAPFlag.objects.all().delete() + flag_name = "FEATURE_DISPATCHERD_ENABLED" + setattr(settings, flag_name, flag_val) + seed_feature_flags() + url = "/api/v2/feature_flags/states/" response = get(url, user=bob, expect=200) - assert len(response.data) == 2 - assert response.data["FEATURE_SOME_PLATFORM_FLAG_ENABLED"] is False - assert response.data["FEATURE_SOME_PLATFORM_FLAG_FOO_ENABLED"] is True + assert len(response.data["results"]) == 6 + assert flag_state(flag_name) == flag_val diff --git a/awx/main/tests/functional/test_dispatch.py b/awx/main/tests/functional/test_dispatch.py index a5a344d504..b752762fa6 100644 --- a/awx/main/tests/functional/test_dispatch.py +++ b/awx/main/tests/functional/test_dispatch.py @@ -5,11 +5,8 @@ import signal import time import yaml from unittest import mock -from copy import deepcopy - +from flags.state import disable_flag, enable_flag from django.utils.timezone import now as tz_now -from django.conf import settings -from django.test.utils import override_settings import pytest from awx.main.models import Job, WorkflowJob, Instance @@ -302,13 +299,14 @@ class TestTaskDispatcher: assert str(result) == "No module named 'awx.foo'" # noqa +@pytest.mark.django_db class TestTaskPublisher: @pytest.fixture(autouse=True) def _disable_dispatcherd(self): - ffs = deepcopy(settings.FLAGS) - ffs['FEATURE_DISPATCHERD_ENABLED'][0]['value'] = False - with override_settings(FLAGS=ffs): - yield + flag_name = "FEATURE_DISPATCHERD_ENABLED" + disable_flag(flag_name) + yield + enable_flag(flag_name) def test_function_callable(self): assert add(2, 2) == 4 diff --git a/awx/main/tests/unit/test_settings.py b/awx/main/tests/unit/test_settings.py index acc376c15c..42ad771b1a 100644 --- a/awx/main/tests/unit/test_settings.py +++ b/awx/main/tests/unit/test_settings.py @@ -9,6 +9,9 @@ LOCAL_SETTINGS = ( 'DEBUG', 'NAMED_URL_GRAPH', 'DISPATCHER_MOCK_PUBLISH', + # Platform flags are managed by the platform flags system and have environment-specific defaults + 'FEATURE_DISPATCHERD_ENABLED', + 'FEATURE_INDIRECT_NODE_COUNTING_ENABLED', ) @@ -28,7 +31,7 @@ def test_default_settings(): continue default_val = getattr(settings.default_settings, k, None) snapshot_val = settings.DEFAULTS_SNAPSHOT[k] - assert default_val == snapshot_val, f'Setting for {k} does not match shapshot:\nsnapshot: {snapshot_val}\ndefault: {default_val}' + assert default_val == snapshot_val, f'Setting for {k} does not match snapshot:\nsnapshot: {snapshot_val}\ndefault: {default_val}' def test_django_conf_settings_is_awx_settings(): @@ -69,3 +72,27 @@ 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, FEATURE_DISPATCHERD_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 hasattr(development_defaults, 'FEATURE_DISPATCHERD_ENABLED') + assert development_defaults.FEATURE_DISPATCHERD_ENABLED is True + assert FEATURE_INDIRECT_NODE_COUNTING_ENABLED is True + assert FEATURE_DISPATCHERD_ENABLED is True diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 980eab2959..cd4d343b87 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -461,6 +461,7 @@ class TestExtraVarSanitation(TestJobExecution): class TestGenericRun: + @pytest.mark.django_db(reset_sequences=True) def test_generic_failure(self, patch_Job, execution_environment, mock_me, mock_create_partition): job = Job(status='running', inventory=Inventory(), project=Project(local_path='/projects/_23_foo')) job.websocket_emit_status = mock.Mock() @@ -545,6 +546,7 @@ class TestGenericRun: private_data_dir, extra_vars, safe_dict = call_args assert extra_vars['super_secret'] == "CLASSIFIED" + @pytest.mark.django_db def test_awx_task_env(self, patch_Job, private_data_dir, execution_environment, mock_me): job = Job(project=Project(), inventory=Inventory()) job.execution_environment = execution_environment @@ -845,6 +847,7 @@ class TestJobCredentials(TestJobExecution): [None, '0'], ], ) + @pytest.mark.django_db def test_net_credentials(self, authorize, expected_authorize, job, private_data_dir, mock_me): task = jobs.RunJob() task.instance = job @@ -901,6 +904,7 @@ class TestJobCredentials(TestJobExecution): assert safe_env['AZURE_PASSWORD'] == HIDDEN_PASSWORD + @pytest.mark.django_db def test_awx_task_env(self, settings, private_data_dir, job, mock_me): settings.AWX_TASK_ENV = {'FOO': 'BAR'} task = jobs.RunJob() diff --git a/awx/resource_api.py b/awx/resource_api.py index f169bf9c5a..10c2eac4ed 100644 --- a/awx/resource_api.py +++ b/awx/resource_api.py @@ -1,8 +1,14 @@ from ansible_base.resource_registry.registry import ParentResource, ResourceConfig, ServiceAPIConfig, SharedResource -from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType from ansible_base.rbac.models import RoleDefinition -from ansible_base.resource_registry.shared_types import RoleDefinitionType +from ansible_base.resource_registry.shared_types import ( + FeatureFlagType, + RoleDefinitionType, + OrganizationType, + TeamType, + UserType, +) +from ansible_base.feature_flags.models import AAPFlag from awx.main import models @@ -15,7 +21,11 @@ RESOURCE_LIST = ( models.Organization, shared_resource=SharedResource(serializer=OrganizationType, is_provider=False), ), - ResourceConfig(models.User, shared_resource=SharedResource(serializer=UserType, is_provider=False), name_field="username"), + ResourceConfig( + models.User, + shared_resource=SharedResource(serializer=UserType, is_provider=False), + name_field="username", + ), ResourceConfig( models.Team, shared_resource=SharedResource(serializer=TeamType, is_provider=False), @@ -25,4 +35,8 @@ RESOURCE_LIST = ( RoleDefinition, shared_resource=SharedResource(serializer=RoleDefinitionType, is_provider=False), ), + ResourceConfig( + AAPFlag, + shared_resource=SharedResource(serializer=FeatureFlagType, is_provider=False), + ), ) diff --git a/awx/settings/__init__.py b/awx/settings/__init__.py index 641d442bbc..2332863d7b 100644 --- a/awx/settings/__init__.py +++ b/awx/settings/__init__.py @@ -8,7 +8,6 @@ from ansible_base.lib.dynamic_config import ( load_envvars, load_python_file_with_injected_context, load_standard_settings_files, - toggle_feature_flags, ) from .functions import ( assert_production_settings, @@ -71,12 +70,5 @@ DYNACONF.update( merge=True, ) -# Toggle feature flags based on installer settings -DYNACONF.update( - toggle_feature_flags(DYNACONF), - loader_identifier="awx.settings:toggle_feature_flags", - merge=True, -) - # Update django.conf.settings with DYNACONF values export(__name__, DYNACONF) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index bf04830942..3bd9510072 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -1148,11 +1148,8 @@ 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 -FLAG_SOURCES = ('flags.sources.SettingsFlagsSource',) -FLAGS = { - 'FEATURE_INDIRECT_NODE_COUNTING_ENABLED': [{'condition': 'boolean', 'value': False}], - 'FEATURE_DISPATCHERD_ENABLED': [{'condition': 'boolean', 'value': False}], -} +FEATURE_INDIRECT_NODE_COUNTING_ENABLED = False +FEATURE_DISPATCHERD_ENABLED = False # Dispatcher worker lifetime. If set to None, workers will never be retired # based on age. Note workers will finish their last task before retiring if diff --git a/awx/settings/development_defaults.py b/awx/settings/development_defaults.py index 1e5ecff78a..c7f43e880e 100644 --- a/awx/settings/development_defaults.py +++ b/awx/settings/development_defaults.py @@ -11,8 +11,6 @@ import socket # /usr/lib64/python/mimetypes.py import mimetypes -from dynaconf import post_hook - # awx-manage shell_plus --notebook NOTEBOOK_ARGUMENTS = ['--NotebookApp.token=', '--ip', '0.0.0.0', '--port', '9888', '--allow-root', '--no-browser'] @@ -70,11 +68,5 @@ AWX_DISABLE_TASK_MANAGERS = False # Needed for launching runserver in debug mode # ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!================================= - -# This modifies FLAGS set by defaults, must be deferred to run later -@post_hook -def set_dev_flags(settings): - defaults_flags = settings.get("FLAGS", {}) - defaults_flags['FEATURE_INDIRECT_NODE_COUNTING_ENABLED'] = [{'condition': 'boolean', 'value': True}] - defaults_flags['FEATURE_DISPATCHERD_ENABLED'] = [{'condition': 'boolean', 'value': True}] - return {'FLAGS': defaults_flags} +FEATURE_INDIRECT_NODE_COUNTING_ENABLED = True +FEATURE_DISPATCHERD_ENABLED = True diff --git a/requirements/requirements.txt b/requirements/requirements.txt index fbf6c37451..1da974773a 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -22,7 +22,7 @@ ansi2html==1.9.2 # via -r /awx_devel/requirements/requirements_git.txt asciichartpy==1.5.25 # via -r /awx_devel/requirements/requirements.in -asgiref==3.10.0 +asgiref==3.11.0 # via # channels # channels-redis @@ -104,7 +104,7 @@ click==8.1.8 # via receptorctl constantly==23.10.4 # via twisted -cryptography==46.0.2 +cryptography==46.0.3 # via # -r /awx_devel/requirements/requirements.in # adal @@ -138,7 +138,7 @@ django==4.2.26 # django-solo # djangorestframework # drf-spectacular -# django-ansible-base @ git+https://github.com/ansible/django-ansible-base@devel # git requirements installed separately +# django-ansible-base[feature-flags,jwt-consumer,rbac,resource-registry,rest-filters] @ git+https://github.com/ansible/django-ansible-base@devel # git requirements installed separately # via -r /awx_devel/requirements/requirements_git.txt django-cors-headers==4.9.0 # via -r /awx_devel/requirements/requirements.in @@ -148,7 +148,7 @@ django-crum==0.7.9 # django-ansible-base django-extensions==4.1 # via -r /awx_devel/requirements/requirements.in -django-flags==5.0.14 +django-flags==5.1.0 # via # -r /awx_devel/requirements/requirements.in # django-ansible-base @@ -169,7 +169,7 @@ drf-spectacular==0.29.0 # via -r /awx_devel/requirements/requirements.in durationpy==0.10 # via kubernetes -dynaconf==3.2.11 +dynaconf==3.2.12 # via # -r /awx_devel/requirements/requirements.in # django-ansible-base