Skip to content

Commit 229920a

Browse files
authored
Merge pull request #84 from wobcom/feature/autodesc-feature-flag
Feature flags / config with flag for interface auto descriptions
2 parents 43f5941 + 6b9e12d commit 229920a

File tree

7 files changed

+213
-2
lines changed

7 files changed

+213
-2
lines changed

cosmo.example.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
fqdnSuffix: infra.example.com
22
asn: 65542
3+
features:
4+
interface-auto-descriptions: YES
35
devices:
46
router:
57
- "router1"

cosmo/__main__.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import argparse
88

99
from cosmo.clients.netbox import NetboxClient
10+
from cosmo.features import features
1011
from cosmo.log import (
1112
info,
1213
logger,
@@ -15,7 +16,7 @@
1516
HumanReadableLoggingStrategy,
1617
)
1718
from cosmo.serializer import RouterSerializer, SwitchSerializer
18-
from cosmo.common import DeviceSerializationError
19+
from cosmo.common import DeviceSerializationError, APP_NAME
1920

2021

2122
def main() -> int:
@@ -41,6 +42,24 @@ def main() -> int:
4142
parser.add_argument(
4243
"--json", "-j", action="store_true", help="Toggle machine readable output on"
4344
)
45+
parser.add_argument(
46+
"--disable-feature",
47+
default=[],
48+
metavar="DISABLED_FEATURE",
49+
action=features.toggleFeatureActionFactory(False),
50+
choices=features.getAllFeatureNames(),
51+
dest="nofeatures",
52+
help="selectively disable cosmo feature. can be repeated.",
53+
)
54+
parser.add_argument(
55+
"--enable-feature",
56+
default=[],
57+
metavar="ENABLED_FEATURE",
58+
action=features.toggleFeatureActionFactory(True),
59+
choices=features.getAllFeatureNames(),
60+
dest="yesfeatures",
61+
help="selectively enable cosmo feature. can be repeated.",
62+
)
4463

4564
args = parser.parse_args()
4665

@@ -70,11 +89,13 @@ def main() -> int:
7089
cosmo_configuration = {}
7190
with open(args.config, "r") as cfg_file:
7291
cosmo_configuration = yaml.safe_load(cfg_file)
92+
features.setFeaturesFromConfig(cosmo_configuration)
7393

7494
if not "asn" in cosmo_configuration:
7595
error(f"Field 'asn' not defined in configuration file", None)
7696
return 1
7797

98+
info(f"Feature toggles for {APP_NAME}: {features}")
7899
info(f"Fetching information from Netbox, make sure VPN is enabled on your system.")
79100

80101
netbox_url = os.environ.get("NETBOX_URL")

cosmo/clients/netbox.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def __init__(self, url, token):
3232
raise Exception("Unknown Version")
3333

3434
for f, e in feature_flags.items():
35-
log.info(f"Feature {f}: {e}")
35+
log.info(f"Netbox feature {f}: {e}")
3636

3737
def query_version(self):
3838
r = requests.get(

cosmo/features.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# implementation guide
2+
# https://martinfowler.com/articles/feature-toggles.html
3+
from argparse import Action, ArgumentParser
4+
from typing import Never, Self, Optional, TextIO, Sequence, Any
5+
6+
import yaml
7+
8+
9+
class NonExistingFeatureToggleException(Exception):
10+
pass
11+
12+
13+
class FeatureToggle:
14+
CFG_KEY = "features"
15+
16+
def __init__(self, features_default_config: dict[str, bool]):
17+
self._store: dict[str, bool] = features_default_config
18+
self._authorized_keys = list(features_default_config.keys())
19+
20+
def checkFeatureExistsOrRaise(self, key: str) -> bool | Never:
21+
if key in self._authorized_keys:
22+
return True
23+
raise NonExistingFeatureToggleException(
24+
f"feature toggle {key} is unknown, please check your code"
25+
)
26+
27+
def getAllFeatureNames(self) -> list[str]:
28+
return self._authorized_keys
29+
30+
def featureIsEnabled(self, key: str) -> bool:
31+
self.checkFeatureExistsOrRaise(key)
32+
return bool(self._store.get(key))
33+
34+
def setFeature(self, key: str, toggle: bool) -> Self:
35+
self.checkFeatureExistsOrRaise(key)
36+
self._store[key] = toggle
37+
return self # chain
38+
39+
def setFeatures(self, config: dict[str, bool]) -> Self:
40+
for feature_key, feature_toggle in config.items():
41+
self.setFeature(feature_key, feature_toggle)
42+
return self # chain
43+
44+
def setFeaturesFromConfig(self, config: dict) -> Self:
45+
config_dict = dict(config.get(self.CFG_KEY, dict()))
46+
self.setFeatures(config_dict)
47+
return self
48+
49+
def setFeaturesFromYAML(self, yaml_stream_or_str: TextIO | str) -> Self:
50+
config = yaml.safe_load(yaml_stream_or_str)
51+
self.setFeaturesFromConfig(config)
52+
return self
53+
54+
def setFeaturesFromYAMLFile(self, path: str) -> Self:
55+
with open(path, "r") as cfg_file:
56+
self.setFeaturesFromYAML(cfg_file)
57+
return self
58+
59+
def toggleFeatureActionFactory(self, toggle_to: bool) -> type[Action]:
60+
feature_toggle_instance = self
61+
62+
class ToggleFeatureAction(Action):
63+
def __call__(
64+
self,
65+
parser: ArgumentParser,
66+
namespace: object,
67+
values: str | Sequence[Any] | None,
68+
option_string: Optional[str] = None,
69+
):
70+
if type(values) is list:
71+
[feature_toggle_instance.setFeature(v, toggle_to) for v in values]
72+
elif type(values) is str:
73+
feature_toggle_instance.setFeature(values, toggle_to)
74+
setattr(namespace, self.dest, values)
75+
76+
return ToggleFeatureAction
77+
78+
def __str__(self):
79+
features_desc = []
80+
conv = {True: "ENABLED", False: "DISABLED"}
81+
for feature, state in self._store.items():
82+
features_desc.append(f"{feature}: {conv.get(state)}")
83+
return ", ".join(features_desc)
84+
85+
86+
features = FeatureToggle({"interface-auto-descriptions": True})

cosmo/serializer.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
head,
1212
CosmoOutputType,
1313
)
14+
from cosmo.features import features
1415
from cosmo.log import error
1516
from cosmo.netbox_types import DeviceType, CosmoLoopbackType, AbstractNetboxType
1617
from cosmo.loopbacks import LoopbackHelper
@@ -40,6 +41,8 @@ def getMerger():
4041

4142
@staticmethod
4243
def autoDescPreprocess(_: CosmoOutputType, value: AbstractNetboxType):
44+
if not features.featureIsEnabled("interface-auto-descriptions"):
45+
return # early return / skip
4346
MutatingAutoDescVisitor().accept(value)
4447

4548
def walk(
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
features:
2+
feature_a: YES
3+
feature_b: NO

cosmo/tests/test_features.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import argparse
2+
import os.path
3+
4+
import pytest
5+
6+
from cosmo.features import NonExistingFeatureToggleException, FeatureToggle
7+
8+
9+
def test_set_get():
10+
ft = FeatureToggle({"feature_a": False, "feature_b": False})
11+
12+
ft.setFeature("feature_a", True)
13+
assert ft.featureIsEnabled("feature_a")
14+
15+
ft.setFeature("feature_a", False)
16+
assert not ft.featureIsEnabled("feature_a")
17+
18+
ft.setFeatures({"feature_a": True, "feature_b": True})
19+
assert ft.featureIsEnabled("feature_a")
20+
assert ft.featureIsEnabled("feature_b")
21+
22+
23+
def test_config_from_str():
24+
ft = FeatureToggle({"feature_a": False, "feature_b": False})
25+
yaml_config = """
26+
features:
27+
feature_a: NO
28+
feature_b: YES
29+
"""
30+
31+
ft.setFeaturesFromYAML(yaml_config)
32+
assert not ft.featureIsEnabled("feature_a")
33+
assert ft.featureIsEnabled("feature_b")
34+
35+
ft.setFeaturesFromYAMLFile(
36+
os.path.join(os.path.dirname(__file__), "cosmo-test-features-toggles.yaml")
37+
)
38+
assert ft.featureIsEnabled("feature_a")
39+
assert not ft.featureIsEnabled("feature_b")
40+
41+
42+
def test_get_feature_names():
43+
features_dict = {
44+
"feature_a": True,
45+
"feature_b": False,
46+
"feature_c": False,
47+
"feature_d": True,
48+
}
49+
ft = FeatureToggle(features_dict)
50+
assert list(features_dict.keys()) == ft.getAllFeatureNames()
51+
52+
53+
def test_non_existing_features():
54+
ft = FeatureToggle({"feature_a": True})
55+
56+
with pytest.raises(NonExistingFeatureToggleException):
57+
ft.setFeature("i-do-not-exist", True)
58+
59+
60+
def test_argparse_integration():
61+
ft = FeatureToggle({"feature_a": False, "feature_b": False, "feature_c": True})
62+
63+
parser = argparse.ArgumentParser()
64+
parser.add_argument(
65+
"--enable-feature",
66+
default=[],
67+
metavar="ENABLED_FEATURE",
68+
action=ft.toggleFeatureActionFactory(True),
69+
choices=ft.getAllFeatureNames(),
70+
dest="yesfeatures",
71+
help="selectively enable features",
72+
)
73+
parser.add_argument(
74+
"--disable-feature",
75+
default=[],
76+
metavar="DISABLED_FEATURE",
77+
action=ft.toggleFeatureActionFactory(False),
78+
choices=ft.getAllFeatureNames(),
79+
dest="nofeatures",
80+
help="selectively disable features",
81+
)
82+
83+
parser.parse_args(
84+
[
85+
"--enable-feature",
86+
"feature_a",
87+
"--enable-feature",
88+
"feature_b",
89+
"--disable-feature",
90+
"feature_c",
91+
]
92+
)
93+
94+
assert ft.featureIsEnabled("feature_a")
95+
assert ft.featureIsEnabled("feature_b")
96+
assert not ft.featureIsEnabled("feature_c")

0 commit comments

Comments
 (0)