mzcompose.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. # Copyright Materialize, Inc. and contributors. All rights reserved.
  2. #
  3. # Use of this software is governed by the Business Source License
  4. # included in the LICENSE file at the root of this repository.
  5. #
  6. # As of the Change Date specified in that file, in accordance with
  7. # the Business Source License, use of this software will be governed
  8. # by the Apache License, Version 2.0.
  9. """
  10. Test the LaunchDarkly integration, get configuration flags from LD.
  11. """
  12. from itertools import chain
  13. from os import environ
  14. from textwrap import dedent
  15. from time import sleep
  16. from typing import Any
  17. from uuid import uuid1
  18. import launchdarkly_api # type: ignore
  19. from launchdarkly_api.api import feature_flags_api # type: ignore
  20. from launchdarkly_api.model.client_side_availability_post import ( # type: ignore
  21. ClientSideAvailabilityPost,
  22. )
  23. from launchdarkly_api.model.defaults import Defaults # type: ignore
  24. from launchdarkly_api.model.feature_flag_body import FeatureFlagBody # type: ignore
  25. from launchdarkly_api.model.json_patch import JSONPatch # type: ignore
  26. from launchdarkly_api.model.patch_operation import PatchOperation # type: ignore
  27. from launchdarkly_api.model.patch_with_comment import PatchWithComment # type: ignore
  28. from launchdarkly_api.model.variation import Variation # type: ignore
  29. from materialize.mzcompose import DEFAULT_MZ_ENVIRONMENT_ID, DEFAULT_ORG_ID
  30. from materialize.mzcompose.composition import Composition
  31. from materialize.mzcompose.services.materialized import Materialized
  32. from materialize.mzcompose.services.postgres import CockroachOrPostgresMetadata
  33. from materialize.mzcompose.services.testdrive import Testdrive
  34. from materialize.ui import UIError
  35. # Access keys required for interacting with LaunchDarkly.
  36. LAUNCHDARKLY_API_TOKEN = environ.get("LAUNCHDARKLY_API_TOKEN")
  37. LAUNCHDARKLY_SDK_KEY = environ.get("LAUNCHDARKLY_SDK_KEY")
  38. # We need those to derive feature flag name that guarantees that we won't have
  39. # collisions between runs.
  40. BUILDKITE_JOB_ID = environ.get("BUILDKITE_JOB_ID", uuid1())
  41. BUILDKITE_PULL_REQUEST = environ.get("BUILDKITE_PULL_REQUEST")
  42. # This should always coincide with the MZ_ENVIRONMENT_ID value passed to the
  43. # Materialize service.
  44. LD_CONTEXT_KEY = DEFAULT_MZ_ENVIRONMENT_ID
  45. # A unique feature flag key to use for this test.
  46. LD_FEATURE_FLAG_KEY = f"ci-test-{BUILDKITE_JOB_ID}"
  47. SERVICES = [
  48. CockroachOrPostgresMetadata(),
  49. Materialized(
  50. environment_extra=[
  51. f"MZ_LAUNCHDARKLY_SDK_KEY={LAUNCHDARKLY_SDK_KEY}",
  52. f"MZ_LAUNCHDARKLY_KEY_MAP=max_result_size={LD_FEATURE_FLAG_KEY}",
  53. "MZ_CONFIG_SYNC_LOOP_INTERVAL=1s",
  54. ],
  55. additional_system_parameter_defaults={
  56. "log_filter": "mz_adapter::catalog=debug,mz_adapter::config=debug",
  57. },
  58. external_metadata_store=True,
  59. ),
  60. Testdrive(no_reset=True, seed=1),
  61. ]
  62. def workflow_default(c: Composition) -> None:
  63. if LAUNCHDARKLY_API_TOKEN is None:
  64. raise UIError("Missing LAUNCHDARKLY_API_TOKEN environment variable")
  65. if LAUNCHDARKLY_SDK_KEY is None:
  66. raise UIError("Missing LAUNCHDARKLY_SDK_KEY environment variable")
  67. # Create a LaunchDarkly client that simulates somebody interacting
  68. # with the LaunchDarkly frontend.
  69. ld_client = LaunchDarklyClient(
  70. configuration=launchdarkly_api.Configuration(
  71. api_key=dict(ApiKey=LAUNCHDARKLY_API_TOKEN),
  72. ),
  73. project_key="default",
  74. environment_key="ci-cd",
  75. )
  76. try:
  77. c.up({"name": "testdrive", "persistent": True})
  78. # Assert that the default max_result_size is served when sync is disabled.
  79. with c.override(Materialized(external_metadata_store=True)):
  80. c.up("materialized")
  81. c.testdrive("\n".join(["> SHOW max_result_size", "1GB"]))
  82. c.stop("materialized")
  83. # Create a test feature flag unique for this test run. Based on the
  84. # MZ_LAUNCHDARKLY_KEY_MAP value, the test feature will be mapped to the
  85. # max_result_size system parameter.
  86. ld_client.create_flag(
  87. LD_FEATURE_FLAG_KEY,
  88. tags=(
  89. ["ci-test", f"gh-{BUILDKITE_PULL_REQUEST}"]
  90. if BUILDKITE_PULL_REQUEST
  91. else ["ci-test"]
  92. ),
  93. )
  94. # Turn on targeting. The default rule will now serve 2GiB for the test
  95. # feature.
  96. ld_client.update_targeting(
  97. LD_FEATURE_FLAG_KEY,
  98. on=True,
  99. )
  100. # 3 seconds should be enough to avoid race conditions between the update
  101. # above and the query below.
  102. sleep(3)
  103. # Assert that the value is as expected after the initial parameter sync.
  104. with c.override(
  105. Materialized(
  106. environment_extra=[
  107. f"MZ_LAUNCHDARKLY_SDK_KEY={LAUNCHDARKLY_SDK_KEY}",
  108. f"MZ_LAUNCHDARKLY_KEY_MAP=max_result_size={LD_FEATURE_FLAG_KEY}",
  109. ],
  110. additional_system_parameter_defaults={
  111. "log_filter": "mz_adapter::catalog=debug,mz_adapter::config=debug",
  112. },
  113. external_metadata_store=True,
  114. )
  115. ):
  116. c.up("materialized")
  117. c.testdrive("\n".join(["> SHOW max_result_size", "2GB"]))
  118. c.stop("materialized")
  119. # Assert that the last value is persisted and available upon restart,
  120. # even if the parameter sync loop is not running.
  121. with c.override(Materialized(external_metadata_store=True)):
  122. c.up("materialized")
  123. c.testdrive("\n".join(["> SHOW max_result_size", "2GB"]))
  124. c.stop("materialized")
  125. # Restart Materialized with the parameter sync loop running.
  126. c.up("materialized")
  127. # Add a rule that targets the current environment with the 4GiB - 1 byte variant.
  128. ld_client.update_targeting(
  129. LD_FEATURE_FLAG_KEY,
  130. contextTargets=[
  131. {
  132. "contextKind": "environment",
  133. "values": [LD_CONTEXT_KEY],
  134. "variation": 3,
  135. }
  136. ],
  137. )
  138. # Assert that max_result_size is 4 GiB - 1 byte.
  139. c.testdrive("\n".join(["> SHOW max_result_size", "4294967295B"]))
  140. # Add a rule that targets the current organization with the 3GiB
  141. # variant. Even though we don't delete the above rule (replicated as
  142. # first entry in the contextTargets list below), the evaluation order is
  143. # based on the definition order of flag variants.
  144. ld_client.update_targeting(
  145. LD_FEATURE_FLAG_KEY,
  146. contextTargets=[
  147. {
  148. "contextKind": "environment",
  149. "values": [LD_CONTEXT_KEY],
  150. "variation": 3,
  151. },
  152. {
  153. "contextKind": "organization",
  154. "values": [DEFAULT_ORG_ID],
  155. "variation": 2,
  156. },
  157. ],
  158. )
  159. # Assert that max_result_size is 3 GiB.
  160. c.testdrive("\n".join(["> SHOW max_result_size", "3GB"]))
  161. # Assert that we can turn off synchronization
  162. def sys(command: str) -> None:
  163. c.testdrive(
  164. "\n".join(
  165. [
  166. "$ postgres-connect name=mz_system url=postgres://mz_system:materialize@${testdrive.materialize-internal-sql-addr}",
  167. "$ postgres-execute connection=mz_system",
  168. command,
  169. ]
  170. )
  171. )
  172. # (1) The logs should report that the frontend was not stopped until now
  173. logs = c.invoke("logs", "materialized", capture=True)
  174. assert "stopping system parameter frontend" not in logs.stdout
  175. # (2) Turn the kill switch on
  176. sys("ALTER SYSTEM SET enable_launchdarkly=off")
  177. sleep(10)
  178. # (3) The logs should report that the frontend was stopped at least once
  179. logs = c.invoke("logs", "materialized", capture=True)
  180. assert "stopping system parameter frontend" in logs.stdout
  181. # (4) After that, it should be safe to alter a value directly.
  182. # The new value should not be replaced, even after 15 seconds
  183. sys("ALTER SYSTEM SET max_result_size=1234567")
  184. sleep(15)
  185. c.testdrive("\n".join(["> SHOW max_result_size", "1234567B"]))
  186. # (5) The value should be reset after we turn the kill switch back off
  187. sys("ALTER SYSTEM SET enable_launchdarkly=on")
  188. c.testdrive("\n".join(["> SHOW max_result_size", "3GB"]))
  189. # Remove custom targeting.
  190. ld_client.update_targeting(
  191. LD_FEATURE_FLAG_KEY,
  192. contextTargets=[],
  193. )
  194. # Assert that max_result_size is 2 GiB (the default when targeting is
  195. # turned on).
  196. c.testdrive("\n".join(["> SHOW max_result_size", "2GB"]))
  197. # Disable targeting.
  198. ld_client.update_targeting(
  199. LD_FEATURE_FLAG_KEY,
  200. on=False,
  201. )
  202. # Assert that max_result_size is 1 GiB (the default when targeting is
  203. # turned off).
  204. c.testdrive("\n".join(["> SHOW max_result_size", "1GB"]))
  205. c.stop("materialized")
  206. except launchdarkly_api.ApiException as e:
  207. raise UIError(
  208. dedent(
  209. f"""
  210. Error when calling the Launch Darkly API.
  211. - Status: {e.status},
  212. - Reason: {e.reason},
  213. """
  214. )
  215. )
  216. finally:
  217. try:
  218. ld_client.delete_flag(LD_FEATURE_FLAG_KEY)
  219. except:
  220. pass # ignore exceptions on cleanup
  221. class LaunchDarklyClient:
  222. """
  223. A test-specific LaunchDarkly client that simulates a client modifying
  224. a LaunchDarkly configuration.
  225. """
  226. def __init__(
  227. self,
  228. configuration: launchdarkly_api.Configuration,
  229. project_key: str,
  230. environment_key: str,
  231. ) -> None:
  232. self.configuration = configuration
  233. self.project_key = project_key
  234. self.environment_key = environment_key
  235. def create_flag(self, feature_flag_key: str, tags: list[str] = []) -> Any:
  236. with launchdarkly_api.ApiClient(self.configuration) as api_client:
  237. api = feature_flags_api.FeatureFlagsApi(api_client)
  238. return api.post_feature_flag(
  239. project_key=self.project_key,
  240. feature_flag_body=FeatureFlagBody(
  241. name=feature_flag_key,
  242. key=feature_flag_key,
  243. client_side_availability=ClientSideAvailabilityPost(
  244. using_environment_id=True,
  245. using_mobile_key=True,
  246. ),
  247. variations=[
  248. Variation(value=1073741824, name="1 GiB"),
  249. Variation(value=2147483648, name="2 GiB"),
  250. Variation(value=3221225472, name="3 GiB"),
  251. Variation(value=4294967295, name="4 GiB - 1 (max size)"),
  252. ],
  253. temporary=False,
  254. tags=tags,
  255. defaults=Defaults(
  256. off_variation=0,
  257. on_variation=1,
  258. ),
  259. ),
  260. )
  261. def update_targeting(
  262. self,
  263. feature_flag_key: str,
  264. on: bool | None = None,
  265. contextTargets: list[Any] | None = None,
  266. ) -> Any:
  267. with launchdarkly_api.ApiClient(self.configuration) as api_client:
  268. api = feature_flags_api.FeatureFlagsApi(api_client)
  269. return api.patch_feature_flag(
  270. project_key=self.project_key,
  271. feature_flag_key=feature_flag_key,
  272. patch_with_comment=PatchWithComment(
  273. patch=JSONPatch(
  274. list(
  275. chain(
  276. (
  277. [
  278. PatchOperation(
  279. op="replace",
  280. path=f"/environments/{self.environment_key}/on",
  281. value=on,
  282. )
  283. ]
  284. if on is not None
  285. else []
  286. ),
  287. (
  288. [
  289. PatchOperation(
  290. op="replace",
  291. path=f"/environments/{self.environment_key}/contextTargets",
  292. value=contextTargets,
  293. ),
  294. ]
  295. if contextTargets is not None
  296. else []
  297. ),
  298. )
  299. )
  300. )
  301. ),
  302. )
  303. def delete_flag(self, feature_flag_key: str) -> Any:
  304. with launchdarkly_api.ApiClient(self.configuration) as api_client:
  305. api = feature_flags_api.FeatureFlagsApi(api_client)
  306. return api.delete_feature_flag(self.project_key, feature_flag_key)