Skip to content

Commit bd916e4

Browse files
committed
--wip-- [skip ci]
Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com>
1 parent b6d767d commit bd916e4

File tree

25 files changed

+738
-266
lines changed

25 files changed

+738
-266
lines changed

providers/openfeature-provider-flagd/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ classifiers = [
1818
keywords = []
1919
dependencies = [
2020
"openfeature-sdk>=0.6.0",
21-
"grpcio>=1.68.0",
21+
"grpcio>=1.68.1",
2222
"protobuf>=4.25.2",
2323
"mmh3>=4.1.0",
2424
"panzi-json-logic>=1.0.1",

providers/openfeature-provider-flagd/pytest.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@ markers =
44
in-process: tests for rpc mode.
55
customCert: Supports custom certs.
66
unixsocket: Supports unixsockets.
7+
targetURI: Supports targetURI.
8+
grace: Supports grace attempts.
9+
targeting: Supports targeting.
10+
fractional: Supports fractional.
11+
string: Supports string.
12+
semver: Supports semver.
13+
reconnect: Supports reconnect.
714
events: Supports events.
815
sync: Supports sync.
916
caching: Supports caching.
1017
offline: Supports offline.
18+
bdd_features_base_dir = tests/features

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@ def __init__( # noqa: PLR0913
4343
host: typing.Optional[str] = None,
4444
port: typing.Optional[int] = None,
4545
tls: typing.Optional[bool] = None,
46-
deadline: typing.Optional[int] = None,
46+
deadline_ms: typing.Optional[int] = None,
4747
timeout: typing.Optional[int] = None,
4848
retry_backoff_ms: typing.Optional[int] = None,
4949
resolver_type: typing.Optional[ResolverType] = None,
5050
offline_flag_source_path: typing.Optional[str] = None,
5151
stream_deadline_ms: typing.Optional[int] = None,
5252
keep_alive_time: typing.Optional[int] = None,
53-
cache_type: typing.Optional[CacheType] = None,
53+
cache: typing.Optional[CacheType] = None,
5454
max_cache_size: typing.Optional[int] = None,
5555
retry_backoff_max_ms: typing.Optional[int] = None,
5656
retry_grace_period: typing.Optional[int] = None,
@@ -61,16 +61,16 @@ def __init__( # noqa: PLR0913
6161
:param host: the host to make requests to
6262
:param port: the port the flagd service is available on
6363
:param tls: enable/disable secure TLS connectivity
64-
:param deadline: the maximum to wait before a request times out
64+
:param deadline_ms: the maximum to wait before a request times out
6565
:param timeout: the maximum time to wait before a request times out
6666
:param retry_backoff_ms: the number of milliseconds to backoff
6767
:param offline_flag_source_path: the path to the flag source file
6868
:param stream_deadline_ms: the maximum time to wait before a request times out
6969
:param keep_alive_time: the number of milliseconds to keep alive
7070
:param resolver_type: the type of resolver to use
7171
"""
72-
if deadline is None and timeout is not None:
73-
deadline = timeout * 1000
72+
if deadline_ms is None and timeout is not None:
73+
deadline_ms = timeout * 1000
7474
warnings.warn(
7575
"'timeout' property is deprecated, please use 'deadline' instead, be aware that 'deadline' is in milliseconds",
7676
DeprecationWarning,
@@ -81,15 +81,15 @@ def __init__( # noqa: PLR0913
8181
host=host,
8282
port=port,
8383
tls=tls,
84-
deadline_ms=deadline,
84+
deadline_ms=deadline_ms,
8585
retry_backoff_ms=retry_backoff_ms,
8686
retry_backoff_max_ms=retry_backoff_max_ms,
8787
retry_grace_period=retry_grace_period,
8888
resolver=resolver_type,
8989
offline_flag_source_path=offline_flag_source_path,
9090
stream_deadline_ms=stream_deadline_ms,
9191
keep_alive_time=keep_alive_time,
92-
cache=cache_type,
92+
cache=cache,
9393
max_cache_size=max_cache_size,
9494
)
9595

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py

Lines changed: 17 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import json
21
import logging
32
import threading
43
import time
@@ -55,47 +54,22 @@ def __init__(
5554
self.emit_provider_error = emit_provider_error
5655
self.emit_provider_stale = emit_provider_stale
5756
self.emit_provider_configuration_changed = emit_provider_configuration_changed
58-
self.cache: typing.Optional[BaseCacheImpl] = (
59-
LRUCache(maxsize=self.config.max_cache_size)
60-
if self.config.cache == CacheType.LRU
61-
else None
62-
)
57+
self.cache: typing.Optional[BaseCacheImpl] = self._create_cache()
6358

64-
retry_backoff_seconds = config.retry_backoff_ms * 0.001
65-
retry_backoff_max_seconds = config.retry_backoff_max_ms * 0.001
6659
self.retry_grace_period = config.retry_grace_period
6760
self.streamline_deadline_seconds = config.stream_deadline_ms * 0.001
6861
self.deadline = config.deadline_ms * 0.001
6962
self.connected = False
7063
channel_factory = grpc.secure_channel if config.tls else grpc.insecure_channel
71-
service_config = {
72-
"methodConfig": [
73-
{
74-
"name": [
75-
{
76-
"service": "flagd.evaluation.v1.Service",
77-
"method": "EventStream",
78-
}
79-
],
80-
"retryPolicy": {
81-
"maxAttempts": 50000, # Max value for a 32-bit integer
82-
"initialBackoff": f"{retry_backoff_seconds}s", # Initial backoff delay
83-
"maxBackoff": f"{retry_backoff_max_seconds}s", # Maximum backoff delay
84-
"backoffMultiplier": 2, # Exponential backoff multiplier
85-
"retryableStatusCodes": [
86-
"UNAVAILABLE",
87-
"UNKNOWN",
88-
], # Retry on these statuses
89-
},
90-
}
91-
],
92-
}
9364

9465
# Create the channel with the service config
9566
options = [
96-
("grpc.service_config", json.dumps(service_config)),
9767
("grpc.keepalive_time_ms", config.keep_alive_time),
68+
("grpc.initial_reconnect_backoff_ms", config.retry_backoff_ms),
69+
("grpc.max_reconnect_backoff_ms", config.retry_backoff_max_ms),
70+
("grpc.min_reconnect_backoff_ms", config.deadline_ms),
9871
]
72+
9973
self.channel = channel_factory(
10074
f"{config.host}:{config.port}",
10175
options=options,
@@ -104,15 +78,21 @@ def __init__(
10478

10579
self.thread: typing.Optional[threading.Thread] = None
10680
self.timer: typing.Optional[threading.Timer] = None
81+
self.active = False
82+
83+
def _create_cache(self):
84+
return (
85+
LRUCache(maxsize=self.config.max_cache_size)
86+
if self.config.cache == CacheType.LRU
87+
else None
88+
)
10789

10890
def initialize(self, evaluation_context: EvaluationContext) -> None:
10991
self.connect()
11092

11193
def shutdown(self) -> None:
11294
self.active = False
11395
self.channel.close()
114-
if self.cache:
115-
self.cache.clear()
11696

11797
def connect(self) -> None:
11898
self.active = True
@@ -167,7 +147,7 @@ def state_change_callback(new_state: ChannelConnectivity) -> None:
167147

168148
def emit_error(self) -> None:
169149
logger.debug("gRPC error emitted")
170-
if self.cache:
150+
if self.cache is not None:
171151
self.cache.clear()
172152
self.emit_provider_error(
173153
ProviderEventDetails(
@@ -189,7 +169,9 @@ def listen(self) -> None:
189169
while self.active:
190170
try:
191171
logger.info("Setting up gRPC sync flags connection")
192-
for message in self.stub.EventStream(request, **call_args):
172+
for message in self.stub.EventStream(
173+
request, wait_for_ready=True, **call_args
174+
):
193175
if message.type == "provider_ready":
194176
self.connected = True
195177
self.emit_provider_ready(

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ def __init__(
3131
self.last_modified = 0.0
3232
self.flag_data: typing.Mapping[str, Flag] = {}
3333
self.load_data()
34+
self.active = True
3435
self.thread = threading.Thread(target=self.refresh_file, daemon=True)
3536
self.thread.start()
36-
self.active = True
3737

3838
def shutdown(self) -> None:
3939
self.active = False
Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import typing
22

3-
from tests.e2e.steps import * # noqa: F403
3+
from tests.e2e.step.config_steps import * # noqa: F403
4+
from tests.e2e.step.context_steps import * # noqa: F403
5+
from tests.e2e.step.event_steps import * # noqa: F403
6+
from tests.e2e.step.flag_step import * # noqa: F403
7+
from tests.e2e.step.provider_steps import * # noqa: F403
8+
from tests.e2e.steps import * # noqa: F403 # noqa: F403
49

510
JsonPrimitive = typing.Union[str, bool, float, int]
611

712
TEST_HARNESS_PATH = "../../openfeature/test-harness"
813
SPEC_PATH = "../../openfeature/spec"
9-
10-
11-
# running all gherkin tests, except the ones, not implemented
12-
def pytest_collection_modifyitems(config):
13-
marker = "not customCert and not unixsocket and not sync and not targetURI"
14-
15-
# this seems to not work with python 3.8
16-
if hasattr(config.option, "markexpr") and config.option.markexpr == "":
17-
config.option.markexpr = marker

providers/openfeature-provider-flagd/tests/e2e/flagd_container.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,27 @@
55
from testcontainers.core.container import DockerContainer
66
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
77

8+
from openfeature.contrib.provider.flagd.config import ResolverType
9+
810
HEALTH_CHECK = 8014
911

1012

1113
class FlagdContainer(DockerContainer):
1214
def __init__(
13-
self,
14-
image: str = "ghcr.io/open-feature/flagd-testbed:v0.5.15",
15-
port: int = 8013,
16-
**kwargs,
15+
self,
16+
**kwargs,
1717
) -> None:
18+
image: str = "ghcr.io/open-feature/flagd-testbed:v0.5.15"
1819
super().__init__(image, **kwargs)
19-
self.port = port
20-
self.with_exposed_ports(self.port, HEALTH_CHECK)
20+
self.rpc = 8013
21+
self.ipr = 8015
22+
self.with_exposed_ports(self.rpc, self.ipr, HEALTH_CHECK)
23+
24+
def get_port(self, resolver_type:ResolverType):
25+
if resolver_type == ResolverType.RPC:
26+
return self.get_exposed_port(self.rpc)
27+
else:
28+
return self.get_exposed_port(self.ipr)
2129

2230
def start(self) -> "FlagdContainer":
2331
super().start()

providers/openfeature-provider-flagd/tests/e2e/in-process-file/__init__.py

Whitespace-only changes.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import json
2+
import os
3+
import tempfile
4+
5+
import pytest
6+
import yaml
7+
from pytest_bdd import given, parsers, when
8+
from tests.e2e.conftest import TEST_HARNESS_PATH
9+
from tests.e2e.step._utils import wait_for
10+
from tests.e2e.testFilter import TestFilter
11+
12+
from openfeature import api
13+
from openfeature.client import OpenFeatureClient
14+
from openfeature.contrib.provider.flagd import FlagdProvider
15+
from openfeature.contrib.provider.flagd.config import ResolverType
16+
from openfeature.provider import ProviderStatus
17+
18+
# from tests.e2e.step.config_steps import *
19+
# from tests.e2e.step.event_steps import *
20+
# from tests.e2e.step.provider_steps import *
21+
22+
resolver = ResolverType.IN_PROCESS
23+
feature_list = {
24+
"~targetURI",
25+
"~customCert",
26+
"~unixsocket",
27+
"~events",
28+
"~sync",
29+
"~caching",
30+
"~reconnect",
31+
"~grace",
32+
}
33+
34+
35+
def pytest_collection_modifyitems(config, items):
36+
test_filter = TestFilter(
37+
config, feature_list=feature_list, resolver=resolver.value, base_path=__file__
38+
)
39+
test_filter.filter_items(items)
40+
41+
42+
KEY_EVALUATORS = "$evaluators"
43+
44+
KEY_FLAGS = "flags"
45+
46+
MERGED_FILE = "merged_file"
47+
48+
49+
@pytest.fixture()
50+
def resolver_type() -> ResolverType:
51+
return resolver
52+
53+
54+
@pytest.fixture(scope="module")
55+
def all_flags(request):
56+
result = {KEY_FLAGS: {}, KEY_EVALUATORS: {}}
57+
58+
path = os.path.abspath(
59+
os.path.join(os.path.dirname(__file__), f"../{TEST_HARNESS_PATH}/flags/")
60+
)
61+
62+
for f in os.listdir(path):
63+
with open(path + "/" + f, "rb") as infile:
64+
loaded_json = json.load(infile)
65+
result[KEY_FLAGS] = {**result[KEY_FLAGS], **loaded_json[KEY_FLAGS]}
66+
if loaded_json.get(KEY_EVALUATORS):
67+
result[KEY_EVALUATORS] = {
68+
**result[KEY_EVALUATORS],
69+
**loaded_json[KEY_EVALUATORS],
70+
}
71+
72+
return result
73+
74+
75+
@pytest.fixture(params=["json", "yaml"], scope="module")
76+
def file_name(request, all_flags):
77+
extension = request.param
78+
outfile = tempfile.NamedTemporaryFile("w", delete=False, suffix="." + extension)
79+
write_test_file(outfile, all_flags)
80+
yield outfile
81+
return outfile
82+
83+
84+
def write_test_file(outfile, all_flags):
85+
with open(outfile.name, "w") as file:
86+
if file.name.endswith("json"):
87+
json.dump(all_flags, file)
88+
else:
89+
yaml.dump(all_flags, file)
90+
91+
92+
@when(
93+
parsers.cfparse('a flag with key "{flag_key}" is modified'),
94+
target_fixture="changed_flag",
95+
)
96+
def changed_flag(
97+
flag_key: str,
98+
all_flags: dict,
99+
file_name,
100+
):
101+
flag = all_flags[KEY_FLAGS][flag_key]
102+
103+
other_variant = [k for k in flag["variants"] if flag["defaultVariant"] in k].pop()
104+
105+
flag["defaultVariant"] = other_variant
106+
107+
all_flags[KEY_FLAGS][flag_key] = flag
108+
write_test_file(file_name, all_flags)
109+
return flag_key
110+
111+
112+
@pytest.fixture(autouse=True)
113+
def container(request, file_name, all_flags, option_values):
114+
api.set_provider(
115+
FlagdProvider(
116+
resolver_type=ResolverType.IN_PROCESS,
117+
deadline_ms=500,
118+
stream_deadline_ms=0,
119+
retry_backoff_ms=1000,
120+
offline_flag_source_path=file_name.name,
121+
**option_values,
122+
),
123+
)
124+
pass
125+
126+
127+
@given(parsers.cfparse("a {provider_type} flagd provider"), target_fixture="client")
128+
def setup_provider(
129+
resolver_type: ResolverType, provider_type: str, option_values: dict, file_name
130+
) -> OpenFeatureClient:
131+
client = api.get_client()
132+
133+
wait_for(lambda: client.get_provider_status() == ProviderStatus.READY)
134+
return client

0 commit comments

Comments
 (0)