Skip to content

Commit 407d77c

Browse files
committed
WIP: Runtime feature flags
1 parent b581c35 commit 407d77c

File tree

18 files changed

+459
-61
lines changed

18 files changed

+459
-61
lines changed

ansible_base/feature_flags/apps.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
from django.apps import AppConfig
2+
from django.db.models.signals import post_migrate
3+
from django.db.utils import OperationalError, ProgrammingError
4+
5+
from .utils import create_initial_data
26

37

48
class FeatureFlagsConfig(AppConfig):
59
default_auto_field = 'django.db.models.BigAutoField'
610
name = 'ansible_base.feature_flags'
711
label = 'dab_feature_flags'
812
verbose_name = 'Feature Flags'
13+
14+
def ready(self):
15+
from django.conf import settings
16+
17+
if 'ansible_base.feature_flags' in settings.INSTALLED_APPS:
18+
# TODO: Is there a better way to handle this logic?
19+
20+
# If migrations are complete, attempt to load in feature flags again.
21+
# This can help capture any updates to the platform flags loaded in to ensure that new values
22+
# are added and updated.
23+
# Otherwise wait for migrations to be complete before loading in feature flags.
24+
try:
25+
create_initial_data()
26+
except (ProgrammingError, OperationalError):
27+
post_migrate.connect(create_initial_data)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Generated by Django 4.2.17 on 2025-03-21 13:04
2+
3+
import ansible_base.feature_flags.models.aap_flag
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
import django.db.models.deletion
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
initial = True
12+
13+
dependencies = [
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='AAPFlag',
20+
fields=[
21+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created.')),
23+
('created', models.DateTimeField(auto_now_add=True, help_text='The date/time this resource was created.')),
24+
('name', models.CharField(help_text='The name of the feature flag. Must follow the format of FEATURE_<flag-name>_ENABLED.', max_length=64, validators=[ansible_base.feature_flags.models.aap_flag.validate_feature_flag_name])),
25+
('condition', models.CharField(default='boolean', help_text='Used to specify a condition, which if met, will enable the feature flag.', max_length=64)),
26+
('value', models.CharField(default='True', help_text='The value used to evaluate the conditional specified.', max_length=127)),
27+
('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.")),
28+
('support_level', models.CharField(choices=[('NOT_FOR_USE', 'Not for use'), ('NOT_FOR_PRODUCTION', 'Not for production'), ('READY_FOR_PRODUCTION', 'Ready for production')], help_text='The support criteria for the feature flag. Must be one of (NOT_FOR_USE, NOT_FOR_PRODUCTION, READY_FOR_PRODUCTION).', max_length=25)),
29+
('visibility', models.CharField(choices=[('public', 'public'), ('private', 'private')], help_text='The visibility level of the feature flag. If private, flag is hidden.', max_length=20)),
30+
('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)),
31+
('description', models.CharField(default='', help_text='A detailed description giving an overview of the feature flag.', max_length=300)),
32+
('version_added', models.CharField(help_text='The Ansible Automation Platform version the feature flag was added in.', max_length=30)),
33+
('labels', models.JSONField(blank=True, default=list, help_text='A list of labels for the feature flag.', null=True)),
34+
('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)),
35+
('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)),
36+
],
37+
options={
38+
'unique_together': {('name', 'condition', 'value')},
39+
},
40+
),
41+
]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .aap_flag import AAPFlag
2+
3+
__all__ = ('AAPFlag',)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from django.core.exceptions import ValidationError
2+
from django.db import models
3+
from django.utils.translation import gettext_lazy as _
4+
5+
from ansible_base.lib.abstract_models.common import NamedCommonModel
6+
7+
8+
def validate_feature_flag_name(value: str):
9+
if not value.startswith('FEATURE_') or not value.endswith('_ENABLED'):
10+
raise ValidationError(_("Feature flag names must follow the format of `FEATURE_<flag-name>_ENABLED`"))
11+
12+
13+
class AAPFlag(NamedCommonModel):
14+
class Meta:
15+
app_label = "dab_feature_flags"
16+
unique_together = ("name", "condition", "value")
17+
18+
def __str__(self):
19+
return "{name} is enabled when {condition} is " "{value}{required}".format(
20+
name=self.name,
21+
condition=self.condition,
22+
value=self.value,
23+
required=" (required)" if self.required else "",
24+
)
25+
26+
name = models.CharField(
27+
max_length=64,
28+
null=False,
29+
help_text=_("The name of the feature flag. Must follow the format of FEATURE_<flag-name>_ENABLED."),
30+
validators=[validate_feature_flag_name],
31+
blank=False,
32+
)
33+
condition = models.CharField(max_length=64, default="boolean", help_text=_("Used to specify a condition, which if met, will enable the feature flag."))
34+
value = models.CharField(max_length=127, default="True", help_text=_("The value used to evaluate the conditional specified."))
35+
required = models.BooleanField(
36+
default=False,
37+
help_text=_("If multiple conditions are required to be met to enable a feature flag, 'required' can be used to specify the necessary conditionals."),
38+
)
39+
support_level = models.CharField(
40+
max_length=25,
41+
null=False,
42+
help_text=_("The support criteria for the feature flag. Must be one of (NOT_FOR_USE, NOT_FOR_PRODUCTION, READY_FOR_PRODUCTION)."),
43+
choices=(('NOT_FOR_USE', 'Not for use'), ('NOT_FOR_PRODUCTION', 'Not for production'), ('READY_FOR_PRODUCTION', 'Ready for production')),
44+
blank=False,
45+
)
46+
visibility = models.CharField(
47+
max_length=20,
48+
null=False,
49+
choices=[('public', 'public'), ('private', 'private')],
50+
help_text=_("The visibility level of the feature flag. If private, flag is hidden."),
51+
blank=False,
52+
)
53+
toggle_type = models.CharField(
54+
max_length=20,
55+
null=False,
56+
choices=[('install-time', 'install-time'), ('run-time', 'run-time')],
57+
default='run-time',
58+
help_text=_("Details whether a flag is toggle-able at run-time or install-time. (Default: 'run-time')."),
59+
)
60+
description = models.CharField(max_length=300, null=False, default="", help_text=_("A detailed description giving an overview of the feature flag."))
61+
version_added = models.CharField(
62+
max_length=30, null=False, help_text=_("The Ansible Automation Platform version the feature flag was added in."), blank=False
63+
)
64+
labels = models.JSONField(null=True, default=list, help_text=_("A list of labels for the feature flag."), blank=True)
Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
11
from flags.state import flag_state
2-
from rest_framework import serializers
2+
3+
from ansible_base.feature_flags.models import AAPFlag
4+
from ansible_base.lib.serializers.common import NamedCommonModelSerializer
35

46
from .utils import get_django_flags
57

68

7-
class FeatureFlagSerializer(serializers.Serializer):
9+
class FeatureFlagSerializer(NamedCommonModelSerializer):
10+
"""Serialize list of feature flags"""
11+
12+
class Meta:
13+
model = AAPFlag
14+
fields = NamedCommonModelSerializer.Meta.fields + [x.name for x in AAPFlag._meta.concrete_fields]
15+
read_only_fields = ["name", "condition", "required", "support_level", "visibility", "toggle_type", "description", "version_added", "labels"]
16+
17+
def to_representation(self, instance):
18+
ret = super().to_representation(instance)
19+
return ret
20+
21+
22+
# TODO: Remove once all components are migrated to the new endpont.
23+
class OldFeatureFlagSerializer(NamedCommonModelSerializer):
824
"""Serialize list of feature flags"""
925

26+
class Meta:
27+
model = AAPFlag
28+
fields = NamedCommonModelSerializer.Meta.fields + [x.name for x in AAPFlag._meta.concrete_fields]
29+
read_only_fields = ["name", "condition", "required", "support_level", "visibility", "toggle_type", "description", "version_added", "labels"]
30+
1031
def to_representation(self) -> dict:
1132
return_data = {}
1233
feature_flags = get_django_flags()
1334
for feature_flag in feature_flags:
1435
return_data[feature_flag] = flag_state(feature_flag)
15-
1636
return return_data

ansible_base/feature_flags/urls.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
from django.urls import path
1+
from django.urls import include, path
22

33
from ansible_base.feature_flags import views
44
from ansible_base.feature_flags.apps import FeatureFlagsConfig
5+
from ansible_base.lib.routers import AssociationResourceRouter
56

67
app_name = FeatureFlagsConfig.label
78

8-
api_version_urls = [
9-
path('feature_flags_state/', views.FeatureFlagsStateListView.as_view(), name='featureflags-list'),
10-
]
9+
router = AssociationResourceRouter()
10+
11+
router.register(r'feature_flags/states', views.FeatureFlagsStatesView, basename='aap_flags_states')
12+
13+
router.register(r'feature_flags', views.FeatureFlagsView, basename='aap_flags')
14+
15+
# TODO: Remove once all components are migrated to new endpoints.
16+
api_version_urls = [path('feature_flags_state/', views.OldFeatureFlagsStateListView.as_view(), name='featureflags-list'), path('', include(router.urls))]
1117
api_urls = []
1218
root_urls = []
Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,80 @@
1-
from django.conf import settings
1+
from django.apps import apps
2+
from django.core.exceptions import ValidationError
3+
from flags.sources import get_flags
4+
5+
from ansible_base.lib.dynamic_config.feature_flags.platform_flags import AAP_FEATURE_FLAGS
26

37

48
def get_django_flags():
5-
return getattr(settings, 'FLAGS', {})
9+
return get_flags()
10+
11+
12+
def is_boolean_str(val):
13+
return val.lower() in {'true', 'false'}
14+
15+
16+
def create_initial_data(**kwargs):
17+
"""
18+
Loads in platform feature flags when the server starts
19+
"""
20+
21+
from ansible_base.feature_flags.models.aap_flag import AAPFlag
22+
23+
def update_feature_flag(existing: AAPFlag, new):
24+
"""
25+
Update only the required fields of the feature flag model.
26+
This is used to ensure that flags can be loaded in when the server starts, with any applicable updates.
27+
"""
28+
29+
existing.support_level = new['support_level']
30+
existing.visibility = new['visibility']
31+
existing.version_added = new['version_added']
32+
if 'required' in new:
33+
existing.required = new['required']
34+
if 'toggle_type' in new:
35+
existing.toggle_type = new['toggle_type']
36+
if 'labels' in new:
37+
existing.labels = new['labels']
38+
if 'description' in new:
39+
existing.description = new['description']
40+
existing.save()
41+
42+
def load_feature_flags():
43+
"""
44+
Loads in all feature flags into the database. Updates them if necessary.
45+
"""
46+
FeatureFlags = apps.get_model('dab_feature_flags', 'AAPFlag')
47+
for flag in AAP_FEATURE_FLAGS:
48+
try:
49+
existing_flag = FeatureFlags.objects.filter(name=flag['name'], condition=flag['condition'])
50+
if existing_flag:
51+
update_feature_flag(existing_flag.first(), flag)
52+
else:
53+
FeatureFlags.objects.create(**flag)
54+
AAPFlag(**flag).full_clean()
55+
except ValidationError as e:
56+
# Ignore this error unless better way to bypass this
57+
if e.messages[0] == 'Aap flag with this Name, Condition and Value already exists.':
58+
pass
59+
else:
60+
# Raise row validation errors
61+
raise e
62+
63+
def delete_feature_flags():
64+
"""
65+
If a feature flag has been removed from the platform flags list, delete it from the database.
66+
"""
67+
all_flags = apps.get_model('dab_feature_flags', 'AAPFlag').objects.all()
68+
for flag in all_flags:
69+
found = False
70+
for _flag in AAP_FEATURE_FLAGS:
71+
if flag.name == _flag['name'] and flag.condition == _flag['condition']:
72+
found = True
73+
continue
74+
if found:
75+
continue
76+
if not found:
77+
flag.delete()
78+
79+
delete_feature_flags()
80+
load_feature_flags()

ansible_base/feature_flags/views.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,83 @@
1+
from django.shortcuts import get_object_or_404
12
from django.utils.translation import gettext_lazy as _
3+
from flags.state import flag_enabled, flag_state, get_flags
4+
from rest_framework import status
25
from rest_framework.response import Response
6+
from rest_framework.viewsets import ModelViewSet
37

4-
from ansible_base.feature_flags.serializers import FeatureFlagSerializer
8+
from ansible_base.feature_flags.models import AAPFlag
9+
from ansible_base.feature_flags.serializers import FeatureFlagSerializer, OldFeatureFlagSerializer
510
from ansible_base.lib.utils.views.ansible_base import AnsibleBaseView
11+
from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView
12+
from ansible_base.lib.utils.views.permissions import IsSuperuserOrAuditor
13+
from ansible_base.rest_pagination import DefaultPaginator
614

7-
from .utils import get_django_flags
15+
from .utils import get_django_flags, is_boolean_str
816

917

10-
class FeatureFlagsStateListView(AnsibleBaseView):
18+
class FeatureFlagsStatesView(AnsibleBaseDjangoAppApiView, ModelViewSet):
19+
"""
20+
A view class for displaying feature flags states
21+
"""
22+
23+
queryset = AAPFlag.objects.order_by('id')
24+
permission_classes = [IsSuperuserOrAuditor]
25+
http_method_names = ['get', 'head', 'options']
26+
27+
def list(self, request):
28+
paginator = DefaultPaginator()
29+
flags = get_flags()
30+
ret = []
31+
for flag in flags:
32+
ret.append({"flag_name": flag, "flag_state": flag_state(flag)})
33+
result_page = paginator.paginate_queryset(ret, request)
34+
return paginator.get_paginated_response(result_page)
35+
36+
37+
class FeatureFlagsView(AnsibleBaseDjangoAppApiView, ModelViewSet):
1138
"""
1239
A view class for displaying feature flags
1340
"""
1441

42+
queryset = AAPFlag.objects.order_by('id')
1543
serializer_class = FeatureFlagSerializer
44+
permission_classes = [IsSuperuserOrAuditor]
45+
http_method_names = ['get', 'put', 'head', 'options']
46+
47+
def update(self, request, **kwargs):
48+
_feature_flag = self.get_object()
49+
value = request.data.get('value')
50+
if not value:
51+
return Response(status=status.HTTP_400_BAD_REQUEST, data={"details": "Invalid request object."})
52+
53+
# Disable runtime toggle if the feature flag feature is not enabled
54+
if not flag_enabled('FEATURE_FEATURE_FLAGS_ENABLED'):
55+
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data={"details": "Runtime feature flags toggle is not enabled."})
56+
57+
feature_flag = get_object_or_404(AAPFlag, pk=_feature_flag.id)
58+
if feature_flag.toggle_type == 'install-time':
59+
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data={"details": "Install-time feature flags cannot be toggled at run-time."})
60+
if feature_flag.condition == "boolean" and not is_boolean_str(value):
61+
return Response(status=status.HTTP_400_BAD_REQUEST, data={"details": "Feature flag boolean conditional requires using boolean value."})
62+
feature_flag.value = value
63+
feature_flag.save()
64+
65+
return Response(self.get_serializer().to_representation(feature_flag))
66+
67+
68+
# TODO: This can be removed after functionality is migrated over to new class
69+
class OldFeatureFlagsStateListView(AnsibleBaseView):
70+
"""
71+
A view class for displaying feature flags
72+
"""
73+
74+
serializer_class = OldFeatureFlagSerializer
1675
filter_backends = []
1776
name = _('Feature Flags')
1877
http_method_names = ['get', 'head']
1978

2079
def get(self, request, format=None):
21-
self.serializer = FeatureFlagSerializer()
80+
self.serializer = OldFeatureFlagSerializer()
2281
return Response(self.serializer.to_representation())
2382

2483
def get_queryset(self):

ansible_base/lib/dynamic_config/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
load_envvars,
66
load_python_file_with_injected_context,
77
load_standard_settings_files,
8+
toggle_database_feature_flags,
89
toggle_feature_flags,
910
validate,
1011
)
@@ -17,5 +18,6 @@
1718
"load_python_file_with_injected_context",
1819
"load_standard_settings_files",
1920
"toggle_feature_flags",
21+
"toggle_database_feature_flags",
2022
"validate",
2123
]

0 commit comments

Comments
 (0)