Skip to content

Commit 740140d

Browse files
committed
Add documentation and JSON Schema validation
1 parent 25381a6 commit 740140d

File tree

10 files changed

+165
-62
lines changed

10 files changed

+165
-62
lines changed
File renamed without changes.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Feature Flag Configuration Schema",
4+
"description": "Validates a list of feature flag configurations.",
5+
"type": "array",
6+
"items": {
7+
"type": "object",
8+
"properties": {
9+
"name": {
10+
"description": "The unique identifier for the feature flag. Must be in all capitals and start with 'FEATURE_' and end with '_ENABLED'",
11+
"type": "string",
12+
"pattern": "^FEATURE_[A-Z0-9_]+_ENABLED$"
13+
},
14+
"ui_name": {
15+
"description": "The human-readable name for the feature flag displayed in the UI.",
16+
"type": "string",
17+
"minLength": 1
18+
},
19+
"visibility": {
20+
"description": "Controls whether the feature is visible in the UI.",
21+
"type": "string",
22+
"enum": ["public", "private"]
23+
},
24+
"condition": {
25+
"description": "The type of condition for the feature flag's value. Currently only boolean is supported.",
26+
"type": "string",
27+
"enum": ["boolean"]
28+
},
29+
"value": {
30+
"description": "The default value of the feature flag, as a string.",
31+
"type": "string",
32+
"enum": ["True", "False"]
33+
},
34+
"support_level": {
35+
"description": "The level of support provided for this feature.",
36+
"type": "string",
37+
"enum": [
38+
"NOT_FOR_USE",
39+
"NOT_FOR_PRODUCTION",
40+
"READY_FOR_PRODUCTION"
41+
]
42+
},
43+
"description": {
44+
"description": "A brief explanation of what the feature does.",
45+
"type": "string"
46+
},
47+
"support_url": {
48+
"description": "A URL to the relevant documentation for the feature.",
49+
"type": "string",
50+
"format": "uri"
51+
},
52+
"toggle_type": {
53+
"description": "The actual value of the feature flag. Note: The YAML string 'False' or 'True' is parsed as a boolean.",
54+
"type": "string",
55+
"enum": ["install-time", "run-time"]
56+
},
57+
"labels": {
58+
"description": "A list of labels to categorize the feature.",
59+
"type": "array",
60+
"items": {
61+
"type": "string",
62+
"enum": ["controller", "eda", "gateway", "platform"]
63+
},
64+
"minItems": 1,
65+
"uniqueItems": true
66+
}
67+
},
68+
"required": [
69+
"name",
70+
"ui_name",
71+
"visibility",
72+
"condition",
73+
"value",
74+
"support_level",
75+
"description",
76+
"support_url"
77+
]
78+
}
79+
}

ansible_base/feature_flags/flag_source.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,6 @@
22
from flags.sources import Condition
33

44

5-
class DatabaseCondition(Condition):
6-
"""Condition that includes the AAPFlags database object"""
7-
8-
def __init__(self, condition, value, required=False, obj=None):
9-
super().__init__(condition, value, required=required)
10-
self.obj = obj
11-
12-
135
class AAPFlagSource(object):
146

157
def get_queryset(self):
@@ -21,5 +13,5 @@ def get_flags(self):
2113
for o in self.get_queryset():
2214
if o.name not in flags:
2315
flags[o.name] = []
24-
flags[o.name].append(DatabaseCondition(o.condition, o.value, required=o.required, obj=o))
16+
flags[o.name].append(Condition(o.condition, o.value, required=o.required))
2517
return flags

ansible_base/feature_flags/serializers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
from flags.state import flag_state
2+
from rest_framework import serializers
23

34
from ansible_base.feature_flags.models import AAPFlag
45
from ansible_base.lib.serializers.common import NamedCommonModelSerializer
56

67
from .utils import get_django_flags
78

89

10+
class FeatureFlagStatesSerializer(NamedCommonModelSerializer):
11+
"""Serialize list of feature flags"""
12+
13+
state = serializers.SerializerMethodField()
14+
15+
def get_state(self, instance):
16+
return flag_state(instance.name)
17+
18+
class Meta:
19+
model = AAPFlag
20+
fields = ["name", "state"]
21+
22+
def to_representation(self, instance=None) -> dict:
23+
instance.state = True
24+
ret = super().to_representation(instance)
25+
return ret
26+
27+
928
# TODO: Remove once all components are migrated to the new endpont.
1029
class OldFeatureFlagSerializer(NamedCommonModelSerializer):
1130
"""Serialize list of feature flags"""

ansible_base/feature_flags/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def get_django_flags():
1616

1717
def feature_flags_list():
1818
current_dir = Path(__file__).parent
19-
flags_list_file = current_dir / 'feature_flags.yaml'
19+
flags_list_file = current_dir / 'definitions/feature_flags.yaml'
2020
with open(flags_list_file, 'r') as file:
2121
try:
2222
return yaml.safe_load(file)

ansible_base/feature_flags/views.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
from django.conf import settings
22
from django.utils.translation import gettext_lazy as _
3-
from flags.sources import get_flags
4-
from flags.state import flag_state
53
from rest_framework.response import Response
64
from rest_framework.viewsets import ModelViewSet
75

86
from ansible_base.feature_flags.models import AAPFlag
9-
from ansible_base.feature_flags.serializers import OldFeatureFlagSerializer
7+
from ansible_base.feature_flags.serializers import FeatureFlagStatesSerializer, OldFeatureFlagSerializer
108
from ansible_base.lib.utils.views.ansible_base import AnsibleBaseView
119
from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView
1210
from ansible_base.lib.utils.views.permissions import IsSuperuserOrAuditor, try_add_oauth2_scope_permission
13-
from ansible_base.rest_pagination import DefaultPaginator
1411

1512
from .utils import get_django_flags
1613

@@ -22,17 +19,9 @@ class FeatureFlagsStatesView(AnsibleBaseDjangoAppApiView, ModelViewSet):
2219

2320
queryset = AAPFlag.objects.order_by('id')
2421
permission_classes = try_add_oauth2_scope_permission([IsSuperuserOrAuditor])
22+
serializer_class = FeatureFlagStatesSerializer
2523
http_method_names = ['get', 'head', 'options']
2624

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

3726
# TODO: This can be removed after functionality is migrated over to new class
3827
class OldFeatureFlagsStateListView(AnsibleBaseView):

docs/apps/feature_flags.md

Lines changed: 40 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,55 +5,32 @@ Additional library documentation can be found at https://cfpb.github.io/django-f
55

66
## Settings
77

8-
Add `ansible_base.feature_flags` to your installed apps:
8+
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:
99

1010
```python
1111
INSTALLED_APPS = [
1212
...
1313
'ansible_base.feature_flags',
14+
'ansible_base.resource_registry', # Must also be added
1415
]
1516
```
1617

17-
### Additional Settings
18+
## Detail
1819

19-
Additional settings are required to enable feature_flags.
20-
This will happen automatically if using [dynamic_settings](../Installation.md)
21-
22-
First, you need to add `flags` to your `INSTALLED_APPS`:
20+
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.
21+
To receive flag state updates, ensure the following definition is available in your components `RESOURCE_LIST` -
2322

2423
```python
25-
INSTALLED_APPS = [
26-
...
27-
'flags',
28-
...
29-
]
30-
```
31-
32-
Additionally, create a `FLAGS` entry:
33-
34-
```python
35-
FLAGS = {}
36-
```
37-
38-
Finally, add `django.template.context_processors.request` to your `TEMPLATES` `context_processors` setting:
24+
from ansible_base.feature_flags.models import AAPFlag
25+
from ansible_base.resource_registry.shared_types import FeatureFlagType
3926

40-
```python
41-
TEMPLATES = [
42-
{
43-
'BEACKEND': 'django.template.backends.django.DjangoTemplates',
44-
...
45-
'OPTIONS': {
46-
...
47-
'context_processors': [
48-
...
49-
'django.template.context_processors.request',
50-
...
51-
]
52-
...
53-
}
54-
...
55-
}
56-
]
27+
RESOURCE_LIST = (
28+
...
29+
ResourceConfig(
30+
AAPFlag,
31+
shared_resource=SharedResource(serializer=FeatureFlagType, is_provider=False),
32+
),
33+
)
5734
```
5835

5936
## URLS
@@ -70,3 +47,29 @@ urlpatterns = [
7047
...
7148
]
7249
```
50+
51+
## Adding Feature Flags
52+
53+
To add a feature flag to the platform, specify it in the following [file](../../ansible_base/feature_flags/definitions/feature_flags.yaml)
54+
55+
An example flag could resemble -
56+
57+
```yaml
58+
- name: FEATURE_FOO_ENABLED
59+
ui_name: Foo
60+
visibility: public
61+
condition: boolean
62+
value: 'False'
63+
support_level: NOT_FOR_PRODUCTION
64+
description: TBD
65+
support_url: https://docs.redhat.com/en/documentation/red_hat_ansible_automation_platform/2.5/
66+
labels:
67+
- controller
68+
```
69+
70+
Validate this file against the json schema by running `check-jsonschema` -
71+
72+
```bash
73+
pip install check-jsonschema
74+
check-jsonschema --schemafile ansible_base/feature_flags/definitions/schema.json ansible_base/feature_flags/definitions/feature_flags.yaml
75+
```

requirements/requirements_dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ flake8==7.1.1 # Linting tool, if changed update pyproject.toml as well
99
Flake8-pyproject==1.2.3 # Linting tool, if changed update pyproject.toml as well
1010
ipython
1111
isort==6.0.0 # Linting tool, if changed update pyproject.toml as well
12+
jsonschema
1213
tox
1314
tox-docker
1415
typeguard

test_app/tests/feature_flags/test_utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import json
12
from unittest.mock import MagicMock, call
23

34
import pytest
5+
import yaml
46
from django.core.exceptions import ValidationError
7+
from jsonschema import validate
58

69
MODULE_PATH = "ansible_base.feature_flags.utils"
710

@@ -119,6 +122,23 @@ def test_get_django_flags(mocker):
119122
assert result == {"FLAG_X": True}
120123

121124

125+
def test_validate_flags_yaml_against_json_schema():
126+
feature_flags_yaml = 'ansible_base/feature_flags/definitions/feature_flags.yaml'
127+
feature_flags_schema = 'ansible_base/feature_flags/definitions/schema.json'
128+
try:
129+
with open(feature_flags_yaml, 'r') as file:
130+
feature_flags_file = yaml.safe_load(file)
131+
with open(feature_flags_schema, 'r') as file:
132+
schema = json.load(file)
133+
validate(instance=feature_flags_file, schema=schema)
134+
assert True, "Validation succeeded as expected."
135+
except FileNotFoundError as e:
136+
pytest.fail(f"Could not find a necessary file: {e}. Make sure schema.json and valid_data.yaml exist.")
137+
except Exception as e:
138+
# If any other exception occurs (like a ValidationError), fail the test.
139+
pytest.fail(f"Validation failed unexpectedly for a valid file: {e}")
140+
141+
122142
class TestCreateInitialData:
123143

124144
@pytest.mark.django_db # May not be strictly necessary with all the mocking, but good practice

test_app/tests/feature_flags/views/test_feature_flag.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ def test_feature_flags_states_list(admin_api_client, flags_list):
3737

3838
found_and_verified_flags_count = 0
3939
for flag_from_api in response.data['results']:
40-
api_flag_name = flag_from_api.get('flag_name')
40+
api_flag_name = flag_from_api.get('name')
4141
if api_flag_name in expected_flag_states:
4242
found_and_verified_flags_count += 1
4343
expected_value = expected_flag_states[api_flag_name]
44-
actual_value = flag_from_api.get('flag_state')
44+
actual_value = flag_from_api.get('state')
4545
assert actual_value == expected_value
4646

4747
# Assert that all flags you intended to check were actually found in the API response and verified

0 commit comments

Comments
 (0)