diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..ddd061a07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +**/.github +**/.cache +**/.gitignore +**/.venv +**/venv +**/.tox diff --git a/ansible_base/authentication/utils/claims.py b/ansible_base/authentication/utils/claims.py index 349f7aa23..326e82eb7 100644 --- a/ansible_base/authentication/utils/claims.py +++ b/ansible_base/authentication/utils/claims.py @@ -217,7 +217,7 @@ def _add_rbac_role_mapping(has_permission, role_mapping, role, organization=None def _is_case_insensitivity_enabled() -> bool: - return flag_enabled("FEATURE_CASE_INSENSITIVE_AUTH_MAPS") + return flag_enabled("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") def _lowercase_group_triggers(trigger_condition: dict) -> dict: diff --git a/ansible_base/feature_flags/apps.py b/ansible_base/feature_flags/apps.py index cfe665a64..94cfee764 100644 --- a/ansible_base/feature_flags/apps.py +++ b/ansible_base/feature_flags/apps.py @@ -1,4 +1,7 @@ from django.apps import AppConfig +from django.db.models.signals import post_migrate + +from ansible_base.feature_flags.utils import create_initial_data class FeatureFlagsConfig(AppConfig): @@ -6,3 +9,6 @@ class FeatureFlagsConfig(AppConfig): name = 'ansible_base.feature_flags' label = 'dab_feature_flags' verbose_name = 'Feature Flags' + + def ready(self): + post_migrate.connect(create_initial_data, sender=self) diff --git a/ansible_base/feature_flags/definitions/feature_flags.yaml b/ansible_base/feature_flags/definitions/feature_flags.yaml new file mode 100644 index 000000000..de2483fb3 --- /dev/null +++ b/ansible_base/feature_flags/definitions/feature_flags.yaml @@ -0,0 +1,64 @@ +- name: FEATURE_INDIRECT_NODE_COUNTING_ENABLED + ui_name: Indirect Node Counting + visibility: True + condition: boolean + value: 'False' + support_level: TECHNOLOGY_PREVIEW + description: "Indirect Node Counting parses the event stream of all jobs to identify resources and stores these in the platform database. Example: Job automates VMware, the parser will report back the VMs, Hypervisors that were automated. This feature helps customers and partners report on the automations they are doing beyond an API endpoint." + support_url: https://access.redhat.com/articles/7109910 + labels: + - controller +- name: FEATURE_EDA_ANALYTICS_ENABLED + ui_name: Event-Driven Ansible Analytics + visibility: False + condition: boolean + value: 'False' + support_level: TECHNOLOGY_PREVIEW + description: Submit Event-Driven Ansible usage analytics to console.redhat.com. + support_url: https://access.redhat.com/solutions/7112810 + toggle_type: install-time + labels: + - eda +- name: FEATURE_GATEWAY_IPV6_USAGE_ENABLED + ui_name: Gateway IPv6 Enablement + visibility: False + condition: boolean + value: 'False' + support_level: TECHNOLOGY_PREVIEW + description: The feature flag represents enabling IPv6 only traffic to be allowed through the gateway component and does not include all components of the platform. + support_url: https://access.redhat.com/articles/7116569 + labels: + - gateway +- name: FEATURE_GATEWAY_CREATE_CRC_SERVICE_TYPE_ENABLED + ui_name: Dynamic Service Type Feature + visibility: False + condition: boolean + value: 'False' + support_level: DEVELOPER_PREVIEW + description: The Dynamic Service Type feature allows for the introduction of new platform services without requiring registration to the existing database. The new service can be enabled through the use of configuration. + support_url: https://access.redhat.com/articles/7122668 + toggle_type: install-time + labels: + - gateway +- name: FEATURE_DISPATCHERD_ENABLED + ui_name: AAP Dispatcherd background tasking system + visibility: False + condition: boolean + value: 'False' + support_level: TECHNOLOGY_PREVIEW + description: A service to run python tasks in subprocesses, designed specifically to work well with pg_notify, but intended to be extensible to other message delivery means. + support_url: '' + toggle_type: install-time + labels: + - eda + - controller +- name: FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED + ui_name: Case Insensitive Authentication Maps + visibility: False + condition: boolean + value: 'False' + support_level: DEVELOPER_PREVIEW + description: Enable case-insensitive comparison for authentication mapping attributes and group names. + support_url: '' + labels: + - platform diff --git a/ansible_base/feature_flags/definitions/schema.json b/ansible_base/feature_flags/definitions/schema.json new file mode 100644 index 000000000..f8d4794da --- /dev/null +++ b/ansible_base/feature_flags/definitions/schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Feature Flag Configuration Schema", + "description": "Validates a list of feature flag configurations.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "The unique identifier for the feature flag. Must be in all capitals and start with 'FEATURE_' and end with '_ENABLED'", + "type": "string", + "pattern": "^FEATURE_[A-Z0-9_]+_ENABLED$" + }, + "ui_name": { + "description": "The human-readable name for the feature flag displayed in the UI.", + "type": "string", + "minLength": 1 + }, + "visibility": { + "description": "Controls whether the feature is visible in the UI.", + "type": "boolean" + }, + "condition": { + "description": "The type of condition for the feature flag's value. Currently only boolean is supported.", + "type": "string", + "enum": ["boolean"] + }, + "value": { + "description": "The default value of the feature flag, as a string.", + "type": "string", + "enum": ["True", "False"] + }, + "support_level": { + "description": "The level of support provided for this feature.", + "type": "string", + "enum": [ + "TECHNOLOGY_PREVIEW", + "DEVELOPER_PREVIEW" + ] + }, + "description": { + "description": "A brief explanation of what the feature does.", + "type": "string" + }, + "support_url": { + "description": "A URL to the relevant documentation for the feature.", + "type": "string", + "format": "uri" + }, + "toggle_type": { + "description": "The actual value of the feature flag. Note: The YAML string 'False' or 'True' is parsed as a boolean.", + "type": "string", + "enum": ["install-time", "run-time"] + }, + "labels": { + "description": "A list of labels to categorize the feature.", + "type": "array", + "items": { + "type": "string", + "enum": ["controller", "eda", "gateway", "platform"] + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ + "name", + "ui_name", + "visibility", + "condition", + "value", + "support_level", + "description", + "support_url" + ] + } +} diff --git a/ansible_base/feature_flags/flag_source.py b/ansible_base/feature_flags/flag_source.py new file mode 100644 index 000000000..04a2c53b2 --- /dev/null +++ b/ansible_base/feature_flags/flag_source.py @@ -0,0 +1,29 @@ +from django.apps import apps +from flags.sources import Condition + + +class DatabaseCondition(Condition): + """Condition that includes the AAPFlags database object + This is required to ensure that enable_flag/disable_flag calls + can work as expected, with the custom flag objects + """ + + def __init__(self, condition, value, required=False, obj=None): + super().__init__(condition, value, required=required) + self.obj = obj + + +class AAPFlagSource(object): + """The customer AAP flag source, retrieves a list of all flags in the database""" + + def get_queryset(self): + aap_flags = apps.get_model('dab_feature_flags', 'AAPFlag') + return aap_flags.objects.all() + + def get_flags(self): + flags = {} + for o in self.get_queryset(): + if o.name not in flags: + flags[o.name] = [] + flags[o.name].append(DatabaseCondition(o.condition, o.value, required=o.required, obj=o)) + return flags diff --git a/ansible_base/feature_flags/management/__init__.py b/ansible_base/feature_flags/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_base/feature_flags/management/commands/__init__.py b/ansible_base/feature_flags/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_base/feature_flags/management/commands/feature_flags.py b/ansible_base/feature_flags/management/commands/feature_flags.py new file mode 100644 index 000000000..1e4b1cddd --- /dev/null +++ b/ansible_base/feature_flags/management/commands/feature_flags.py @@ -0,0 +1,50 @@ +try: + from tabulate import tabulate + + HAS_TABULATE = True +except ImportError: + HAS_TABULATE = False + +from django.core.management.base import BaseCommand +from flags.state import flag_state + +from ansible_base.feature_flags.models import AAPFlag + + +class Command(BaseCommand): + help = "AAP Feature Flag management command" + + def add_arguments(self, parser): + parser.add_argument("--list", action="store_true", help="List feature flags", required=False) + + def handle(self, *args, **options): + if options["list"]: + self.list_feature_flags() + + def list_feature_flags(self): + feature_flags = [] + headers = ["Name", "UI_Name", "Value", "State", "Support Level", "Visibility", "Toggle Type", "Description", "Support URL"] + + for feature_flag in AAPFlag.objects.all().order_by('name'): + feature_flags.append( + [ + f'{feature_flag.name}', + f'{feature_flag.ui_name}', + f'{feature_flag.value}', + f'{flag_state(feature_flag.name)}', + f'{feature_flag.support_level}', + f'{feature_flag.visibility}', + f'{feature_flag.toggle_type}', + f'{feature_flag.description}', + f'{feature_flag.support_url}', + ] + ) + self.stdout.write('') + + if HAS_TABULATE: + self.stdout.write(tabulate(feature_flags, headers, tablefmt="github")) + else: + self.stdout.write("\t".join(headers)) + for feature_flag in feature_flags: + self.stdout.write("\t".join(feature_flag)) + self.stdout.write('') diff --git a/ansible_base/feature_flags/migrations/0001_initial.py b/ansible_base/feature_flags/migrations/0001_initial.py new file mode 100644 index 000000000..bf989d1d2 --- /dev/null +++ b/ansible_base/feature_flags/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.21 on 2025-06-24 13:34 +# FileHash: 4a09bba8183818ba8a0627df1dfb136dc33892c20670951b91b8e53ed1a57721 + +import ansible_base.feature_flags.models.aap_flag +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AAPFlag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created.')), + ('created', models.DateTimeField(auto_now_add=True, help_text='The date/time this resource was created.')), + ('name', models.CharField(help_text='The name of the feature flag. Must follow the format of FEATURE__ENABLED.', max_length=64, validators=[ansible_base.feature_flags.models.aap_flag.validate_feature_flag_name])), + ('ui_name', models.CharField(help_text='The pretty name to display in the application User Interface', max_length=64)), + ('condition', models.CharField(default='boolean', help_text='Used to specify a condition, which if met, will enable the feature flag.', max_length=64)), + ('value', models.CharField(default='False', help_text='The value used to evaluate the conditional specified.', max_length=127)), + ('required', models.BooleanField(default=False, help_text="If multiple conditions are required to be met to enable a feature flag, 'required' can be used to specify the necessary conditionals.")), + ('support_level', models.CharField(choices=[('DEVELOPER_PREVIEW', 'Developer Preview'), ('TECHNOLOGY_PREVIEW', 'Technology Preview')], editable=False, help_text='The support criteria for the feature flag. Must be one of (DEVELOPER_PREVIEW or TECHNOLOGY_PREVIEW).', max_length=25)), + ('visibility', models.BooleanField(default=False, help_text='Controls whether the feature is visible in the UI.')), + ('toggle_type', models.CharField(choices=[('install-time', 'install-time'), ('run-time', 'run-time')], default='run-time', help_text="Details whether a flag is toggle-able at run-time or install-time. (Default: 'run-time').", max_length=20)), + ('description', models.CharField(default='', help_text='A detailed description giving an overview of the feature flag.', max_length=500)), + ('support_url', models.CharField(blank=True, default='', help_text='A link to the documentation support URL for the feature', max_length=250)), + ('labels', models.JSONField(blank=True, default=list, help_text='A list of labels for the feature flag.', null=True, validators=[ansible_base.feature_flags.models.aap_flag.validate_labels])), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('name', 'condition')}, + }, + ), + ] diff --git a/ansible_base/feature_flags/migrations/example_migration b/ansible_base/feature_flags/migrations/example_migration new file mode 100644 index 000000000..60b6689d8 --- /dev/null +++ b/ansible_base/feature_flags/migrations/example_migration @@ -0,0 +1,21 @@ +### INSTRUCTIONS ### +# If updating the feature_flags.yaml, create a new migration file by copying this one. +# 1. Name the file XXXX_manual_YYYYMMDD.py. For example 0002_manual_20250808.py +# 1. Uncomment the migration below, by uncommenting everything below the FileHash +# 2. Update the dependency to point to the last dependency +# 3. Set the FileHash +### + +# FileHash: + +# from django.db import migrations + + +# class Migration(migrations.Migration): + +# dependencies = [ +# ('dab_feature_flags', '0001_initial'), +# ] + +# operations = [ +# ] diff --git a/ansible_base/feature_flags/models/__init__.py b/ansible_base/feature_flags/models/__init__.py new file mode 100644 index 000000000..136cbf6b4 --- /dev/null +++ b/ansible_base/feature_flags/models/__init__.py @@ -0,0 +1,3 @@ +from .aap_flag import AAPFlag + +__all__ = ('AAPFlag',) diff --git a/ansible_base/feature_flags/models/aap_flag.py b/ansible_base/feature_flags/models/aap_flag.py new file mode 100644 index 000000000..fd3913d30 --- /dev/null +++ b/ansible_base/feature_flags/models/aap_flag.py @@ -0,0 +1,84 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ansible_base.lib.abstract_models.common import NamedCommonModel +from ansible_base.resource_registry.fields import AnsibleResourceField + + +def validate_feature_flag_name(value: str): + if not value.startswith('FEATURE_') or not value.endswith('_ENABLED'): + raise ValidationError(_("Feature flag names must follow the format of `FEATURE__ENABLED`")) + + +def validate_labels(value): + """Validate that labels is a list of strings.""" + if value is None: + return # Allow null values + + if not isinstance(value, list): + raise ValidationError(_("Labels must be a list.")) + + for item in value: + if not isinstance(item, str): + raise ValidationError(_("All labels must be strings.")) + + +class AAPFlag(NamedCommonModel): + class Meta: + app_label = "dab_feature_flags" + unique_together = ("name", "condition") + + def __str__(self): + return "{name} condition {condition} is set to " "{value}{required}".format( + name=self.name, + condition=self.condition, + value=self.value, + required=" (required)" if self.required else "", + ) + + resource = AnsibleResourceField(primary_key_field="id") + + name = models.CharField( + max_length=64, + null=False, + help_text=_("The name of the feature flag. Must follow the format of FEATURE__ENABLED."), + validators=[validate_feature_flag_name], + blank=False, + ) + ui_name = models.CharField(max_length=64, null=False, blank=False, help_text=_("The pretty name to display in the application User Interface")) + condition = models.CharField(max_length=64, default="boolean", help_text=_("Used to specify a condition, which if met, will enable the feature flag.")) + value = models.CharField( + max_length=127, + default="False", + help_text=_("The value used to evaluate the conditional specified."), + ) + required = models.BooleanField( + default=False, + help_text=_("If multiple conditions are required to be met to enable a feature flag, 'required' can be used to specify the necessary conditionals."), + ) + support_level = models.CharField( + max_length=25, + null=False, + help_text=_("The support criteria for the feature flag. Must be one of (DEVELOPER_PREVIEW or TECHNOLOGY_PREVIEW)."), + choices=( + ("DEVELOPER_PREVIEW", "Developer Preview"), + ("TECHNOLOGY_PREVIEW", "Technology Preview"), + ), + blank=False, + editable=False, + ) + visibility = models.BooleanField( + default=False, + help_text=_("Controls whether the feature is visible in the UI."), + ) + toggle_type = models.CharField( + max_length=20, + null=False, + choices=[('install-time', 'install-time'), ('run-time', 'run-time')], + default='run-time', + help_text=_("Details whether a flag is toggle-able at run-time or install-time. (Default: 'run-time')."), + ) + description = models.CharField(max_length=500, null=False, default="", help_text=_("A detailed description giving an overview of the feature flag.")) + support_url = models.CharField(max_length=250, null=False, default="", blank=True, help_text="A link to the documentation support URL for the feature") + labels = models.JSONField(null=True, default=list, help_text=_("A list of labels for the feature flag."), blank=True, validators=[validate_labels]) diff --git a/ansible_base/feature_flags/serializers.py b/ansible_base/feature_flags/serializers.py index 6fc8f850b..d9de755ba 100644 --- a/ansible_base/feature_flags/serializers.py +++ b/ansible_base/feature_flags/serializers.py @@ -1,16 +1,41 @@ from flags.state import flag_state from rest_framework import serializers +from ansible_base.feature_flags.models import AAPFlag +from ansible_base.lib.serializers.common import NamedCommonModelSerializer + from .utils import get_django_flags -class FeatureFlagSerializer(serializers.Serializer): +class FeatureFlagStatesSerializer(NamedCommonModelSerializer): + """Serialize list of feature flags""" + + state = serializers.SerializerMethodField() + + def get_state(self, instance): + return flag_state(instance.name) + + class Meta: + model = AAPFlag + fields = ["name", "state"] + + def to_representation(self, instance=None) -> dict: + ret = super().to_representation(instance) + return ret + + +# TODO: Remove once all components are migrated to the new endpont. +class OldFeatureFlagSerializer(NamedCommonModelSerializer): """Serialize list of feature flags""" - def to_representation(self) -> dict: + class Meta: + model = AAPFlag + fields = NamedCommonModelSerializer.Meta.fields + [x.name for x in AAPFlag._meta.concrete_fields] + read_only_fields = ["name", "condition", "required", "support_level", "visibility", "toggle_type", "description", "labels"] + + def to_representation(self, instance=None) -> dict: return_data = {} feature_flags = get_django_flags() for feature_flag in feature_flags: return_data[feature_flag] = flag_state(feature_flag) - return return_data diff --git a/ansible_base/feature_flags/urls.py b/ansible_base/feature_flags/urls.py index a02ac0e3e..9c791fd25 100644 --- a/ansible_base/feature_flags/urls.py +++ b/ansible_base/feature_flags/urls.py @@ -1,12 +1,16 @@ -from django.urls import path +from django.urls import include, path from ansible_base.feature_flags import views from ansible_base.feature_flags.apps import FeatureFlagsConfig +from ansible_base.lib.routers import AssociationResourceRouter app_name = FeatureFlagsConfig.label -api_version_urls = [ - path('feature_flags_state/', views.FeatureFlagsStateListView.as_view(), name='feature-flags-state-list'), -] +router = AssociationResourceRouter() + +router.register(r'feature_flags/states', views.FeatureFlagsStatesView, basename='aap_flags_states') +# TODO: Remove once all components are migrated to new endpoints. +api_version_urls = [path('feature_flags_state/', views.OldFeatureFlagsStateListView.as_view(), name='feature-flags-state-list'), path('', include(router.urls))] + api_urls = [] root_urls = [] diff --git a/ansible_base/feature_flags/utils.py b/ansible_base/feature_flags/utils.py index b419a151f..e61a4ec15 100644 --- a/ansible_base/feature_flags/utils.py +++ b/ansible_base/feature_flags/utils.py @@ -1,5 +1,97 @@ +import logging +from pathlib import Path + +import yaml +from django.apps import apps from django.conf import settings +from django.core.exceptions import ValidationError +from flags.sources import get_flags + +logger = logging.getLogger('ansible_base.feature_flags.utils') def get_django_flags(): - return getattr(settings, 'FLAGS', {}) + return get_flags() + + +def feature_flags_list(): + current_dir = Path(__file__).parent + flags_list_file = current_dir / 'definitions/feature_flags.yaml' + with open(flags_list_file, 'r') as file: + try: + return yaml.safe_load(file) + except yaml.YAMLError as exc: + print(exc) + + +def create_initial_data(**kwargs): # NOSONAR + """ + Loads in platform feature flags when the server starts + """ + purge_feature_flags() + load_feature_flags() + + +def update_feature_flag(existing, new): + """ + Update only the required fields of the feature flag model. + This is used to ensure that flags can be loaded in when the server starts, with any applicable updates. + """ + existing.support_level = new.get('support_level') + existing.visibility = new.get('visibility') + existing.ui_name = new.get('ui_name') + existing.support_url = new.get('support_url') + existing.required = new.get('required', False) + existing.toggle_type = new.get('toggle_type', 'run-time') + existing.labels = new.get('labels', []) + existing.description = new.get('description', '') + return existing + + +def load_feature_flags(): + """ + Loads in all feature flags into the database. Updates them if necessary. + """ + from ansible_base.resource_registry.signals.handlers import no_reverse_sync + + feature_flags_model = apps.get_model('dab_feature_flags', 'AAPFlag') + for flag in feature_flags_list(): + try: + existing_flag = feature_flags_model.objects.filter(name=flag['name'], condition=flag['condition']) + if existing_flag: + feature_flag = update_feature_flag(existing_flag.first(), flag) + else: + if hasattr(settings, flag['name']): + flag['value'] = getattr(settings, flag['name']) + feature_flag = feature_flags_model(**flag) + feature_flag.full_clean() + with no_reverse_sync(): + feature_flag.save() + except ValidationError as e: + # Ignore this error unless better way to bypass this + if e.messages[0] == 'Aap flag with this Name and Condition already exists.': + logger.info(f"Feature flag: {flag['name']} already exists") + else: + error_msg = f"Invalid feature flag: {flag['name']}. Error: {e}" + logger.error(error_msg) + + +def purge_feature_flags(): + """ + If a feature flag has been removed from the platform flags list, purge it from the database. + """ + from ansible_base.resource_registry.signals.handlers import no_reverse_sync + + all_flags = apps.get_model('dab_feature_flags', 'AAPFlag').objects.all() + for flag in all_flags: + found = False + for _flag in feature_flags_list(): + if flag.name == _flag['name'] and flag.condition == _flag['condition']: + found = True + break + if found: + continue + if not found: + logger.info(f"Deleting feature flag: {flag.name} as it is no longer available as a platform flag") + with no_reverse_sync(): + flag.delete() diff --git a/ansible_base/feature_flags/views.py b/ansible_base/feature_flags/views.py index 7eb93f28e..4077228b8 100644 --- a/ansible_base/feature_flags/views.py +++ b/ansible_base/feature_flags/views.py @@ -1,25 +1,43 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet -from ansible_base.feature_flags.serializers import FeatureFlagSerializer +from ansible_base.feature_flags.models import AAPFlag +from ansible_base.feature_flags.serializers import FeatureFlagStatesSerializer, OldFeatureFlagSerializer from ansible_base.lib.utils.views.ansible_base import AnsibleBaseView +from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView +from ansible_base.lib.utils.views.permissions import IsSuperuserOrAuditor, try_add_oauth2_scope_permission from .utils import get_django_flags -class FeatureFlagsStateListView(AnsibleBaseView): +class FeatureFlagsStatesView(AnsibleBaseDjangoAppApiView, ModelViewSet): + """ + A view class for displaying feature flags states. + To add/update/remove a feature flag, see the instructions in + `docs/apps/feature_flags.md` + """ + + queryset = AAPFlag.objects.order_by('id') + permission_classes = try_add_oauth2_scope_permission([IsSuperuserOrAuditor]) + serializer_class = FeatureFlagStatesSerializer + http_method_names = ['get', 'head', 'options'] + + +# TODO: This can be removed after functionality is migrated over to new class +class OldFeatureFlagsStateListView(AnsibleBaseView): """ A view class for displaying feature flags """ - serializer_class = FeatureFlagSerializer + serializer_class = OldFeatureFlagSerializer filter_backends = [] name = _('Feature Flags') http_method_names = ['get', 'head'] def _get(self, request, format=None): - self.serializer = FeatureFlagSerializer() + self.serializer = OldFeatureFlagSerializer() return Response(self.serializer.to_representation()) def get_queryset(self): diff --git a/ansible_base/lib/dynamic_config/settings_logic.py b/ansible_base/lib/dynamic_config/settings_logic.py index ca2d1a557..a56784ab8 100644 --- a/ansible_base/lib/dynamic_config/settings_logic.py +++ b/ansible_base/lib/dynamic_config/settings_logic.py @@ -308,6 +308,8 @@ def get_mergeable_dab_settings(settings: dict) -> dict: # NOSONAR if "flags" not in installed_apps: installed_apps.append('flags') + dab_data['FLAG_SOURCES'] = ('ansible_base.feature_flags.flag_source.AAPFlagSource',) + found_template_backend = False template_context_processor = 'django.template.context_processors.request' # Look through all of the tmplates diff --git a/ansible_base/lib/testing/fixtures.py b/ansible_base/lib/testing/fixtures.py index 2177fc432..af2e37595 100644 --- a/ansible_base/lib/testing/fixtures.py +++ b/ansible_base/lib/testing/fixtures.py @@ -366,6 +366,13 @@ def ldap_authenticator(ldap_configuration): delete_authenticator(authenticator) +@pytest.fixture +def aap_flags(): + from ansible_base.feature_flags.utils import create_initial_data + + create_initial_data() + + @pytest.fixture def create_mock_method(): # Creates a function that when called, generates a function that can be used to patch diff --git a/ansible_base/resource_registry/registry.py b/ansible_base/resource_registry/registry.py index 4a102b111..7a10369ef 100644 --- a/ansible_base/resource_registry/registry.py +++ b/ansible_base/resource_registry/registry.py @@ -30,6 +30,7 @@ def _get_default_resource_processors(cls): "shared.team": ResourceTypeProcessor, "shared.organization": ResourceTypeProcessor, "shared.user": ResourceTypeProcessor, + "shared.aapflag": ResourceTypeProcessor, } if is_rbac_installed(): processors["shared.roledefinition"] = RoleDefinitionProcessor diff --git a/ansible_base/resource_registry/shared_types.py b/ansible_base/resource_registry/shared_types.py index bcef8956f..430c980cf 100644 --- a/ansible_base/resource_registry/shared_types.py +++ b/ansible_base/resource_registry/shared_types.py @@ -129,3 +129,21 @@ def is_valid(self, raise_exception=False): raise SkipResource(*e.args) raise + + +class FeatureFlagType(SharedResourceTypeSerializer): + RESOURCE_TYPE = "aapflag" + UNIQUE_FIELDS = ( + "name", + "condition", + ) + + name = serializers.CharField() + condition = serializers.CharField() + value = serializers.CharField() + required = serializers.BooleanField() + support_level = serializers.CharField() + visibility = serializers.CharField() + toggle_type = serializers.CharField() + description = serializers.CharField() + labels = serializers.JSONField() diff --git a/ansible_base/resource_registry/tasks/sync.py b/ansible_base/resource_registry/tasks/sync.py index 8387bef9e..07f65f795 100644 --- a/ansible_base/resource_registry/tasks/sync.py +++ b/ansible_base/resource_registry/tasks/sync.py @@ -365,7 +365,7 @@ def get_orphan_resources( manifest_list: list[ManifestItem], ) -> QuerySet: """QuerySet with orphaned managed resources to be deleted.""" - return ( + queryset = ( Resource.objects.filter( content_type__resource_type__name=resource_type_name, ) @@ -376,6 +376,16 @@ def get_orphan_resources( ) ) + # Exclude system user from deletion, consistent with manifest endpoint + if resource_type_name == "shared.user": + from ansible_base.lib.utils.models import get_system_user + + system_user = get_system_user() + if system_user: + queryset = queryset.exclude(object_id=system_user.id) + + return queryset + def delete_resource(resource: Resource): """Wrapper to delete content_object and its related Resource. @@ -425,7 +435,7 @@ def _handle_conflict(resource_data: dict, resource_type: ResourceType, api_clien if resp.status_code == 404: delete_resource(conflict_resource) - # If the resource does exist, lets update it first. Hopefully this desn't also result + # If the resource does exist, lets update it first. Hopefully this doesn't also result # in a duplicate key error. If it does, we're cooked. elif resp.status_code == 200: data = resp.json() @@ -449,7 +459,7 @@ def _attempt_update_resource( return SyncResult(SyncStatus.NOOP, manifest_item) except IntegrityError: # pragma: no cover # This typically means that there was a duplicate key error. To mitigate this - # we will attempt to hanlde the conflicting resource and perform the operation + # we will attempt to handle the conflicting resource and perform the operation # again. try: _handle_conflict(resource_data, resource.resource_type_obj, api_client) @@ -484,7 +494,7 @@ def _attempt_create_resource( return SyncResult(SyncStatus.NOOP, manifest_item) except IntegrityError: # This typically means that there was a duplicate key error. To mitigate this - # we will attempt to hanlde the conflicting resource and perform the operation + # we will attempt to handle the conflicting resource and perform the operation # again. try: _handle_conflict(resource_data, resource_type, api_client) @@ -696,7 +706,7 @@ def _handle_retries(self): # pragma: no cover self.attempts += 1 def _dispatch_sync_process(self, manifest_list: list[ManifestItem]): - """Sync all the items from the manifest using either asyncio or sequentialy.""" + """Sync all the items from the manifest using either asyncio or sequentially.""" if self.asyncio is True: # pragma: no cover self.write(f"Processing {len(manifest_list)} resources with asyncio executor.") self.write() diff --git a/ansible_base/resource_registry/utils/resource_type_processor.py b/ansible_base/resource_registry/utils/resource_type_processor.py index a9e9f3c6c..94b4f3801 100644 --- a/ansible_base/resource_registry/utils/resource_type_processor.py +++ b/ansible_base/resource_registry/utils/resource_type_processor.py @@ -55,7 +55,7 @@ def save(self, validated_data: Dict[str, Any], is_new: bool = False, skip_keys: class RoleDefinitionProcessor(ResourceTypeProcessor): def save(self, validated_data: Dict[str, Any], is_new: bool = False, skip_keys: List[str] = []) -> Tuple[bool, RoleDefinition]: - (changed, instance) = super().save(validated_data, is_new=is_new, skip_keys=skip_keys + ['permissions']) + (changed, self.instance) = super().save(validated_data, is_new=is_new, skip_keys=skip_keys + ['permissions']) permissions = None # many-to-many field for k, val in validated_data.items(): if k == 'permissions': diff --git a/docs/apps/feature_flags.md b/docs/apps/feature_flags.md index d371a44e9..98d4edd76 100644 --- a/docs/apps/feature_flags.md +++ b/docs/apps/feature_flags.md @@ -5,55 +5,32 @@ Additional library documentation can be found at https://cfpb.github.io/django-f ## Settings -Add `ansible_base.feature_flags` to your installed apps: +Add `ansible_base.feature_flags` to your installed apps and ensure `ansible_base.resource_registry` as added to enable flag state to sync across the platform: ```python INSTALLED_APPS = [ ... 'ansible_base.feature_flags', + 'ansible_base.resource_registry', # Must also be added ] ``` -### Additional Settings +## Detail -Additional settings are required to enable feature_flags. -This will happen automatically if using [dynamic_settings](../Installation.md) - -First, you need to add `flags` to your `INSTALLED_APPS`: - -```python -INSTALLED_APPS = [ - ... - 'flags', - ... -] -``` - -Additionally, create a `FLAGS` entry: +By adding the `ansible_base.feature_flags` app to your application, all Ansible Automation Platform feature flags will be loaded and available in your component. +To receive flag state updates, ensure the following definition is available in your components `RESOURCE_LIST` - ```python -FLAGS = {} -``` - -Finally, add `django.template.context_processors.request` to your `TEMPLATES` `context_processors` setting: +from ansible_base.feature_flags.models import AAPFlag +from ansible_base.resource_registry.shared_types import FeatureFlagType -```python -TEMPLATES = [ - { - 'BEACKEND': 'django.template.backends.django.DjangoTemplates', - ... - 'OPTIONS': { - ... - 'context_processors': [ - ... - 'django.template.context_processors.request', - ... - ] - ... - } - ... - } -] +RESOURCE_LIST = ( + ... + ResourceConfig( + AAPFlag, + shared_resource=SharedResource(serializer=FeatureFlagType, is_provider=False), + ), +) ``` ## URLS @@ -70,3 +47,37 @@ urlpatterns = [ ... ] ``` + +## Adding/updating/removing feature flags + +To add/update/remove a feature flag to the platform, ensure its configuration is specified correctly it in the following [file](../../ansible_base/feature_flags/definitions/feature_flags.yaml) + +An example flag could resemble - + +```yaml +- name: FEATURE_FOO_ENABLED + ui_name: Foo + visibility: True + condition: boolean + value: 'False' + support_level: NOT_FOR_PRODUCTION + description: TBD + support_url: https://docs.redhat.com/en/documentation/red_hat_ansible_automation_platform/2.5/ + labels: + - controller +``` + +Validate this file against the json schema by running `check-jsonschema` - + +```bash +pip install check-jsonschema +check-jsonschema --schemafile ansible_base/feature_flags/definitions/schema.json ansible_base/feature_flags/definitions/feature_flags.yaml +``` + +After adding/updating/removing a feature flag, make a manual migration. This can be done by - + +1. Copying this [example-migration](../../ansible_base/feature_flags/migrations/example_migration). +2. Name the file XXXX_manual_YYYYMMDD.py. For example 0002_manual_20250808.py +3. Uncomment the migration, by uncommenting everything below the FileHash +4. Update the dependency in the migration to point to the previous migration +5. Set the **FileHash** in the migration file diff --git a/requirements/requirements_dev.txt b/requirements/requirements_dev.txt index 7a8463633..57f485c58 100644 --- a/requirements/requirements_dev.txt +++ b/requirements/requirements_dev.txt @@ -9,6 +9,7 @@ flake8==7.1.1 # Linting tool, if changed update pyproject.toml as well Flake8-pyproject==1.2.3 # Linting tool, if changed update pyproject.toml as well ipython isort==6.0.0 # Linting tool, if changed update pyproject.toml as well +jsonschema tox tox-docker typeguard @@ -17,6 +18,7 @@ pytest-asyncio pytest-xdist pytest-cov pytest-django +pytest-mock setuptools-scm sqlparse==0.5.2 psycopg[binary] diff --git a/test_app/defaults.py b/test_app/defaults.py index 331580b83..0dee9ae81 100644 --- a/test_app/defaults.py +++ b/test_app/defaults.py @@ -205,35 +205,3 @@ RENAMED_USERNAME_PREFIX = "dab:" JUST_A_TEST = 41 - -FLAGS = { - "FEATURE_SOME_PLATFORM_FLAG_ENABLED": [ - { - "condition": "boolean", - "value": False, - "required": True, - }, - { - "condition": "before date", - "value": '2022-06-01T12:00Z', - }, - ], - "FEATURE_SOME_PLATFORM_FLAG_FOO_ENABLED": [ - { - "condition": "boolean", - "value": False, - }, - ], - "FEATURE_SOME_PLATFORM_FLAG_BAR_ENABLED": [ - { - "condition": "boolean", - "value": True, - }, - ], - "FEATURE_CASE_INSENSITIVE_AUTH_MAPS": [ - { - "condition": "boolean", - "value": False, - }, - ], -} diff --git a/test_app/resource_api.py b/test_app/resource_api.py index be514b4ab..5fc40476c 100644 --- a/test_app/resource_api.py +++ b/test_app/resource_api.py @@ -1,9 +1,16 @@ from django.contrib.auth import get_user_model from ansible_base.authentication.models import Authenticator +from ansible_base.feature_flags.models import AAPFlag from ansible_base.rbac.models import RoleDefinition from ansible_base.resource_registry.registry import ResourceConfig, ServiceAPIConfig, SharedResource -from ansible_base.resource_registry.shared_types import OrganizationType, RoleDefinitionType, TeamType, UserType +from ansible_base.resource_registry.shared_types import ( + FeatureFlagType, + OrganizationType, + RoleDefinitionType, + TeamType, + UserType, +) from ansible_base.resource_registry.utils.resource_type_processor import ResourceTypeProcessor from test_app.models import Organization, Original1, Proxy2, ResourceMigrationTestModel, Team @@ -47,4 +54,8 @@ class APIConfig(ServiceAPIConfig): ResourceConfig(ResourceMigrationTestModel), ResourceConfig(Original1), ResourceConfig(Proxy2), + ResourceConfig( + AAPFlag, + shared_resource=SharedResource(serializer=FeatureFlagType, is_provider=False), + ), ] diff --git a/test_app/tests/authentication/management/test_authenticators.py b/test_app/tests/authentication/management/test_authenticators.py index 2715968a3..f95c76e3d 100644 --- a/test_app/tests/authentication/management/test_authenticators.py +++ b/test_app/tests/authentication/management/test_authenticators.py @@ -97,7 +97,7 @@ def test_authenticators_cli_initialize( err = StringIO() # Sanity check: - assert django_user_model.objects.count() == 0 + assert django_user_model.objects.count() == 1 # Optionally create admin user if admin_user_exists: diff --git a/test_app/tests/authentication/utils/test_claims.py b/test_app/tests/authentication/utils/test_claims.py index 41658db57..9795e69ec 100644 --- a/test_app/tests/authentication/utils/test_claims.py +++ b/test_app/tests/authentication/utils/test_claims.py @@ -1,7 +1,6 @@ from unittest import mock import pytest -from django.conf import settings from django.db import connection from ansible_base.authentication.models import AuthenticatorMap, AuthenticatorUser @@ -418,15 +417,23 @@ def test_create_claims_revoke(local_authenticator_map, process_function, trigger ], ) @pytest.mark.django_db -def test_process_groups(trigger_condition, groups, case_insensitive, has_access, settings_override_mutable): +def test_process_groups(trigger_condition, groups, case_insensitive, has_access): """ Test the process_groups function. """ - with settings_override_mutable("FLAGS"): - settings.FLAGS["FEATURE_CASE_INSENSITIVE_AUTH_MAPS"][0]["value"] = case_insensitive - res = claims.process_groups(trigger_condition, groups, map_id=1, tracking_id="xxx") + from flags.state import disable_flag, enable_flag + + if case_insensitive: + enable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") + else: + disable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") - assert res is has_access + try: + res = claims.process_groups(trigger_condition, groups, map_id=1, tracking_id="xxx") + assert res is has_access + finally: + # Clean up: disable the flag after test + disable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") @pytest.mark.parametrize( @@ -914,12 +921,20 @@ def test_has_access_with_join(current_access, new_access, condition, expected): ], ) @pytest.mark.django_db -def test_process_user_attributes(trigger_condition, attributes, expected, case_insensitive, settings_override_mutable): - with settings_override_mutable("FLAGS"): - settings.FLAGS["FEATURE_CASE_INSENSITIVE_AUTH_MAPS"][0]["value"] = case_insensitive - res = claims.process_user_attributes(trigger_condition, attributes, map_id=1, tracking_id="xxx") +def test_process_user_attributes(trigger_condition, attributes, expected, case_insensitive): + from flags.state import disable_flag, enable_flag - assert res is expected + if case_insensitive: + enable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") + else: + disable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") + + try: + res = claims.process_user_attributes(trigger_condition, attributes, map_id=1, tracking_id="xxx") + assert res is expected + finally: + # Clean up: disable the flag after test + disable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") def test_update_user_claims_extra_data(user, local_authenticator_map): @@ -2207,17 +2222,22 @@ def test_validate_attribute_conditions(self, condition, expected_result, expecte ), ], ) - def test_prepare_case_insensitive_data( - self, case_insensitive_enabled, trigger_condition, attributes, expected_trigger, expected_attrs, settings_override_mutable - ): + def test_prepare_case_insensitive_data(self, case_insensitive_enabled, trigger_condition, attributes, expected_trigger, expected_attrs): """Test _prepare_case_insensitive_data with case insensitivity enabled/disabled""" - with settings_override_mutable("FLAGS"): - settings.FLAGS["FEATURE_CASE_INSENSITIVE_AUTH_MAPS"][0]["value"] = case_insensitive_enabled + from flags.state import disable_flag, enable_flag - result_trigger, result_attrs = claims._prepare_case_insensitive_data(trigger_condition, attributes, 1, "test-id") + if case_insensitive_enabled: + enable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") + else: + disable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") + try: + result_trigger, result_attrs = claims._prepare_case_insensitive_data(trigger_condition, attributes, 1, "test-id") assert result_trigger == expected_trigger assert result_attrs == expected_attrs + finally: + # Clean up: disable the flag after test + disable_flag("FEATURE_CASE_INSENSITIVE_AUTH_MAPS_ENABLED") @pytest.mark.parametrize( "user_value, expected", diff --git a/test_app/tests/feature_flags/management/__init__.py b/test_app/tests/feature_flags/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/feature_flags/management/test_feature_flags.py b/test_app/tests/feature_flags/management/test_feature_flags.py new file mode 100644 index 000000000..46507d708 --- /dev/null +++ b/test_app/tests/feature_flags/management/test_feature_flags.py @@ -0,0 +1,212 @@ +import io +from unittest import mock + +import pytest +from django.conf import settings # Import settings +from django.core.management import call_command + +# Ensure settings are configured before importing models or command +if not settings.configured: + settings.configure() # Minimal configuration for tests + + +class MockAAPFlag: + def __init__(self, name, ui_name, value, support_level, visibility, toggle_type, description, support_url): + self.name = name + self.ui_name = ui_name + self.value = value + self.support_level = support_level + self.visibility = visibility + self.toggle_type = toggle_type + self.description = description + self.support_url = support_url + + def __str__(self): + return self.name + + +HAS_TABULATE_PATH = 'ansible_base.feature_flags.management.commands.feature_flags.HAS_TABULATE' +COMMAND_MODULE_PATH = 'ansible_base.feature_flags.management.commands.feature_flags' + + +@pytest.fixture +def mock_flags_data(): + return [ + MockAAPFlag( + name="flag1", + ui_name="Flag One", + value=True, + support_level="supported", + visibility="public", + toggle_type="boolean", + description="Description for flag one", + support_url="http://example.com/flag1", + ), + MockAAPFlag( + name="flag2", + ui_name="Flag Two", + value=False, + support_level="experimental", + visibility="internal", + toggle_type="string", + description="Description for flag two", + support_url="http://example.com/flag2", + ), + ] + + +@pytest.mark.django_db(transaction=False) +@mock.patch(f'{COMMAND_MODULE_PATH}.flag_state') +@mock.patch(f'{COMMAND_MODULE_PATH}.AAPFlag.objects') +def test_list_feature_flags_with_tabulate(mock_aap_flag_objects, mock_flag_state, mock_flags_data, capsys): + mock_aap_flag_objects.all.return_value.order_by.return_value = mock_flags_data + + mock_flag_state.side_effect = lambda name: next(f.value for f in mock_flags_data if f.name == name) + + with mock.patch(HAS_TABULATE_PATH, True): # Simulate tabulate is installed + with mock.patch(f'{COMMAND_MODULE_PATH}.tabulate') as mock_tabulate_func: + # Mock the tabulate function to check its call and control its output for simplicity + # or let it run if you want to test its actual output formatting + mock_tabulate_func.return_value = "mocked_tabulate_output" + + call_command('feature_flags', '--list') + + captured = capsys.readouterr() + + # Check headers (they are part of the data passed to tabulate) + expected_headers = ["Name", "UI_Name", "Value", "State", "Support Level", "Visibility", "Toggle Type", "Description", "Support URL"] + + # Check that tabulate was called + assert mock_tabulate_func.called + + # Check the arguments passed to tabulate + args, kwargs = mock_tabulate_func.call_args + passed_data = args[0] + passed_headers = args[1] + passed_tablefmt = kwargs.get('tablefmt') + + assert passed_headers == expected_headers + assert passed_tablefmt == "github" + + assert len(passed_data) == 2 + assert passed_data[0] == [ + 'flag1', + 'Flag One', + 'True', + 'True', + 'supported', + 'public', + 'boolean', + 'Description for flag one', + 'http://example.com/flag1', + ] + assert passed_data[1] == [ + 'flag2', + 'Flag Two', + 'False', + 'False', + 'experimental', + 'internal', + 'string', + 'Description for flag two', + 'http://example.com/flag2', + ] + + assert "mocked_tabulate_output" in captured.out + assert captured.out.strip() == "mocked_tabulate_output" + + +@pytest.mark.django_db(transaction=False) +@mock.patch(f'{COMMAND_MODULE_PATH}.flag_state') +@mock.patch(f'{COMMAND_MODULE_PATH}.AAPFlag.objects') +def test_list_feature_flags_without_tabulate(mock_aap_flag_objects, mock_flag_state, mock_flags_data, capsys): + mock_aap_flag_objects.all.return_value.order_by.return_value = mock_flags_data + mock_flag_state.side_effect = lambda name: next(f.value for f in mock_flags_data if f.name == name) + + with mock.patch(HAS_TABULATE_PATH, False): # Simulate tabulate is NOT installed + call_command('feature_flags', '--list') + + captured = capsys.readouterr() + output_lines = captured.out.strip().split('\n') + + expected_headers_str = "\t".join(["Name", "UI_Name", "Value", "State", "Support Level", "Visibility", "Toggle Type", "Description", "Support URL"]) + + assert output_lines[0] == expected_headers_str + + expected_data_row1 = "\t".join( + ['flag1', 'Flag One', 'True', 'True', 'supported', 'public', 'boolean', 'Description for flag one', 'http://example.com/flag1'] + ) + expected_data_row2 = "\t".join( + ['flag2', 'Flag Two', 'False', 'False', 'experimental', 'internal', 'string', 'Description for flag two', 'http://example.com/flag2'] + ) + + assert output_lines[1] == expected_data_row1 + assert output_lines[2] == expected_data_row2 + assert len(output_lines) == 3 # Headers + 2 data rows + + +@pytest.mark.django_db(transaction=False) +@mock.patch(f'{COMMAND_MODULE_PATH}.flag_state') # Still need to mock this even if no flags +@mock.patch(f'{COMMAND_MODULE_PATH}.AAPFlag.objects') +def test_list_feature_flags_no_flags_with_tabulate(mock_aap_flag_objects, mock_flag_state, capsys): + mock_aap_flag_objects.all.return_value.order_by.return_value = [] # No flags + + with mock.patch(HAS_TABULATE_PATH, True): + with mock.patch(f'{COMMAND_MODULE_PATH}.tabulate') as mock_tabulate_func: + mock_tabulate_func.return_value = "mocked_empty_table" + + call_command('feature_flags', '--list') + + captured = capsys.readouterr() + + assert mock_tabulate_func.called + args, kwargs = mock_tabulate_func.call_args + assert args[0] == [] + assert args[1] == ["Name", "UI_Name", "Value", "State", "Support Level", "Visibility", "Toggle Type", "Description", "Support URL"] + assert kwargs.get('tablefmt') == "github" + + assert "mocked_empty_table" in captured.out.strip() + + +@pytest.mark.django_db(transaction=False) +@mock.patch(f'{COMMAND_MODULE_PATH}.flag_state') +@mock.patch(f'{COMMAND_MODULE_PATH}.AAPFlag.objects') +def test_list_feature_flags_no_flags_without_tabulate(mock_aap_flag_objects, mock_flag_state, capsys): + mock_aap_flag_objects.all.return_value.order_by.return_value = [] # No flags + + with mock.patch(HAS_TABULATE_PATH, False): + call_command('feature_flags', '--list') + + captured = capsys.readouterr() + output_lines = captured.out.strip().split('\n') + + expected_headers_str = "\t".join(["Name", "UI_Name", "Value", "State", "Support Level", "Visibility", "Toggle Type", "Description", "Support URL"]) + + assert output_lines[0] == expected_headers_str + assert len(output_lines) == 1 # Only headers + + +def test_handle_no_options(): + # This test is to ensure that if no options (like --list) are passed, + # the command doesn't error out and list_feature_flags is not called. + # We expect it to do nothing based on the provided handle method. + stdout = io.StringIO() + stderr = io.StringIO() + + # Patch list_feature_flags to ensure it's not called + with mock.patch(f'{COMMAND_MODULE_PATH}.Command.list_feature_flags') as mock_list_method: + call_command('feature_flags', stdout=stdout, stderr=stderr) # No arguments + mock_list_method.assert_not_called() + assert stdout.getvalue() == "" + assert stderr.getvalue() == "" + + +@pytest.mark.django_db +def test_management_command_existing_data(aap_flags, capsys): + from ansible_base.feature_flags.utils import feature_flags_list + + call_command('feature_flags', '--list') + + captured = capsys.readouterr() + output_lines = captured.out.strip().split('\n') + assert len(output_lines) - 2 == len(feature_flags_list()) # Subtract 2 to remove header and '---' line before data diff --git a/test_app/tests/feature_flags/migrations/__init__.py b/test_app/tests/feature_flags/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/feature_flags/migrations/test_migrations.py b/test_app/tests/feature_flags/migrations/test_migrations.py new file mode 100644 index 000000000..b961edaff --- /dev/null +++ b/test_app/tests/feature_flags/migrations/test_migrations.py @@ -0,0 +1,82 @@ +import hashlib +import os + +from django.conf import settings +from django.test import TestCase + + +class FileHashTest(TestCase): + FILE_TO_CHECK_PATH = os.path.join(settings.BASE_DIR, 'ansible_base', 'feature_flags', 'definitions', 'feature_flags.yaml') + HASH_ALGORITHM = 'sha256' + HASH_COMMENT_PREFIX = '# FileHash:' + + def _get_last_migration_file(self): + """ + Finds the path to the last migration file in the specified Django app. + """ + migrations_dir = os.path.join(settings.BASE_DIR, 'ansible_base', 'feature_flags', 'migrations') + if not os.path.isdir(migrations_dir): + raise FileNotFoundError(f"Migrations directory not found for app: {self.APP_NAME}") + + migration_files = sorted([ + f for f in os.listdir(migrations_dir) + if f.endswith('.py') and f != '__init__.py' + ]) + + if not migration_files: + raise FileNotFoundError(f"No migration files found in {migrations_dir}") + + return os.path.join(migrations_dir, migration_files[-1]) + + def _extract_hash_from_migration(self, migration_file_path): + """ + Extracts the expected hash from a comment in the migration file. + Assumes the format: '# FileHash: ' + """ + with open(migration_file_path, 'r') as f: + for line in f: + if line.strip().startswith(self.HASH_COMMENT_PREFIX): + return line.strip().replace(self.HASH_COMMENT_PREFIX, '').strip() + return None + + def _calculate_file_hash(self, file_path): + """ + Calculates the hash of the given file. + """ + hash_func = getattr(hashlib, self.HASH_ALGORITHM, None) + if not hash_func: + raise ValueError(f"Unsupported hash algorithm: {self.HASH_ALGORITHM}") + + hasher = hash_func() + with open(file_path, 'rb') as f: + for chunk in iter(lambda: f.read(4096), b""): + hasher.update(chunk) + return hasher.hexdigest() + + def test_file_hash_matches_migration_comment(self): + """ + Checks if the hash of a specified file matches the hash commented + in the last migration file. + """ + # 1. Get the last migration file + try: + last_migration_file = self._get_last_migration_file() + except FileNotFoundError as e: + self.fail(f"Could not find last migration file: {e}") + + # 2. Extract the expected hash from the migration file + expected_hash = self._extract_hash_from_migration(last_migration_file) + self.assertIsNotNone(expected_hash, + f"No hash comment '{self.HASH_COMMENT_PREFIX}' found in {last_migration_file}") + self.assertTrue(expected_hash, "Extracted hash is empty.") + + # 3. Calculate the hash of the target file + self.assertTrue(os.path.exists(self.FILE_TO_CHECK_PATH), + f"File to check does not exist: {self.FILE_TO_CHECK_PATH}") + actual_hash = self._calculate_file_hash(self.FILE_TO_CHECK_PATH) + + # 4. Compare the hashes + self.assertEqual(expected_hash, actual_hash, + f"Hash mismatch for '{os.path.basename(self.FILE_TO_CHECK_PATH)}'. " + f"Expected: {expected_hash}, Got: {actual_hash} " + f"If the feature_flags.yaml file changed, generate a new no-op migration file, and correctly set the FileHash.") diff --git a/test_app/tests/feature_flags/models/__init__.py b/test_app/tests/feature_flags/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/feature_flags/models/test_aap_flag.py b/test_app/tests/feature_flags/models/test_aap_flag.py new file mode 100644 index 000000000..119e5a50a --- /dev/null +++ b/test_app/tests/feature_flags/models/test_aap_flag.py @@ -0,0 +1,63 @@ +import pytest +from django.conf import settings +from flags.state import disable_flag, enable_flag, flag_state + +from ansible_base.feature_flags.models import AAPFlag +from ansible_base.feature_flags.utils import feature_flags_list + + +@pytest.mark.django_db +def test_total_platform_flags(aap_flags): + assert AAPFlag.objects.count() == len(feature_flags_list()) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "feature_flag", + feature_flags_list(), +) +def test_feature_flags_from_db(aap_flags, feature_flag): + flag = AAPFlag.objects.get(name=feature_flag['name']) + assert flag + assert feature_flag.get('ui_name') == flag.ui_name + assert feature_flag.get('condition') == flag.condition + assert feature_flag.get('visibility') == flag.visibility + assert feature_flag.get('value') == flag.value + assert feature_flag.get('support_level') == flag.support_level + assert feature_flag.get('description') == flag.description + assert feature_flag.get('support_url') == flag.support_url + assert feature_flag.get('labels') == flag.labels + assert feature_flag.get('required', False) == flag.required + assert feature_flag.get('toggle_type', 'run-time') == flag.toggle_type + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "feature_flag, value", + [ + ('FEATURE_INDIRECT_NODE_COUNTING_ENABLED', True), + ('FEATURE_GATEWAY_IPV6_USAGE_ENABLED', False), + ], +) +def test_feature_flag_database_setting_override(feature_flag, value): + AAPFlag.objects.all().delete() + from ansible_base.feature_flags.utils import create_initial_data + + setattr(settings, feature_flag, value) + create_initial_data() + flag = AAPFlag.objects.get(name=feature_flag) + assert flag.value == str(value) + + +@pytest.mark.django_db +def test_enable_and_disable_flag_functions(aap_flags): + flag_name = "FEATURE_INDIRECT_NODE_COUNTING_ENABLED" + # Assert Initial State + assert flag_state(flag_name) is False + + # Ensure flag can be enabled via django-flags enable_flag function + enable_flag(flag_name) + assert flag_state(flag_name) is True + # Ensure flag can be disabled via django-flags enable_flag function + disable_flag(flag_name) + assert flag_state(flag_name) is False diff --git a/test_app/tests/feature_flags/test_api.py b/test_app/tests/feature_flags/test_api.py deleted file mode 100644 index df71cd20c..000000000 --- a/test_app/tests/feature_flags/test_api.py +++ /dev/null @@ -1,57 +0,0 @@ -from django.test import override_settings -from rest_framework.test import APIClient - -from ansible_base.lib.utils.response import get_relative_url - - -def test_feature_flags_state_api_list(admin_api_client: APIClient): - """ - Test that we can list all feature flags - """ - url = get_relative_url("feature-flags-state-list") - response = admin_api_client.get(url) - assert response.status_code == 200 - assert 'FEATURE_SOME_PLATFORM_FLAG_ENABLED' in response.data - assert response.data["FEATURE_SOME_PLATFORM_FLAG_ENABLED"] is False - assert 'FEATURE_SOME_PLATFORM_FLAG_FOO_ENABLED' in response.data - assert response.data["FEATURE_SOME_PLATFORM_FLAG_FOO_ENABLED"] is False - assert 'FEATURE_SOME_PLATFORM_FLAG_BAR_ENABLED' in response.data - assert response.data["FEATURE_SOME_PLATFORM_FLAG_BAR_ENABLED"] is True - - -@override_settings( - FLAGS={ - "FEATURE_SOME_PLATFORM_OVERRIDE_ENABLED": [ - {"condition": "boolean", "value": False}, - {"condition": "before date", "value": "2022-06-01T12:00Z"}, - ], - "FEATURE_SOME_PLATFORM_OVERRIDE_TRUE_ENABLED": [ - {"condition": "boolean", "value": True}, - ], - } -) -def test_feature_flags_state_api_list_settings_override(admin_api_client: APIClient): - """ - Test that we can list all feature flags - """ - url = get_relative_url("feature-flags-state-list") - response = admin_api_client.get(url) - assert response.status_code == 200 - assert 'FEATURE_SOME_PLATFORM_FLAG_ENABLED' not in response.data - assert 'FEATURE_SOME_PLATFORM_FLAG_FOO_ENABLED' not in response.data - assert 'FEATURE_SOME_PLATFORM_FLAG_BAR_ENABLED' not in response.data - assert 'FEATURE_SOME_PLATFORM_OVERRIDE_ENABLED' in response.data - assert response.data["FEATURE_SOME_PLATFORM_OVERRIDE_ENABLED"] is False - assert 'FEATURE_SOME_PLATFORM_OVERRIDE_TRUE_ENABLED' in response.data - assert response.data["FEATURE_SOME_PLATFORM_OVERRIDE_TRUE_ENABLED"] is True - - -@override_settings(FLAGS={}) -def test_feature_flags_state_api_list_settings_override_empty(admin_api_client: APIClient): - """ - Test that we can list all feature flags - """ - url = get_relative_url("feature-flags-state-list") - response = admin_api_client.get(url) - assert response.status_code == 200 - assert response.data == {} diff --git a/test_app/tests/feature_flags/test_old_api.py b/test_app/tests/feature_flags/test_old_api.py new file mode 100644 index 000000000..2b1b659f1 --- /dev/null +++ b/test_app/tests/feature_flags/test_old_api.py @@ -0,0 +1,12 @@ +from rest_framework.test import APIClient + +from ansible_base.lib.utils.response import get_relative_url + + +def test_feature_flags_state_api_list(admin_api_client: APIClient): + """ + Test that we can list all feature flags + """ + url = get_relative_url("feature-flags-state-list") + response = admin_api_client.get(url) + assert response.status_code == 200 diff --git a/test_app/tests/feature_flags/test_utils.py b/test_app/tests/feature_flags/test_utils.py new file mode 100644 index 000000000..29db3476f --- /dev/null +++ b/test_app/tests/feature_flags/test_utils.py @@ -0,0 +1,409 @@ +import json +from unittest.mock import MagicMock, call + +import pytest +import yaml +from django.core.exceptions import ValidationError +from jsonschema import validate + +MODULE_PATH = "ansible_base.feature_flags.utils" + + +# Mock the AAPFlag model structure that apps.get_model would return +# and instances that the model manager would operate on. +class MockAAPFlagInstance: + def __init__(self, **kwargs): + self.name = kwargs.get('name') + self.condition = kwargs.get('condition') + self.value = kwargs.get('value') + self.support_level = kwargs.get('support_level') + self.visibility = kwargs.get('visibility') + self.ui_name = kwargs.get('ui_name') + self.support_url = kwargs.get('support_url') + self.required = kwargs.get('required', False) + self.toggle_type = kwargs.get('toggle_type', 'run-time') + self.labels = kwargs.get('labels', []) + self.description = kwargs.get('description', '') + # Add save, full_clean, delete methods that can be spied on or controlled + self.save = MagicMock() + self.full_clean = MagicMock() + self.delete = MagicMock() + + # To allow attribute setting like existing.support_level = ... + def __setattr__(self, key, value): + super().__setattr__(key, value) + if key not in ['save', 'full_clean', 'delete']: + pass + + +@pytest.fixture +def mock_aap_flag_model_cls(mocker): + model_class = MagicMock(spec_set=['objects']) + + def model_constructor(**kwargs): + instance = MockAAPFlagInstance(**kwargs) + instance.save = mocker.MagicMock() + instance.full_clean = mocker.MagicMock() + instance.delete = mocker.MagicMock() + return instance + + model_class.side_effect = model_constructor + model_class.return_value = MagicMock(spec=MockAAPFlagInstance) + + # Mock the manager + model_class.objects = MagicMock() + model_class.objects.all = MagicMock() + model_class.objects.filter = MagicMock() + + return model_class + + +@pytest.fixture +def mock_apps_get_model(mocker, mock_aap_flag_model_cls): + return mocker.patch(f"{MODULE_PATH}.apps.get_model", return_value=mock_aap_flag_model_cls) + + +@pytest.fixture +def mock_settings(mocker): + # Patch 'settings' within the utils module + mocked_settings = mocker.patch(f"{MODULE_PATH}.settings") + + # This dictionary will hold the "true" values for our settings attributes. + _settings_attrs = {} + + def _hasattr_callable(name): + return name in _settings_attrs + + def _getattr_callable(name): + if name in _settings_attrs: + return _settings_attrs[name] + raise AttributeError(f"Mock settings has no attribute {name}") + + # Configure the 'hasattr' and 'getattr' attributes on the mocked_settings object. + # This is for when the code explicitly calls settings.hasattr(...) or settings.getattr(...). + mocked_settings.hasattr = mocker.MagicMock(side_effect=_hasattr_callable) + mocked_settings.getattr = mocker.MagicMock(side_effect=_getattr_callable) + + def set_settings_attr(name, value): + # Store the attribute in our local dictionary + _settings_attrs[name] = value + setattr(mocked_settings, name, value) + # Update the side effects for the callable attributes 'hasattr' and 'getattr' + # to use the latest state of _settings_attrs. + mocked_settings.hasattr.side_effect = lambda n: n in _settings_attrs + mocked_settings.getattr.side_effect = lambda n: (_settings_attrs[n] if n in _settings_attrs else AttributeError(f"Mock settings has no attribute {n}")) + + mocked_settings.set_attr = set_settings_attr + return mocked_settings + + +@pytest.fixture +def mock_logger(mocker): + logger_instance = mocker.MagicMock() + mocker.patch(f"{MODULE_PATH}.logger", logger_instance) + return logger_instance + + +@pytest.fixture +def mock_feature_flags_list(mocker): + mock = mocker.patch(f"{MODULE_PATH}.feature_flags_list") + return mock + + +def test_get_django_flags(mocker): + from ansible_base.feature_flags.utils import get_django_flags + + mock_internal_get_flags = mocker.patch(f"{MODULE_PATH}.get_flags") + mock_internal_get_flags.return_value = {"FLAG_X": True} + + result = get_django_flags() + + mock_internal_get_flags.assert_called_once() + assert result == {"FLAG_X": True} + + +def test_validate_flags_yaml_against_json_schema(): + feature_flags_yaml = 'ansible_base/feature_flags/definitions/feature_flags.yaml' + feature_flags_schema = 'ansible_base/feature_flags/definitions/schema.json' + try: + with open(feature_flags_yaml, 'r') as file: + feature_flags_file = yaml.safe_load(file) + with open(feature_flags_schema, 'r') as file: + schema = json.load(file) + validate(instance=feature_flags_file, schema=schema) + # Test passes if no exception is raised during validation + except FileNotFoundError as e: + pytest.fail(f"Could not find a necessary file: {e}. Make sure schema.json and valid_data.yaml exist.") + except Exception as e: + # If any other exception occurs (like a ValidationError), fail the test. + pytest.fail(f"Validation failed unexpectedly for a valid file: {e}") + + +class TestCreateInitialData: + + @pytest.mark.django_db # May not be strictly necessary with all the mocking, but good practice + def test_load_feature_flags_creates_new_flag_from_settings_value( + self, mock_apps_get_model, mock_aap_flag_model_cls, mock_settings, mock_logger, mock_feature_flags_list, mocker + ): + from ansible_base.feature_flags.utils import create_initial_data + + flag_def = { + 'name': 'NEW_FLAG', + 'condition': 'some.condition', + 'ui_name': 'New Flag', + 'support_level': 'tech_preview', + 'visibility': 'public', + # No 'value' here, expecting it from settings + } + mock_feature_flags_list.return_value = [flag_def] + # --- Mocks for database interaction (for load_feature_flags part) --- + mock_filter_queryset = MagicMock() + # Simulate flag does NOT exist: + mock_filter_queryset.first.return_value = None # Crucial: .first() should return None + mock_filter_queryset.exists.return_value = False # If your code uses .exists() + # Crucial: The queryset itself should be falsy if evaluated in a boolean context (e.g. if queryset:) + # The error log showed a call to .__bool__(), so this is necessary. + mock_filter_queryset.__bool__ = lambda self: False + + mock_aap_flag_model_cls.objects.filter.return_value = mock_filter_queryset + + mock_settings.set_attr('NEW_FLAG', True) + + mock_constructed_instance = MockAAPFlagInstance( + name=flag_def['name'], # Initialize with expected attributes for robustness + condition=flag_def['condition'], + # You can add other relevant fields from flag_def if needed by your code before save + ) + mock_aap_flag_model_cls.side_effect = [mock_constructed_instance] + + mock_aap_flag_model_cls.objects.all.return_value = [] + + # --- Call the function under test --- + create_initial_data() + + # --- Assertions --- + # Assert that Model.objects.filter was called correctly to check for existence + mock_aap_flag_model_cls.objects.filter.assert_called_with(name='NEW_FLAG', condition='some.condition') + + # Assert that the model class was called (instantiated) with the correct arguments + expected_constructor_args = { + 'name': 'NEW_FLAG', + 'condition': 'some.condition', + 'ui_name': 'New Flag', + 'support_level': 'tech_preview', + 'visibility': 'public', + 'value': True, # Crucially, this should now be True from settings + } + mock_aap_flag_model_cls.assert_called_once_with(**expected_constructor_args) + + # Assert that methods were called on the *instance* returned by the constructor + mock_constructed_instance.full_clean.assert_called_once() + mock_constructed_instance.save.assert_called_once() + + @pytest.mark.django_db + def test_load_feature_flags_creates_new_flag_with_default_value_if_not_in_settings( + self, mock_apps_get_model, mock_aap_flag_model_cls, mock_settings, mock_logger, mock_feature_flags_list, mocker + ): + from ansible_base.feature_flags.utils import create_initial_data + + flag_def = { + 'name': 'NEW_FLAG_DEF_VAL', + 'condition': 'another.condition', + 'ui_name': 'New Flag Def Val', + 'support_level': 'supported', + 'visibility': 'private', + 'value': False, # Default value in definition + } + mock_feature_flags_list.return_value = [flag_def] + + mock_empty_queryset = MagicMock() + mock_empty_queryset.first.return_value = None + mock_empty_queryset.__bool__ = lambda self: False + mock_aap_flag_model_cls.objects.filter.return_value = mock_empty_queryset + + mock_constructed_flag = MockAAPFlagInstance() + mock_aap_flag_model_cls.side_effect = [mock_constructed_flag] + + mock_aap_flag_model_cls.objects.all.return_value = [] # For purge_feature_flags + + create_initial_data() + + mock_aap_flag_model_cls.objects.filter.assert_called_with(name='NEW_FLAG_DEF_VAL', condition='another.condition') + mock_aap_flag_model_cls.assert_called_once_with(**flag_def) # value comes from flag_def + + mock_constructed_flag.full_clean.assert_called_once() + mock_constructed_flag.save.assert_called_once() + + @pytest.mark.django_db + def test_load_feature_flags_updates_existing_flag( + self, mock_apps_get_model, mock_aap_flag_model_cls, mock_settings, mock_logger, mock_feature_flags_list, mocker + ): + from ansible_base.feature_flags.utils import create_initial_data + + flag_def_updated = { + 'name': 'EXISTING_FLAG', + 'condition': 'cond1', + 'ui_name': 'Updated UI Name', + 'support_level': 'beta', + 'visibility': 'internal', + 'support_url': 'new.url', + 'required': True, + 'toggle_type': 'static', + 'labels': ['new'], + 'description': 'new desc', + } + mock_feature_flags_list.return_value = [flag_def_updated] + + existing_db_flag = MockAAPFlagInstance( + name='EXISTING_FLAG', + condition='cond1', + ui_name='Old UI Name', + support_level='alpha', + visibility='public', + support_url='old.url', + required=False, + toggle_type='run-time', + labels=['old'], + description='old desc', + ) + + mock_existing_queryset = MagicMock() + mock_existing_queryset.first.return_value = existing_db_flag + mock_existing_queryset.__bool__ = lambda self: True + mock_aap_flag_model_cls.objects.filter.return_value = mock_existing_queryset + + mock_aap_flag_model_cls.objects.all.return_value = [existing_db_flag] + + create_initial_data() + + mock_aap_flag_model_cls.objects.filter.assert_called_with(name='EXISTING_FLAG', condition='cond1') + + # Assert that the existing_db_flag instance was updated + assert existing_db_flag.ui_name == 'Updated UI Name' + assert existing_db_flag.support_level == 'beta' + assert existing_db_flag.visibility == 'internal' + assert existing_db_flag.support_url == 'new.url' + assert existing_db_flag.required is True + assert existing_db_flag.toggle_type == 'static' + assert existing_db_flag.labels == ['new'] + assert existing_db_flag.description == 'new desc' + + existing_db_flag.full_clean.assert_called_once() + existing_db_flag.save.assert_called_once() + mock_aap_flag_model_cls.assert_not_called() # No new instance created + + @pytest.mark.django_db + def test_load_feature_flags_handles_specific_validation_error( + self, mock_apps_get_model, mock_aap_flag_model_cls, mock_settings, mock_logger, mock_feature_flags_list, mocker + ): + from ansible_base.feature_flags.utils import create_initial_data + + flag_def = {'name': 'ERROR_FLAG', 'condition': 'err_cond', 'ui_name': 'Error Flag'} + mock_feature_flags_list.return_value = [flag_def] + + mock_empty_queryset = MagicMock() + mock_empty_queryset.first.return_value = None + mock_empty_queryset.__bool__ = lambda self: False + mock_aap_flag_model_cls.objects.filter.return_value = mock_empty_queryset + + mock_created_instance = MockAAPFlagInstance(**flag_def) + validation_error = ValidationError('Aap flag with this Name and Condition already exists.') + mock_created_instance.save.side_effect = validation_error + + mock_aap_flag_model_cls.side_effect = [mock_created_instance] + + mock_aap_flag_model_cls.objects.all.return_value = [] + + create_initial_data() + + mock_logger.info.assert_called_once_with("Feature flag: ERROR_FLAG already exists") + mock_logger.error.assert_not_called() + mock_created_instance.full_clean.assert_called_once() + + @pytest.mark.django_db + def test_load_feature_flags_logs_other_validation_errors( + self, mock_apps_get_model, mock_aap_flag_model_cls, mock_settings, mock_logger, mock_feature_flags_list, mocker + ): + from ansible_base.feature_flags.utils import create_initial_data + + flag_def = {'name': 'OTHER_ERROR_FLAG', 'condition': 'other_err_cond', 'ui_name': 'Other Error'} + mock_feature_flags_list.return_value = [flag_def] + + mock_empty_queryset = MagicMock() + mock_empty_queryset.first.return_value = None + mock_empty_queryset.__bool__ = lambda self: False + mock_aap_flag_model_cls.objects.filter.return_value = mock_empty_queryset + + mock_created_instance = MockAAPFlagInstance(**flag_def) + validation_error = ValidationError('Some other validation error.') + mock_created_instance.full_clean.side_effect = validation_error + + mock_aap_flag_model_cls.side_effect = [mock_created_instance] + + mock_aap_flag_model_cls.objects.all.return_value = [] + + create_initial_data() + + mock_logger.error.assert_called_once_with(f"Invalid feature flag: {flag_def['name']}. Error: {validation_error}") + mock_logger.info.assert_not_called() + mock_created_instance.save.assert_not_called() + + @pytest.mark.django_db + def test_purge_feature_flags_removes_obsolete_flag(self, mock_apps_get_model, mock_aap_flag_model_cls, mock_logger, mock_feature_flags_list): + from ansible_base.feature_flags.utils import create_initial_data + + obsolete_flag_in_db = MockAAPFlagInstance(name='OBSOLETE_FLAG', condition='obs_cond') + + mock_aap_flag_model_cls.objects.all.return_value = [obsolete_flag_in_db] + mock_empty_queryset = MagicMock() + mock_empty_queryset.first.return_value = None + mock_empty_queryset.__bool__ = lambda self: False + mock_aap_flag_model_cls.objects.filter.return_value = mock_empty_queryset + + create_initial_data() + + mock_aap_flag_model_cls.objects.all.assert_called_once() + obsolete_flag_in_db.delete.assert_called_once() + mock_logger.info.assert_any_call(f"Deleting feature flag: {obsolete_flag_in_db.name} as it is no longer available as a platform flag") + + @pytest.mark.django_db + def test_purge_feature_flags_keeps_current_flag(self, mock_apps_get_model, mock_aap_flag_model_cls, mock_logger, mock_feature_flags_list): + from ansible_base.feature_flags.utils import create_initial_data + + current_flag_def = {'name': 'CURRENT_FLAG', 'condition': 'curr_cond', 'ui_name': 'Current'} + mock_feature_flags_list.return_value = [current_flag_def] + + current_flag_in_db = MockAAPFlagInstance(name='CURRENT_FLAG', condition='curr_cond') + + mock_aap_flag_model_cls.objects.all.return_value = [current_flag_in_db] + + # For load_feature_flags part (update existing) + mock_existing_queryset = MagicMock() + mock_existing_queryset.first.return_value = current_flag_in_db + mock_existing_queryset.__bool__ = lambda self: True + mock_aap_flag_model_cls.objects.filter.return_value = mock_existing_queryset + + create_initial_data() + + current_flag_in_db.delete.assert_not_called() + # Check that logger.info for deletion was not called for this flag + for call_arg in mock_logger.info.call_args_list: + assert "Deleting feature flag: CURRENT_FLAG" not in call_arg[0][0] + + current_flag_in_db.save.assert_called_once() + + def test_create_initial_data_call_order(self, mocker): + from ansible_base.feature_flags.utils import create_initial_data + + # Mock the inner functions directly to check call order + mock_delete = mocker.patch(f"{MODULE_PATH}.purge_feature_flags") + mock_load = mocker.patch(f"{MODULE_PATH}.load_feature_flags") + + manager = MagicMock() + manager.attach_mock(mock_delete, 'delete_flags') + manager.attach_mock(mock_load, 'load_flags') + + create_initial_data() + + expected_calls = [call.delete_flags(), call.load_flags()] + assert manager.mock_calls == expected_calls diff --git a/test_app/tests/feature_flags/views/__init__.py b/test_app/tests/feature_flags/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/feature_flags/views/test_feature_flag.py b/test_app/tests/feature_flags/views/test_feature_flag.py new file mode 100644 index 000000000..dc22cd1a1 --- /dev/null +++ b/test_app/tests/feature_flags/views/test_feature_flag.py @@ -0,0 +1,59 @@ +import pytest +from django.conf import settings + +from ansible_base.feature_flags.models import AAPFlag +from ansible_base.feature_flags.utils import feature_flags_list +from ansible_base.lib.utils.response import get_relative_url + + +@pytest.mark.parametrize( + 'flags_list', + [ + [ + {'name': 'FEATURE_INDIRECT_NODE_COUNTING_ENABLED', 'value': True}, + {'name': 'FEATURE_EDA_ANALYTICS_ENABLED', 'value': True}, + ], + [ + {'name': 'FEATURE_GATEWAY_IPV6_USAGE_ENABLED', 'value': False}, + {'name': 'FEATURE_GATEWAY_CREATE_CRC_SERVICE_TYPE_ENABLED', 'value': True}, + ], + ], +) +def test_feature_flags_states_list(admin_api_client, flags_list): + """ + Test that we can list feature flags api, after preloading data + """ + from ansible_base.feature_flags.utils import create_initial_data + + AAPFlag.objects.all().delete() + for flag in flags_list: + setattr(settings, flag['name'], flag['value']) + expected_flag_states = {item['name']: item['value'] for item in flags_list} + + create_initial_data() + url = get_relative_url("aap_flags_states-list") + response = admin_api_client.get(url) + assert response.status_code == 200 + assert len(response.data['results']) == len(feature_flags_list()) + + found_and_verified_flags_count = 0 + for flag_from_api in response.data['results']: + api_flag_name = flag_from_api.get('name') + if api_flag_name in expected_flag_states: + found_and_verified_flags_count += 1 + expected_value = expected_flag_states[api_flag_name] + actual_value = flag_from_api.get('state') + assert actual_value == expected_value + + # Assert that all flags you intended to check were actually found in the API response and verified + assert found_and_verified_flags_count == len(expected_flag_states) + + +def test_old_feature_flags_list(admin_api_client, aap_flags): + """ + Test that we can list feature flags api, after preloading data + """ + url = get_relative_url("feature-flags-state-list") + response = admin_api_client.get(url) + assert response.status_code == 200 + assert len(response.data) == len(feature_flags_list()) diff --git a/test_app/tests/lib/utils/test_create_system_user.py b/test_app/tests/lib/utils/test_create_system_user.py index e9950ce02..8f2137e99 100644 --- a/test_app/tests/lib/utils/test_create_system_user.py +++ b/test_app/tests/lib/utils/test_create_system_user.py @@ -73,6 +73,7 @@ def test_get_system_user_from_basic_model(self): @pytest.mark.django_db def test_get_system_user_from_managed_model(self): + User.all_objects.filter(username=get_system_username()[0]).delete() create_system_user(user_model=ManagedUser) assert ManagedUser.objects.filter(username=get_system_username()[0]).count() == 0 diff --git a/test_app/tests/resource_registry/test_resource_types_api.py b/test_app/tests/resource_registry/test_resource_types_api.py index 7af80e08a..6e1167e59 100644 --- a/test_app/tests/resource_registry/test_resource_types_api.py +++ b/test_app/tests/resource_registry/test_resource_types_api.py @@ -21,6 +21,7 @@ def test_resource_type_list(admin_api_client): "shared.organization", "shared.roledefinition", "aap.resourcemigrationtestmodel", + "shared.aapflag", ] )