Skip to content

Commit f0dcb5e

Browse files
committed
Add support for CI use for feature flags
Add support for CI use for feature flags
1 parent e0fc87d commit f0dcb5e

File tree

10 files changed

+1391
-1276
lines changed

10 files changed

+1391
-1276
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ class AppEnvVars:
2323
NO_DYNAMIC_DEPS = "DDA_NO_DYNAMIC_DEPS"
2424
TELEMETRY_API_KEY = "DDA_TELEMETRY_API_KEY"
2525
FEATURE_FLAGS_CLIENT_TOKEN = "DDA_FEATURE_FLAGS_CLIENT_TOKEN" # noqa: S105 This is not a hardcoded secret but the linter complains on it
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: 125 additions & 42 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,67 +26,38 @@ 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:
30-
self.__app = app
35+
self._app = app
3136

32-
self.__client = DatadogFeatureFlag(self.__client_token, self.__app)
37+
self.__client = DatadogFeatureFlag(self.__client_token, self._app)
3338

3439
# Manually implemented cache to avoid calling several time Feature flag backend on the same flag evaluation.
3540
# Cache key is a tuple of the flag, entity and scopes, to make it hashable.
3641
# For example after calling `enabled("test-flag", default=False, scopes={"user": "user1"}),
3742
# the cache will contain the result for the tuple ("test-flag", "entity", (("user", "user1"),)).
3843
self.__cache: dict[tuple[str, str, tuple[tuple[str, str], ...]], Any] = {}
3944

40-
@cached_property
41-
def __client_token(self) -> str | None:
42-
if running_in_ci(): # We do not support feature flags token retrieval in the CI yet.
43-
return None
44-
45-
from contextlib import suppress
46-
47-
from dda.secrets.api import fetch_client_token, read_client_token, save_client_token
48-
49-
client_token: str | None = None
50-
with suppress(Exception):
51-
client_token = read_client_token()
52-
if not client_token:
53-
client_token = fetch_client_token()
54-
save_client_token(client_token)
55-
56-
return client_token
57-
58-
@property
59-
def __user(self) -> FeatureFlagUser:
60-
return FeatureFlagUser(self.__app.config)
61-
62-
def __get_entity(self) -> str:
63-
if running_in_ci():
64-
import os
65-
66-
return os.getenv("CI_JOB_ID", "default_job_id")
67-
68-
return self.__user.machine_id
69-
7045
def enabled(self, flag: str, *, default: bool = False, scopes: Optional[dict[str, str]] = None) -> bool:
7146
if not self.__client_token:
72-
self.__app.display_debug("No client token found")
47+
self._app.display_debug("No client token found")
7348
return default
7449

75-
entity = self.__get_entity()
76-
base_scopes = self.__get_base_scopes()
50+
entity = self.__entity
51+
base_scopes = self.__base_scopes
7752
if scopes is not None:
7853
base_scopes.update(scopes)
7954

8055
attributes_items = base_scopes.items()
8156
tuple_attributes = tuple(((key, value) for key, value in sorted(attributes_items)))
8257

83-
self.__app.display_debug(f"Checking flag {flag} with entity {entity} and scopes {base_scopes}")
58+
self._app.display_debug(
59+
f"Checking flag {flag} with targeting key {entity} and targeting attributes {tuple_attributes}"
60+
)
8461
flag_value = self.__check_flag(flag, entity, tuple_attributes)
8562
if flag_value is None:
8663
return default
@@ -95,8 +72,114 @@ def __check_flag(self, flag: str, entity: str, scopes: tuple[tuple[str, str], ..
9572
self.__cache[cache_key] = flag_value
9673
return flag_value
9774

98-
def __get_base_scopes(self) -> dict[str, str]:
75+
@abstractmethod
76+
def _get_client_token(self) -> str | None:
77+
pass
78+
79+
@abstractmethod
80+
def _get_entity(self) -> str:
81+
pass
82+
83+
@abstractmethod
84+
def _get_base_scopes(self) -> dict[str, str]:
85+
pass
86+
87+
@cached_property
88+
def __client_token(self) -> str | None:
89+
return self._get_client_token()
90+
91+
@cached_property
92+
def __base_scopes(self) -> dict[str, str]:
93+
return self._get_base_scopes()
94+
95+
@cached_property
96+
def __entity(self) -> str:
97+
return self._get_entity()
98+
99+
100+
class CIFeatureFlagManager(FeatureFlagManager):
101+
"""
102+
A class for querying feature flags in a CI environment.
103+
"""
104+
105+
def _get_client_token(self) -> str | None:
106+
self._app.display_debug(f"Getting client token for {sys.platform}")
107+
try:
108+
match sys.platform:
109+
case "win32":
110+
return self.__get_client_token_windows()
111+
case "darwin":
112+
return self.__get_client_token_macos()
113+
case "linux":
114+
return self.__get_client_token_linux()
115+
case _:
116+
return None
117+
except Exception as e: # noqa: BLE001
118+
self._app.display_warning(f"Error getting client token: {e}, feature flag will be defaulted")
119+
return None
120+
121+
def __get_client_token_windows(self) -> str | None: # noqa: PLR6301
122+
if (client_token := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_SSM_KEY_WINDOWS)) is None:
123+
return None
124+
return fetch_secret_ssm(name=client_token)
125+
126+
def __get_client_token_macos(self) -> str | None: # noqa: PLR6301
127+
if (client_token := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_VAULT_KEY_MACOS)) is None:
128+
return None
129+
if (vault_path := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_VAULT_PATH_MACOS)) is None:
130+
return None
131+
return fetch_secret_ci(vault_path, client_token)
132+
133+
def __get_client_token_linux(self) -> str | None: # noqa: PLR6301
134+
if (client_token := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_VAULT_KEY)) is None:
135+
return None
136+
if (vault_path := os.getenv(AppEnvVars.FEATURE_FLAGS_CI_VAULT_PATH)) is None:
137+
return None
138+
return fetch_secret_ci(vault_path, client_token)
139+
140+
def _get_entity(self) -> str: # noqa: PLR6301
141+
return os.getenv("CI_JOB_ID", "default_entity")
142+
143+
def _get_base_scopes(self) -> dict[str, str]: # noqa: PLR6301
144+
return {
145+
"ci": "true",
146+
"ci.job.name": os.getenv("CI_JOB_NAME", ""),
147+
"ci.job.id": os.getenv("CI_JOB_ID", ""),
148+
"ci.stage.name": os.getenv("CI_JOB_STAGE", ""),
149+
"git.branch": os.getenv("CI_COMMIT_BRANCH", ""),
150+
}
151+
152+
153+
class LocalFeatureFlagManager(FeatureFlagManager):
154+
"""
155+
A class for querying feature flags. This is available as the
156+
[`Application.features`][dda.cli.application.Application.features] property.
157+
"""
158+
159+
def _get_client_token(self) -> str | None: # noqa: PLR6301
160+
from contextlib import suppress
161+
162+
from dda.secrets.api import fetch_client_token, read_client_token, save_client_token
163+
164+
client_token: str | None = None
165+
with suppress(Exception):
166+
client_token = read_client_token()
167+
if not client_token:
168+
client_token = fetch_client_token()
169+
save_client_token(client_token)
170+
171+
return client_token
172+
173+
@property
174+
def __user(self) -> FeatureFlagUser:
175+
return FeatureFlagUser(self._app.config)
176+
177+
def _get_entity(self) -> str:
178+
return self.__user.machine_id
179+
180+
def _get_base_scopes(self) -> dict[str, str]:
99181
return {
100-
"ci": "true" if running_in_ci() else "false",
182+
"platform": get_os_name(),
183+
"ci": "false",
101184
"user": self.__user.email,
102185
}

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)