33# SPDX-License-Identifier: MIT
44from __future__ import annotations
55
6+ import os
7+ import sys
8+ from abc import ABC , abstractmethod
69from functools import cached_property
710from typing import TYPE_CHECKING , Any , Optional
811
12+ from dda .config .constants import AppEnvVars
913from dda .feature_flags .client import DatadogFeatureFlag
14+ from dda .secrets .ssm import fetch_secret_ssm
15+ from dda .secrets .vault import fetch_secret_ci
1016from dda .user .datadog import User
11- from dda .utils .ci import running_in_ci
17+ from dda .utils .platform import get_os_name
1218
1319if 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 }
0 commit comments