Move to Runtime Platform Flags (#16148)

* move to platform flags

Signed-off-by: Fabricio Aguiar <fabricio.aguiar@gmail.com>

rh-pre-commit.version: 2.3.2
rh-pre-commit.check-secrets: ENABLED

* SonarCloud analyzes files without coverage data.
This commit is contained in:
Fabricio Aguiar
2025-11-25 15:20:04 +00:00
committed by GitHub
parent e03beb4d54
commit 2b2f2b73ac
10 changed files with 102 additions and 68 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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),
),
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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