Skip to content

Commit 526eedd

Browse files
committed
Add support for CI use for feature flags
Add support for CI use for feature flags
1 parent 7833e9c commit 526eedd

File tree

9 files changed

+1309
-1154
lines changed

9 files changed

+1309
-1154
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ classifiers = [
2626
dependencies = [
2727
"ada-url~=1.15.3",
2828
"binary~=1.0.2",
29+
"boto3~=1.38.8",
2930
"click~=8.1",
3031
"datadog-api-client~=2.34",
3132
"dep-sync~=0.1",

src/dda/cli/application.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from dda.cli.terminal import Terminal
1111
from dda.config.constants import AppEnvVars
12+
from dda.feature_flags.manager import CIFeatureFlagManager, LocalFeatureFlagManager
13+
from dda.utils.ci import running_in_ci
1214

1315
if TYPE_CHECKING:
1416
from collections.abc import Callable
@@ -129,9 +131,10 @@ def telemetry(self) -> TelemetryManager:
129131

130132
@cached_property
131133
def features(self) -> FeatureFlagManager:
132-
from dda.feature_flags.manager import FeatureFlagManager
133-
134-
return FeatureFlagManager(self)
134+
self.display_info(f"IS IT RUNNING IN CI {running_in_ci()}")
135+
if running_in_ci():
136+
return CIFeatureFlagManager(self)
137+
return LocalFeatureFlagManager(self)
135138

136139
@cached_property
137140
def dynamic_deps_allowed(self) -> bool:

src/dda/config/constants.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ class AppEnvVars:
2222
VERBOSE = "DDA_VERBOSE"
2323
NO_DYNAMIC_DEPS = "DDA_NO_DYNAMIC_DEPS"
2424
TELEMETRY_API_KEY = "DDA_TELEMETRY_API_KEY"
25-
FEATURE_FLAGS_CLIENT_TOKEN = "DDA_FEATURE_FLAGS_CLIENT_TOKEN" # noqa: S105i
25+
FEATURE_FLAGS_CLIENT_TOKEN = "DDA_FEATURE_FLAGS_CLIENT_TOKEN" # noqa: S105
26+
FEATURE_FLAGS_CI_VAULT_PATH = "DDA_FEATURE_FLAGS_CI_VAULT_PATH"
27+
FEATURE_FLAGS_CI_VAULT_KEY = "DDA_FEATURE_FLAGS_CI_VAULT_KEY"
28+
FEATURE_FLAGS_CI_SSM_KEY_WINDOWS = "DDA_FEATURE_FLAGS_CI_SSM_KEY_WINDOWS"
29+
FEATURE_FLAGS_CI_VAULT_KEY_MACOS = "DDA_FEATURE_FLAGS_CI_VAULT_KEY_MACOS"
30+
FEATURE_FLAGS_CI_VAULT_PATH_MACOS = "DDA_FEATURE_FLAGS_CI_VAULT_PATH_MACOS"
2631
TELEMETRY_USER_MACHINE_ID = "DDA_TELEMETRY_USER_MACHINE_ID"
2732
# https://no-color.org
2833
NO_COLOR = "NO_COLOR"

src/dda/feature_flags/manager.py

Lines changed: 136 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
# SPDX-License-Identifier: MIT
44
from __future__ import annotations
55

6+
import os
7+
import sys
8+
from abc import ABC, abstractmethod
69
from functools import cached_property
710
from typing import TYPE_CHECKING, Any, Optional
811

12+
from dda.config.constants import AppEnvVars
913
from dda.feature_flags.client import DatadogFeatureFlag
14+
from dda.secrets.ssm import fetch_secret_ssm
15+
from dda.secrets.vault import fetch_secret_ci
1016
from dda.user.datadog import User
11-
from dda.utils.ci import running_in_ci
17+
from dda.utils.platform import get_os_name
1218

1319
if TYPE_CHECKING:
1420
from dda.cli.application import Application
@@ -20,10 +26,9 @@ def __init__(self, config: RootConfig) -> None:
2026
super().__init__(config)
2127

2228

23-
class FeatureFlagManager:
29+
class FeatureFlagManager(ABC):
2430
"""
25-
A class for querying feature flags. This is available as the
26-
[`Application.features`][dda.cli.application.Application.features] property.
31+
A class for querying feature flags.
2732
"""
2833

2934
def __init__(self, app: Application) -> None:
@@ -34,11 +39,129 @@ def __init__(self, app: Application) -> None:
3439
# the cache will contain the result for the tuple ("test-flag", "entity", (("user", "user1"),)).
3540
self.__cache: dict[tuple[str, str, tuple[tuple[str, str], ...]], Any] = {}
3641

42+
def enabled(self, flag: str, *, default: bool = False, scopes: Optional[dict[str, str]] = None) -> bool:
43+
entity = self.__entity
44+
base_scopes = self.__base_scopes
45+
if scopes is not None:
46+
base_scopes.update(scopes)
47+
48+
attributes_items = base_scopes.items()
49+
tuple_attributes = tuple(((key, value) for key, value in sorted(attributes_items)))
50+
51+
self._app.display_debug(
52+
f"Checking flag {flag} with targeting key {entity} and targeting attributes {tuple_attributes}"
53+
)
54+
flag_value = self.__check_flag(flag, entity, tuple_attributes)
55+
if flag_value is None:
56+
return default
57+
return flag_value
58+
59+
def __check_flag(self, flag: str, entity: str, scopes: tuple[tuple[str, str], ...]) -> bool | None:
60+
if self.__client is None:
61+
self.__app.display_debug("Feature flag client not initialized properly")
62+
return None
63+
64+
cache_key = (flag, entity, scopes)
65+
if cache_key in self.__cache:
66+
return self.__cache[cache_key]
67+
68+
flag_value = self.__client.get_flag_value(flag, entity, dict(scopes))
69+
self.__cache[cache_key] = flag_value
70+
return flag_value
71+
72+
@abstractmethod
73+
def _get_client_token(self) -> str | None:
74+
pass
75+
76+
@abstractmethod
77+
def _get_entity(self) -> str:
78+
pass
79+
80+
@abstractmethod
81+
def _get_base_scopes(self) -> dict[str, str]:
82+
pass
83+
84+
@abstractmethod
85+
def _get_client_token(self) -> str | None:
86+
pass
87+
3788
@cached_property
3889
def __client(self) -> DatadogFeatureFlag | None:
39-
if running_in_ci(): # We do not support feature flags token retrieval in the CI yet.
90+
token = self._get_client_token()
91+
if token is None:
4092
return None
93+
return DatadogFeatureFlag(token, self.__app)
4194

95+
96+
@cached_property
97+
def __base_scopes(self) -> dict[str, str]:
98+
return self._get_base_scopes()
99+
100+
@cached_property
101+
def __entity(self) -> str:
102+
return self._get_entity()
103+
104+
105+
class CIFeatureFlagManager(FeatureFlagManager):
106+
"""
107+
A class for querying feature flags in a CI environment.
108+
"""
109+
110+
def _get_client_token(self) -> str | None:
111+
self._app.display_debug(f"Getting client token for {sys.platform}")
112+
try:
113+
match sys.platform:
114+
case "win32":
115+
return self.__get_client_token_windows()
116+
case "darwin":
117+
return self.__get_client_token_macos()
118+
case "linux":
119+
return self.__get_client_token_linux()
120+
case _:
121+
return None
122+
except Exception as e: # noqa: BLE001
123+
self._app.display_warning(f"Error getting client token: {e}, feature flag will be defaulted")
124+
return None
125+
126+
def __get_client_token_windows(self) -> str | None: # noqa: PLR6301
127+
if (client_token := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_SSM_KEY_WINDOWS)) is None:
128+
return None
129+
return fetch_secret_ssm(name=client_token)
130+
131+
def __get_client_token_macos(self) -> str | None: # noqa: PLR6301
132+
if (client_token := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_VAULT_KEY_MACOS)) is None:
133+
return None
134+
if (vault_path := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_VAULT_PATH_MACOS)) is None:
135+
return None
136+
return fetch_secret_ci(vault_path, client_token)
137+
138+
def __get_client_token_linux(self) -> str | None: # noqa: PLR6301
139+
if (client_token := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_VAULT_KEY)) is None:
140+
return None
141+
if (vault_path := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_VAULT_PATH)) is None:
142+
return None
143+
return fetch_secret_ci(vault_path, client_token)
144+
145+
def _get_entity(self) -> str: # noqa: PLR6301
146+
return os.getenv("CI_JOB_ID", "default_entity")
147+
148+
def _get_base_scopes(self) -> dict[str, str]: # noqa: PLR6301
149+
return {
150+
"ci": "true",
151+
"ci.job.name": os.getenv("CI_JOB_NAME", ""),
152+
"ci.job.id": os.getenv("CI_JOB_ID", ""),
153+
"ci.stage.name": os.getenv("CI_JOB_STAGE", ""),
154+
"git.branch": os.getenv("CI_COMMIT_BRANCH", ""),
155+
}
156+
157+
158+
class LocalFeatureFlagManager(FeatureFlagManager):
159+
"""
160+
A class for querying feature flags. This is available as the
161+
[`Application.features`][dda.cli.application.Application.features] property.
162+
"""
163+
164+
def _get_client_token(self) -> str | None: # noqa: PLR6301
42165
from contextlib import suppress
43166

44167
from dda.secrets.api import fetch_client_token, read_client_token, save_client_token
@@ -54,16 +177,12 @@ def __client(self) -> DatadogFeatureFlag | None:
54177

55178
@property
56179
def __user(self) -> FeatureFlagUser:
57-
return FeatureFlagUser(self.__app.config)
58-
59-
def __get_entity(self) -> str:
60-
if running_in_ci():
61-
import os
62-
63-
return os.getenv("CI_JOB_ID", "default_job_id")
180+
return FeatureFlagUser(self._app.config)
64181

182+
def _get_entity(self) -> str:
65183
return self.__user.machine_id
66184

185+
<<<<<<< HEAD
67186
def enabled(self, flag: str, *, default: bool = False, scopes: Optional[dict[str, str]] = None) -> bool:
68187
entity = self.__get_entity()
69188
base_scopes = self.__get_base_scopes()
@@ -93,7 +212,11 @@ def __check_flag(self, flag: str, entity: str, scopes: tuple[tuple[str, str], ..
93212
return flag_value
94213

95214
def __get_base_scopes(self) -> dict[str, str]:
215+
=======
216+
def _get_base_scopes(self) -> dict[str, str]:
217+
>>>>>>> be7a4ee (Add support for CI use for feature flags)
96218
return {
97-
"ci": "true" if running_in_ci() else "false",
219+
"platform": get_os_name(),
220+
"ci": "false",
98221
"user": self.__user.email,
99222
}

src/dda/secrets/ssm.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def fetch_secret_ssm(name: str) -> str:
2+
import boto3
3+
4+
ssm = boto3.client("ssm", region_name="us-east-1")
5+
response = ssm.get_parameter(Name=name, WithDecryption=True)
6+
return response["Parameter"]["Value"]

src/dda/secrets/vault.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import hvac
1111
from ada_url import URL, URLSearchParams
1212

13+
from dda.utils.ci import running_in_ci
14+
1315
VAULT_URL = "https://vault.us1.ddbuild.io"
1416
OIDC_CALLBACK_PORT = 8250
1517
OIDC_REDIRECT_URI = f"http://localhost:{OIDC_CALLBACK_PORT}/oidc/callback"
@@ -94,6 +96,18 @@ def do_GET(self) -> None: # noqa: N802
9496

9597

9698
def fetch_secret(name: str, key: str) -> str:
99+
if running_in_ci():
100+
return fetch_secret_ci(name, key)
101+
return fetch_secret_local(name, key)
102+
103+
104+
def fetch_secret_local(name: str, key: str) -> str:
97105
client = init_client()
98106
secret = client.secrets.kv.v2.read_secret_version(path=name, mount_point="kv")
99107
return secret["data"]["data"][key]
108+
109+
110+
def fetch_secret_ci(name: str, key: str) -> str:
111+
client = hvac.Client()
112+
secret = client.secrets.kv.v2.read_secret_version(path=name, mount_point="kv")
113+
return secret["data"]["data"][key]

src/dda/telemetry/daemon/trace.py

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,37 +15,13 @@
1515
from dda.telemetry.constants import SERVICE_NAME, SERVICE_VERSION
1616
from dda.telemetry.daemon.base import TelemetryClient
1717
from dda.utils.network.http.client import get_http_client
18-
from dda.utils.platform import get_machine_id
18+
from dda.utils.platform import get_machine_id, get_os_name, get_os_version
1919

2020
if TYPE_CHECKING:
2121
from types import TracebackType
2222

2323
URL = "https://instrumentation-telemetry-intake.datadoghq.com/api/v2/apmtelemetry"
2424

25-
if sys.platform == "win32":
26-
27-
def get_os_name() -> str:
28-
return f"{platform.system()} {platform.win32_ver()[0]} {platform.win32_edition()}"
29-
30-
def get_os_version() -> str:
31-
return platform.win32_ver()[0]
32-
33-
elif sys.platform == "darwin":
34-
35-
def get_os_name() -> str:
36-
return f"{platform.system()} {platform.mac_ver()[0]}"
37-
38-
def get_os_version() -> str:
39-
return platform.mac_ver()[0]
40-
41-
else:
42-
43-
def get_os_name() -> str:
44-
return platform.freedesktop_os_release()["NAME"]
45-
46-
def get_os_version() -> str:
47-
return platform.freedesktop_os_release()["VERSION_ID"]
48-
4925

5026
@cache
5127
def get_base_payload() -> dict[str, Any]:

src/dda/utils/platform/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import contextlib
77
import os
8+
import platform
89
import sys
910
from functools import cache
1011
from typing import TYPE_CHECKING
@@ -174,3 +175,28 @@ def get_machine_id() -> UUID:
174175
import uuid
175176

176177
return uuid.uuid5(uuid.NAMESPACE_DNS, str(uuid.getnode()))
178+
179+
180+
if sys.platform == "win32":
181+
182+
def get_os_name() -> str:
183+
return f"{platform.system()} {platform.win32_ver()[0]} {platform.win32_edition()}"
184+
185+
def get_os_version() -> str:
186+
return platform.win32_ver()[0]
187+
188+
elif sys.platform == "darwin":
189+
190+
def get_os_name() -> str:
191+
return f"{platform.system()} {platform.mac_ver()[0]}"
192+
193+
def get_os_version() -> str:
194+
return platform.mac_ver()[0]
195+
196+
else:
197+
198+
def get_os_name() -> str:
199+
return platform.freedesktop_os_release()["NAME"]
200+
201+
def get_os_version() -> str:
202+
return platform.freedesktop_os_release()["VERSION_ID"]

0 commit comments

Comments
 (0)