Skip to content

AAP-45875 Runtime Feature Flags #736

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: devel
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/.github
**/.cache
**/.gitignore
**/.venv
**/venv
**/.tox
6 changes: 6 additions & 0 deletions ansible_base/feature_flags/apps.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
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):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ansible_base.feature_flags'
label = 'dab_feature_flags'
verbose_name = 'Feature Flags'

def ready(self):
post_migrate.connect(create_initial_data, sender=self)
54 changes: 54 additions & 0 deletions ansible_base/feature_flags/definitions/feature_flags.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
- 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
77 changes: 77 additions & 0 deletions ansible_base/feature_flags/definitions/schema.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
29 changes: 29 additions & 0 deletions ansible_base/feature_flags/flag_source.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
Empty file.
50 changes: 50 additions & 0 deletions ansible_base/feature_flags/management/commands/feature_flags.py
Original file line number Diff line number Diff line change
@@ -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('')
43 changes: 43 additions & 0 deletions ansible_base/feature_flags/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 4.2.21 on 2025-06-24 13:34
# FileHash: 8207dc6b9a446b7d4222d21287e695990b80846c779184388dbd63d32771b400

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_<flag-name>_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')], 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='The visibility of the feature flag. If false, flag is hidden.')),
('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)),
('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')},
},
),
]
21 changes: 21 additions & 0 deletions ansible_base/feature_flags/migrations/example_migration
Original file line number Diff line number Diff line change
@@ -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: <FileHash>

# from django.db import migrations


# class Migration(migrations.Migration):

# dependencies = [
# ('dab_feature_flags', '0001_initial'),
# ]

# operations = [
# ]
3 changes: 3 additions & 0 deletions ansible_base/feature_flags/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .aap_flag import AAPFlag

__all__ = ('AAPFlag',)
67 changes: 67 additions & 0 deletions ansible_base/feature_flags/models/aap_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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_<flag-name>_ENABLED`"))


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_<flag-name>_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,
)
visibility = models.BooleanField(
default=False,
help_text=_("The visibility of the feature flag. If false, flag is hidden."),
)
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)
Loading
Loading