mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Select one feature flag provider at compile time (#7677)
## Why Buzz server components need one typed feature-flag contract without forcing the open-source build to depend on LaunchDarkly. A staging or production relay must not be able to choose the wrong provider through runtime configuration. ## What - Adds typed boolean and signed `i64` flags with static, environment, and compile-gated LaunchDarkly evaluators. - Defines an executable relay artifact contract that compiles exactly one provider. - Rejects zero or multiple relay provider features at compile time. - Runs the LaunchDarkly feature tests and the provider compile matrix in the unit CI lane and fallback test script. ## How `buzz-feature-flags` stays provider-neutral. The future relay composition root will expose one build path per artifact feature: `static-feature-flags`, `environment-feature-flags`, or `launchdarkly-feature-flags`. Runtime configuration carries only settings for the selected provider. It never carries a provider name, fallback, or precedence rule. The crate still has no relay consumer. A locked compile fixture proves each singleton builds and every zero or multi-provider combination fails until the relay adopts this contract. This removes the proposed runtime `ProviderMode` and leaves one provider, one construction path, and one evaluator per artifact. ## Risk This PR still has no relay or app consumer, so it changes no user-visible behavior. Risk is limited to the new feature-flag crate, its compile contract, and its unit-test lane. ## Testing An external Rust consumer at `30cf968bfe85e82980fda7e63f5bebe649ca939f` exercised six environment-evaluator scenarios and 59 assertions. The compile-time correction adds no live consumer to exercise manually. ## Bigger picture When `buzz-relay` adopts this crate, its build manifests must map each distribution to one provider feature. That consumer PR must verify live Relay Proxy reachability, secret ingestion, lifecycle ownership, and user-visible behavior. Generated with Codex --------- Signed-off-by: tornquist <tornquist@squareup.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
434
Cargo.lock
generated
434
Cargo.lock
generated
@@ -138,7 +138,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -149,7 +149,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -596,7 +596,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -918,6 +918,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -1090,7 +1099,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1232,7 +1241,7 @@ dependencies = [
|
||||
"reqwest 0.13.4",
|
||||
"rmcp 1.8.0",
|
||||
"rustls",
|
||||
"schemars",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"similar",
|
||||
@@ -1244,6 +1253,19 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buzz-feature-flags"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"buzz-core",
|
||||
"http",
|
||||
"launchdarkly-server-sdk",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buzz-media"
|
||||
version = "0.1.0"
|
||||
@@ -1383,7 +1405,7 @@ dependencies = [
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -1449,7 +1471,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
@@ -1808,6 +1830,12 @@ dependencies = [
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cidr"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -1912,7 +1940,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2552,7 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2585,6 +2613,37 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deltae"
|
||||
version = "0.3.2"
|
||||
@@ -2748,7 +2807,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2969,7 +3028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3024,6 +3083,23 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eventsource-client"
|
||||
version = "0.17.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96df8cfa11d3c8e4e1a48b81c9c600ecbe8c11d1326f41e1524cc4d42f7bf6d9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures",
|
||||
"http",
|
||||
"launchdarkly-sdk-transport",
|
||||
"log",
|
||||
"pin-project",
|
||||
"rand 0.10.1",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "evmap"
|
||||
version = "11.0.0"
|
||||
@@ -3397,7 +3473,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows-link 0.1.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
]
|
||||
|
||||
@@ -3562,7 +3638,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -3595,6 +3671,12 @@ version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -3643,6 +3725,30 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"sha1 0.10.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers-core"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
|
||||
dependencies = [
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapify"
|
||||
version = "0.2.0"
|
||||
@@ -3921,6 +4027,25 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-http-proxy"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
@@ -4203,6 +4328,17 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -4441,7 +4577,7 @@ dependencies = [
|
||||
"iroh-base",
|
||||
"iroh-dns",
|
||||
"iroh-metrics",
|
||||
"lru",
|
||||
"lru 0.18.0",
|
||||
"n0-error",
|
||||
"n0-future",
|
||||
"noq",
|
||||
@@ -4487,6 +4623,60 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -4717,7 +4907,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
]
|
||||
@@ -4745,6 +4935,77 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
|
||||
|
||||
[[package]]
|
||||
name = "launchdarkly-sdk-transport"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "032d66802c461d7254ed1de887be6ca98ebbe126867d9a60c754baae81c4d10d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-http-proxy",
|
||||
"hyper-rustls",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"no-proxy",
|
||||
"tower 0.4.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "launchdarkly-server-sdk"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22259877ec581fa56a9f0b982e583e16fa01b71ba6d3f786ef5ccfc6ca6f92ff"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crossbeam-channel",
|
||||
"data-encoding",
|
||||
"eventsource-client",
|
||||
"flate2",
|
||||
"futures",
|
||||
"http",
|
||||
"launchdarkly-sdk-transport",
|
||||
"launchdarkly-server-sdk-evaluation",
|
||||
"log",
|
||||
"lru 0.16.4",
|
||||
"moka",
|
||||
"parking_lot",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "launchdarkly-server-sdk-evaluation"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76560a130b2a7e7fdb9c56043168844cde483e08e7f0e4a5e178e3888edc4336"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"chrono",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"maplit",
|
||||
"regex",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"sha1 0.10.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -4904,6 +5165,12 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.0"
|
||||
@@ -4960,6 +5227,12 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -5277,7 +5550,7 @@ dependencies = [
|
||||
"rpassword",
|
||||
"rustls",
|
||||
"same-file",
|
||||
"schemars",
|
||||
"schemars 1.2.1",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5386,7 +5659,7 @@ dependencies = [
|
||||
"prost-build 0.14.3",
|
||||
"protoc-bin-vendored",
|
||||
"rmcp 3.2.0",
|
||||
"schemars",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -5590,7 +5863,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"ipnet",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
@@ -5611,7 +5884,7 @@ dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.16.1",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"metrics",
|
||||
"ordered-float 5.3.0",
|
||||
"quanta",
|
||||
@@ -6089,6 +6362,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no-proxy"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd3f71e44f009513a82ac2612c0c2da7e2924fad776931164317baa9695e150"
|
||||
dependencies = [
|
||||
"cidr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -6241,7 +6523,7 @@ dependencies = [
|
||||
"async-wsocket",
|
||||
"faster-hex",
|
||||
"futures",
|
||||
"lru",
|
||||
"lru 0.18.0",
|
||||
"negentropy",
|
||||
"nostr 0.45.1",
|
||||
"nostr-database",
|
||||
@@ -6269,7 +6551,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6986,7 +7268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset 0.5.7",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6997,7 +7279,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset 0.5.7",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7143,7 +7425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -7359,7 +7641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"nix 0.31.3",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -7890,7 +8172,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"itertools",
|
||||
"kasuari",
|
||||
"lru",
|
||||
"lru 0.18.0",
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
@@ -8167,7 +8449,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
@@ -8214,7 +8496,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
@@ -8282,7 +8564,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"process-wrap",
|
||||
"rmcp-macros 1.8.0",
|
||||
"schemars",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
@@ -8306,14 +8588,14 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"process-wrap",
|
||||
"rand 0.10.1",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp-macros 3.2.0",
|
||||
"schemars",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sse-stream",
|
||||
@@ -8465,7 +8747,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8524,7 +8806,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8634,6 +8916,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
@@ -8807,7 +9101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8951,13 +9245,46 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"jiff",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.1",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
@@ -9365,7 +9692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9474,7 +9801,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hashbrown 0.16.1",
|
||||
"hashlink",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"percent-encoding",
|
||||
@@ -9511,7 +9838,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hashbrown 0.16.1",
|
||||
"hashlink",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"percent-encoding",
|
||||
@@ -10035,7 +10362,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10048,7 +10375,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10446,7 +10773,7 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
@@ -10470,7 +10797,7 @@ version = "0.25.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
@@ -10516,7 +10843,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -10544,6 +10871,17 @@ dependencies = [
|
||||
"tonic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -10552,7 +10890,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
@@ -10586,7 +10924,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -10790,7 +11128,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11353,7 +11691,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12277,7 +12615,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
|
||||
@@ -17,6 +17,7 @@ members = [
|
||||
"crates/buzz-ws-client",
|
||||
"crates/buzz-admin",
|
||||
"crates/buzz-deletion",
|
||||
"crates/buzz-feature-flags",
|
||||
"crates/buzz-workflow",
|
||||
"crates/buzz-media",
|
||||
"crates/buzz-cli",
|
||||
@@ -142,6 +143,7 @@ buzz-core = { path = "crates/buzz-core" }
|
||||
buzz-conformance = { path = "crates/buzz-conformance" }
|
||||
buzz-db = { path = "crates/buzz-db" }
|
||||
buzz-deletion = { path = "crates/buzz-deletion" }
|
||||
buzz-feature-flags = { path = "crates/buzz-feature-flags" }
|
||||
buzz-auth = { path = "crates/buzz-auth" }
|
||||
buzz-pubsub = { path = "crates/buzz-pubsub" }
|
||||
buzz-search = { path = "crates/buzz-search" }
|
||||
|
||||
4
Justfile
4
Justfile
@@ -414,6 +414,10 @@ test-unit:
|
||||
# because nothing in CI runs `cargo test --workspace` — workspace
|
||||
# membership alone buys clippy/check, not a single executed test.
|
||||
cargo nextest run -p buzz-backend-kubernetes
|
||||
# Feature-flag crate coverage: run once with LaunchDarkly enabled.
|
||||
# This includes all default tests plus the cfg(feature="launchdarkly")
|
||||
# tests, avoiding duplicate default-only execution in the unit lane.
|
||||
cargo nextest run -p buzz-feature-flags --features launchdarkly
|
||||
# buzz-agent: two infra-free concerns run together by executing the
|
||||
# whole crate (lib + integration tests), because nothing in CI runs
|
||||
# `cargo test --workspace`, so without this stanza neither the crate's
|
||||
|
||||
22
crates/buzz-feature-flags/Cargo.toml
Normal file
22
crates/buzz-feature-flags/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "buzz-feature-flags"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Typed feature-flag contracts and evaluators for Buzz"
|
||||
|
||||
[features]
|
||||
launchdarkly = ["dep:launchdarkly-server-sdk"]
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
http = "1"
|
||||
launchdarkly-server-sdk = { version = "=3.2.0", optional = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true }
|
||||
339
crates/buzz-feature-flags/README.md
Normal file
339
crates/buzz-feature-flags/README.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# buzz-feature-flags
|
||||
|
||||
Provider-neutral, typed feature-flag contracts for Buzz server crates.
|
||||
|
||||
This crate is infrastructure-only. It defines a small API for flag evaluation,
|
||||
plus a static evaluator and an optional LaunchDarkly adapter behind a compile
|
||||
feature.
|
||||
|
||||
## Purpose
|
||||
|
||||
`buzz-feature-flags` centralizes typed flag evaluation so server crates can use
|
||||
flags without importing vendor SDK types.
|
||||
|
||||
Provider-neutral types:
|
||||
|
||||
- `BooleanFlag`
|
||||
- `IntegerFlag` (`i64`, including negative values)
|
||||
- `EvaluationContext` (required `CommunityId`, optional actor `PublicKey`)
|
||||
- `FlagEvaluator` (typed `evaluate_bool` and `evaluate_int`)
|
||||
- `StaticEvaluator` (always returns declared defaults)
|
||||
- `EnvironmentEvaluator` (process-wide immutable environment snapshot)
|
||||
- `EnvironmentDiagnostic` (sanitized malformed-value report)
|
||||
|
||||
LaunchDarkly support is optional and compile-gated behind
|
||||
`buzz-feature-flags/launchdarkly`.
|
||||
|
||||
`EnvironmentEvaluator` is provider-neutral and always available (no Cargo
|
||||
feature and no external dependency).
|
||||
|
||||
## Environment Variable Naming
|
||||
|
||||
`EnvironmentEvaluator` reads only the fixed namespace
|
||||
`BUZZ_FEATURE_FLAG_`.
|
||||
|
||||
For each flag key:
|
||||
|
||||
- uppercase ASCII letters,
|
||||
- replace each run of non-ASCII-alphanumeric characters with `_`,
|
||||
- trim leading/trailing `_`,
|
||||
- prepend `BUZZ_FEATURE_FLAG_`.
|
||||
|
||||
Example: `relay.feature.query-v2` → `BUZZ_FEATURE_FLAG_RELAY_FEATURE_QUERY_V2`.
|
||||
|
||||
Normalized names must be unique across declared flags. Collisions (for example
|
||||
`a-b` and `a_b`) intentionally map to the same environment variable. Enforcing
|
||||
uniqueness belongs in a future flag registry; this crate does not add a
|
||||
registry yet.
|
||||
|
||||
## Relay Artifact Contract (not yet wired into `buzz-relay`)
|
||||
|
||||
The intended composition root is the outer relay artifact. It must select
|
||||
exactly one provider at compile time:
|
||||
|
||||
- `static-feature-flags`
|
||||
- `environment-feature-flags`
|
||||
- `launchdarkly-feature-flags`
|
||||
|
||||
`launchdarkly-feature-flags` is the only relay feature that forwards to
|
||||
`buzz-feature-flags/launchdarkly`. `static-feature-flags` and
|
||||
`environment-feature-flags` keep the reusable crate vendor-neutral and still
|
||||
allow adapter code to compile in tests.
|
||||
|
||||
The relay artifact boundary, not this reusable crate, owns exact-one
|
||||
enforcement. Consumer integration PRs must carry `exact-one compile_error guards`
|
||||
verbatim/equivalently. That keeps `buzz-feature-flags` free to compile multiple
|
||||
adapters for tests while ensuring a staging or production relay artifact cannot
|
||||
choose a different evaluator at runtime.
|
||||
|
||||
This repository currently adds the crate and adapter, but does **not** yet wire
|
||||
relay `AppState` or handlers to consume it. `crates/buzz-relay/Cargo.toml`
|
||||
still has no dependency on `buzz-feature-flags`, so current workspace/default
|
||||
builds remain intentionally unchanged. The executable contract for the future
|
||||
relay integration lives in
|
||||
`tests/fixtures/relay-feature-selection`.
|
||||
|
||||
## Composition Root Shape (artifact boundary)
|
||||
|
||||
Construct once at process startup, then inject shared `Arc<dyn FlagEvaluator>`
|
||||
into server components.
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator, StaticEvaluator};
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
use buzz_feature_flags::launchdarkly::{
|
||||
LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig,
|
||||
LaunchDarklyStartError,
|
||||
};
|
||||
|
||||
#[cfg(not(any(
|
||||
feature = "static-feature-flags",
|
||||
feature = "environment-feature-flags",
|
||||
feature = "launchdarkly-feature-flags",
|
||||
)))]
|
||||
compile_error!(
|
||||
"select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags"
|
||||
);
|
||||
|
||||
#[cfg(any(
|
||||
all(feature = "static-feature-flags", feature = "environment-feature-flags"),
|
||||
all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"),
|
||||
all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"),
|
||||
))]
|
||||
compile_error!(
|
||||
"select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags"
|
||||
);
|
||||
|
||||
#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))]
|
||||
#[derive(Default)]
|
||||
struct FeatureFlagRuntimeConfig;
|
||||
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
struct FeatureFlagRuntimeConfig {
|
||||
sdk_key: String,
|
||||
relay_proxy_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
struct RelayCompositionRoot {
|
||||
feature_flags: Arc<dyn FlagEvaluator>,
|
||||
environment_diagnostics: Option<Arc<EnvironmentEvaluator>>,
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
launchdarkly_lifecycle: Option<Arc<LaunchDarklyEvaluator>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
enum RelayStartupError {
|
||||
LaunchDarklyInit(LaunchDarklyInitError),
|
||||
LaunchDarklyStart(LaunchDarklyStartError),
|
||||
}
|
||||
|
||||
#[cfg(feature = "static-feature-flags")]
|
||||
fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot {
|
||||
RelayCompositionRoot {
|
||||
feature_flags: Arc::new(StaticEvaluator),
|
||||
environment_diagnostics: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "environment-feature-flags")]
|
||||
fn build_feature_flag_evaluator(config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot {
|
||||
let _ = config;
|
||||
let owner = Arc::new(EnvironmentEvaluator::from_process_environment());
|
||||
let feature_flags: Arc<dyn FlagEvaluator> = owner.clone();
|
||||
RelayCompositionRoot {
|
||||
feature_flags,
|
||||
environment_diagnostics: Some(owner),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
async fn build_feature_flag_evaluator(
|
||||
config: FeatureFlagRuntimeConfig,
|
||||
) -> Result<RelayCompositionRoot, RelayStartupError> {
|
||||
let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key);
|
||||
if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint {
|
||||
runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint);
|
||||
}
|
||||
|
||||
let owner = Arc::new(
|
||||
LaunchDarklyEvaluator::from_runtime_config(runtime_config)
|
||||
.map_err(RelayStartupError::LaunchDarklyInit)?,
|
||||
);
|
||||
owner
|
||||
.start_with_default_executor_and_wait(Duration::from_secs(5))
|
||||
.await
|
||||
.map_err(RelayStartupError::LaunchDarklyStart)?;
|
||||
|
||||
let feature_flags: Arc<dyn FlagEvaluator> = owner.clone();
|
||||
Ok(RelayCompositionRoot {
|
||||
feature_flags,
|
||||
environment_diagnostics: None,
|
||||
launchdarkly_lifecycle: Some(owner),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Each relay feature produces exactly one `build_feature_flag_evaluator` path, and
|
||||
runtime config carries only the selected provider's settings. There is no
|
||||
runtime provider enum, no provider-name environment variable, and no fallback
|
||||
selector.
|
||||
|
||||
Startup is fail-closed: if the selected provider fails initialization (including
|
||||
`start_with_default_executor_and_wait` for LaunchDarkly), relay startup must
|
||||
return an error and stop rather than falling back to another evaluator.
|
||||
|
||||
The extra concrete `Arc` is an owner/lifecycle handle to that same evaluator,
|
||||
not a layered provider. Consumers receive only the cloned `Arc<dyn
|
||||
FlagEvaluator>` view. Retain the environment owner to drain diagnostics after
|
||||
evaluations, and retain the LaunchDarkly owner so shutdown can call `close()`.
|
||||
|
||||
`EnvironmentEvaluator` snapshots environment values once at construction,
|
||||
retains only `BUZZ_FEATURE_FLAG_` entries, ignores `EvaluationContext`, and
|
||||
never rereads process environment during evaluation.
|
||||
|
||||
For deterministic tests or embedding, `EnvironmentEvaluator::from_pairs(...)`
|
||||
constructs the same immutable snapshot from explicit key/value pairs.
|
||||
|
||||
Environment entries are untyped at construction, so malformed present values
|
||||
are detected when a typed flag is first evaluated. `take_diagnostics()` drains
|
||||
each sanitized diagnostic at most once per flag key and expected type. The
|
||||
composition root should retain the concrete environment evaluator and emit each
|
||||
drained diagnostic as a warning through the application's logging stack.
|
||||
Diagnostics contain no raw value or environment-variable name; the value
|
||||
snapshot itself remains immutable.
|
||||
|
||||
## `buzz-db` Boundary (approved correction)
|
||||
|
||||
`buzz-db` may evaluate flags internally when choosing between equivalent query
|
||||
implementations. It should depend on `buzz-feature-flags` without enabling the
|
||||
LaunchDarkly feature, and it should never construct or import provider types.
|
||||
|
||||
Concise example (query path selection only):
|
||||
|
||||
```rust
|
||||
use buzz_core::CommunityId;
|
||||
use std::sync::Arc;
|
||||
|
||||
use buzz_feature_flags::{EvaluationContext, FlagEvaluator, IntegerFlag};
|
||||
|
||||
pub struct Db {
|
||||
feature_flags: Arc<dyn FlagEvaluator>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub async fn list_events(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
) -> anyhow::Result<Vec<EventRow>> {
|
||||
let context = EvaluationContext::for_community(community);
|
||||
|
||||
let query_version = self.feature_flags.evaluate_int(
|
||||
IntegerFlag::new("db.events.query-version", 1),
|
||||
&context,
|
||||
);
|
||||
|
||||
if query_version >= 2 {
|
||||
self.list_events_v2_sql(community).await
|
||||
} else {
|
||||
self.list_events_v1_sql(community).await
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
SQL details are intentionally omitted in this example; the key point is that
|
||||
public `Db` methods accept only domain inputs, while feature-flag evaluator
|
||||
wiring remains internal to `Db` construction.
|
||||
|
||||
`EvaluationContext::for_actor` is appropriate only when the existing DB
|
||||
operation already receives an authoritative authenticated actor for domain
|
||||
behavior. Feature targeting must not add actor/pubkey parameters to otherwise
|
||||
actor-free DB APIs.
|
||||
|
||||
For LaunchDarkly, the adapter context always includes kind `community` plus
|
||||
optional global kind `pubkey`. Percentage rollouts must explicitly choose a
|
||||
kind present in evaluation: rollouts must set `contextKind` to a kind present
|
||||
in evaluation (`community` or `pubkey`) when they are intended to bucket Buzz
|
||||
traffic. LaunchDarkly rollouts where configuration omits `contextKind` defaults
|
||||
to `user`; Buzz does not synthesize a `user` alias, so those rollouts bucket as
|
||||
zero and pick the first positive-weight variation.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Evaluation context inputs are authoritative server-resolved values
|
||||
(`community`, optional `actor`), not client-supplied targeting attributes.
|
||||
- Flags may choose between **equivalent** implementations only.
|
||||
- Flags must not weaken authorization, tenant isolation, ordering guarantees,
|
||||
transaction/consistency behavior, or schema invariants.
|
||||
- Declared defaults choose the established safe path.
|
||||
- Integer values must be validated at the consumer boundary before affecting
|
||||
behavior.
|
||||
|
||||
## Fallback, Startup, and Lifecycle
|
||||
|
||||
- `StaticEvaluator` always returns each flag's declared default.
|
||||
- `EnvironmentEvaluator` returns declared defaults when values are missing,
|
||||
empty, invalid, non-Unicode, or the normalized flag key is unusable. Present
|
||||
malformed values also produce a sanitized diagnostic; missing values do not.
|
||||
- LaunchDarkly adapter returns declared defaults when a flag is missing, wrong
|
||||
type, or evaluation fails.
|
||||
- LaunchDarkly numbers are SDK `f64` values. Integer evaluation accepts only
|
||||
mathematically integral values that convert to `i64` exactly from that `f64`
|
||||
representation; otherwise it returns each flag's declared default.
|
||||
- Startup chooses one evaluator at compile time (`StaticEvaluator`,
|
||||
`EnvironmentEvaluator`, or LaunchDarkly); provider precedence/stacking is out
|
||||
of scope.
|
||||
- If LaunchDarkly is used, call evaluator `close()` during process shutdown.
|
||||
`close()` blocks the calling thread while it flushes analytics, so async
|
||||
shutdown code should offload it to a blocking shutdown path.
|
||||
- Safety invariants must never rely on remote-flag availability.
|
||||
|
||||
## Build and Test Expectations
|
||||
|
||||
Build modes:
|
||||
|
||||
```bash
|
||||
# Provider-neutral (default): no LaunchDarkly dependency activated
|
||||
cargo build -p buzz-feature-flags
|
||||
|
||||
# Provider-neutral environment evaluator is included in the default build
|
||||
cargo test -p buzz-feature-flags --test environment_evaluator
|
||||
|
||||
# LaunchDarkly adapter enabled
|
||||
cargo build -p buzz-feature-flags --features launchdarkly
|
||||
```
|
||||
|
||||
Dependency-graph expectation checks:
|
||||
|
||||
```bash
|
||||
# Default graph should exclude launchdarkly-server-sdk
|
||||
cargo tree -p buzz-feature-flags
|
||||
|
||||
# Feature graph should include launchdarkly-server-sdk
|
||||
cargo tree -p buzz-feature-flags --features launchdarkly
|
||||
```
|
||||
|
||||
Testing expectations:
|
||||
|
||||
- Run provider-neutral tests and LaunchDarkly-feature tests for this crate.
|
||||
- Run the relay-artifact compile contract in
|
||||
`tests/relay_feature_selection_contract.rs`, including zero-feature and
|
||||
multiple-feature rejection.
|
||||
- Keep `tests/fixtures/relay-feature-selection/Cargo.lock` checked in for the
|
||||
nested `--locked` compile contract. Regenerate it from repo root with:
|
||||
|
||||
```bash
|
||||
cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml
|
||||
```
|
||||
|
||||
- When `buzz-db` adopts flag-gated query selection, add parity tests proving
|
||||
old/new query paths return equivalent rows, ordering, and transactional
|
||||
behavior for the same inputs.
|
||||
|
||||
## References
|
||||
|
||||
- [ARCHITECTURE.md](../../ARCHITECTURE.md)
|
||||
- [docs/multi-tenant-relay.md](../../docs/multi-tenant-relay.md)
|
||||
321
crates/buzz-feature-flags/src/environment.rs
Normal file
321
crates/buzz-feature-flags/src/environment.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! Process-wide environment-backed feature-flag evaluator.
|
||||
//!
|
||||
//! Environment variables use the fixed `BUZZ_FEATURE_FLAG_` namespace and a
|
||||
//! normalized form of each flag key.
|
||||
//!
|
||||
//! Normalization algorithm:
|
||||
//! - Uppercase ASCII letters.
|
||||
//! - Replace each run of non-ASCII-alphanumeric characters with `_`.
|
||||
//! - Trim leading and trailing `_`.
|
||||
//!
|
||||
//! If normalization yields an empty key segment, evaluation falls back to the
|
||||
//! flag-declared default.
|
||||
//!
|
||||
//! Different flag keys can normalize to the same environment variable name
|
||||
//! (for example `a-b` and `a_b`), so callers must keep normalized names unique.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
ffi::OsString,
|
||||
fmt::{Debug, Formatter},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag};
|
||||
|
||||
const ENV_NAMESPACE: &str = "BUZZ_FEATURE_FLAG_";
|
||||
|
||||
#[derive(Clone)]
|
||||
enum SnapshotValue {
|
||||
Unicode(String),
|
||||
NonUnicode,
|
||||
}
|
||||
|
||||
/// Sanitized diagnostic produced for a malformed configured environment value.
|
||||
///
|
||||
/// Diagnostics identify only the provider-neutral flag key and expected type.
|
||||
/// They never contain raw values or environment-variable names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum EnvironmentDiagnostic {
|
||||
/// A present value could not be evaluated as a boolean.
|
||||
InvalidBooleanValue {
|
||||
/// Stable provider-neutral flag key.
|
||||
flag_key: &'static str,
|
||||
},
|
||||
/// A present value could not be evaluated as a signed integer.
|
||||
InvalidIntegerValue {
|
||||
/// Stable provider-neutral flag key.
|
||||
flag_key: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DiagnosticState {
|
||||
seen: BTreeSet<EnvironmentDiagnostic>,
|
||||
pending: BTreeSet<EnvironmentDiagnostic>,
|
||||
}
|
||||
|
||||
/// Process-wide feature-flag evaluator backed by an immutable environment
|
||||
/// snapshot.
|
||||
///
|
||||
/// This evaluator captures key/value pairs once at construction and never
|
||||
/// rereads or mutates process environment state afterward. Evaluation ignores
|
||||
/// [`EvaluationContext`] because all values are process-wide.
|
||||
#[derive(Clone)]
|
||||
pub struct EnvironmentEvaluator {
|
||||
snapshot: HashMap<String, SnapshotValue>,
|
||||
diagnostics: Arc<Mutex<DiagnosticState>>,
|
||||
}
|
||||
|
||||
impl Default for EnvironmentEvaluator {
|
||||
fn default() -> Self {
|
||||
Self::from_process_environment()
|
||||
}
|
||||
}
|
||||
|
||||
impl EnvironmentEvaluator {
|
||||
/// Capture a one-time snapshot of the current process environment.
|
||||
///
|
||||
/// Only namespaced entries are retained. Entries with non-Unicode names are
|
||||
/// ignored; non-Unicode values are retained only as invalid-value markers.
|
||||
pub fn from_process_environment() -> Self {
|
||||
Self::from_pairs(std::env::vars_os())
|
||||
}
|
||||
|
||||
/// Build an evaluator from a deterministic key/value snapshot.
|
||||
///
|
||||
/// Only entries whose names start with `BUZZ_FEATURE_FLAG_` are retained.
|
||||
/// Entries with non-Unicode names are ignored; non-Unicode values are
|
||||
/// retained only as invalid-value markers.
|
||||
pub fn from_pairs<I, K, V>(pairs: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: Into<OsString>,
|
||||
V: Into<OsString>,
|
||||
{
|
||||
let mut snapshot = HashMap::new();
|
||||
for (name, value) in pairs {
|
||||
let name = name.into();
|
||||
let Some(name) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if !name.starts_with(ENV_NAMESPACE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = match value.into().into_string() {
|
||||
Ok(value) => SnapshotValue::Unicode(value),
|
||||
Err(_) => SnapshotValue::NonUnicode,
|
||||
};
|
||||
snapshot.insert(name.to_owned(), value);
|
||||
}
|
||||
|
||||
Self {
|
||||
snapshot,
|
||||
diagnostics: Arc::new(Mutex::new(DiagnosticState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain sanitized diagnostics that have not previously been reported.
|
||||
///
|
||||
/// Each malformed value is returned at most once per flag key and expected
|
||||
/// type, including across cloned evaluators. Missing values do not produce
|
||||
/// diagnostics.
|
||||
pub fn take_diagnostics(&self) -> Vec<EnvironmentDiagnostic> {
|
||||
let mut state = match self.diagnostics.lock() {
|
||||
Ok(state) => state,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
std::mem::take(&mut state.pending).into_iter().collect()
|
||||
}
|
||||
|
||||
fn lookup(&self, flag_key: &str) -> Option<&SnapshotValue> {
|
||||
let env_name = normalize_flag_env_name(flag_key)?;
|
||||
self.snapshot.get(&env_name)
|
||||
}
|
||||
|
||||
fn record_diagnostic(&self, diagnostic: EnvironmentDiagnostic) {
|
||||
let mut state = match self.diagnostics.lock() {
|
||||
Ok(state) => state,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if state.seen.insert(diagnostic) {
|
||||
state.pending.insert(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for EnvironmentEvaluator {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EnvironmentEvaluator")
|
||||
.field("namespace", &ENV_NAMESPACE)
|
||||
.field("entry_count", &self.snapshot.len())
|
||||
.field("entries", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FlagEvaluator for EnvironmentEvaluator {
|
||||
fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool {
|
||||
let Some(value) = self.lookup(flag.key()) else {
|
||||
return flag.default();
|
||||
};
|
||||
if let SnapshotValue::Unicode(value) = value {
|
||||
if let Some(value) = parse_bool(value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
self.record_diagnostic(EnvironmentDiagnostic::InvalidBooleanValue {
|
||||
flag_key: flag.key(),
|
||||
});
|
||||
flag.default()
|
||||
}
|
||||
|
||||
fn evaluate_int(&self, flag: IntegerFlag, _context: &EvaluationContext) -> i64 {
|
||||
let Some(value) = self.lookup(flag.key()) else {
|
||||
return flag.default();
|
||||
};
|
||||
if let SnapshotValue::Unicode(value) = value {
|
||||
if let Some(value) = parse_i64(value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
self.record_diagnostic(EnvironmentDiagnostic::InvalidIntegerValue {
|
||||
flag_key: flag.key(),
|
||||
});
|
||||
flag.default()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool(raw: &str) -> Option<bool> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.eq_ignore_ascii_case("true") {
|
||||
Some(true)
|
||||
} else if trimmed.eq_ignore_ascii_case("false") {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_i64(raw: &str) -> Option<i64> {
|
||||
raw.trim().parse::<i64>().ok()
|
||||
}
|
||||
|
||||
fn normalize_flag_env_name(flag_key: &str) -> Option<String> {
|
||||
let mut normalized = String::with_capacity(flag_key.len());
|
||||
let mut in_separator = false;
|
||||
|
||||
for character in flag_key.chars() {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
normalized.push(character.to_ascii_uppercase());
|
||||
in_separator = false;
|
||||
} else if !in_separator {
|
||||
normalized.push('_');
|
||||
in_separator = true;
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = normalized.trim_matches('_');
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!("{ENV_NAMESPACE}{trimmed}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::{BooleanFlag, EvaluationContext};
|
||||
use buzz_core::CommunityId;
|
||||
|
||||
fn community(id: &str) -> CommunityId {
|
||||
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_uses_fixed_namespace_and_collapses_separators() {
|
||||
assert_eq!(
|
||||
normalize_flag_env_name("relay.feature.query-v2"),
|
||||
Some("BUZZ_FEATURE_FLAG_RELAY_FEATURE_QUERY_V2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_flag_env_name("___relay..foo---bar***baz??9___"),
|
||||
Some("BUZZ_FEATURE_FLAG_RELAY_FOO_BAR_BAZ_9".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_rejects_empty_segment() {
|
||||
assert_eq!(normalize_flag_env_name(""), None);
|
||||
assert_eq!(normalize_flag_env_name("---***___"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_retains_only_namespaced_entries() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([
|
||||
("BUZZ_FEATURE_FLAG_RELAY_ENABLED", "true"),
|
||||
("DATABASE_URL", "sensitive"),
|
||||
]);
|
||||
|
||||
assert_eq!(evaluator.snapshot.len(), 1);
|
||||
assert!(evaluator
|
||||
.snapshot
|
||||
.contains_key("BUZZ_FEATURE_FLAG_RELAY_ENABLED"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_names_are_ignored_and_values_become_invalid_markers() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([
|
||||
(
|
||||
OsString::from("BUZZ_FEATURE_FLAG_RELAY_NON_UNICODE_NAME"),
|
||||
OsString::from_vec(vec![0x80]),
|
||||
),
|
||||
(OsString::from_vec(vec![0x81]), OsString::from("true")),
|
||||
]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(
|
||||
evaluator.evaluate_bool(BooleanFlag::new("relay.non-unicode-name", true), &context,)
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.take_diagnostics(),
|
||||
vec![EnvironmentDiagnostic::InvalidBooleanValue {
|
||||
flag_key: "relay.non-unicode-name",
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_diagnostic_mutex_recovers_without_panicking() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([(
|
||||
"BUZZ_FEATURE_FLAG_RELAY_POISONED",
|
||||
"not-a-boolean",
|
||||
)]);
|
||||
let diagnostics = Arc::clone(&evaluator.diagnostics);
|
||||
let poison_result = std::thread::spawn(move || {
|
||||
let _guard = diagnostics.lock().expect("diagnostic mutex lock");
|
||||
panic!("poison diagnostic mutex");
|
||||
})
|
||||
.join();
|
||||
assert!(poison_result.is_err());
|
||||
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.poisoned", true), &context));
|
||||
assert_eq!(
|
||||
evaluator.take_diagnostics(),
|
||||
vec![EnvironmentDiagnostic::InvalidBooleanValue {
|
||||
flag_key: "relay.poisoned",
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
49
crates/buzz-feature-flags/src/flags.rs
Normal file
49
crates/buzz-feature-flags/src/flags.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Typed feature-flag keys and declared defaults.
|
||||
|
||||
/// Typed boolean feature definition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct BooleanFlag {
|
||||
key: &'static str,
|
||||
default: bool,
|
||||
}
|
||||
|
||||
impl BooleanFlag {
|
||||
/// Construct a typed boolean flag from its stable key and declared default.
|
||||
pub const fn new(key: &'static str, default: bool) -> Self {
|
||||
Self { key, default }
|
||||
}
|
||||
|
||||
/// Stable key for this flag.
|
||||
pub const fn key(self) -> &'static str {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// Declared default used when an evaluator is unavailable or cannot return a value.
|
||||
pub const fn default(self) -> bool {
|
||||
self.default
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed integer feature definition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct IntegerFlag {
|
||||
key: &'static str,
|
||||
default: i64,
|
||||
}
|
||||
|
||||
impl IntegerFlag {
|
||||
/// Construct a typed integer flag from its stable key and declared default.
|
||||
pub const fn new(key: &'static str, default: i64) -> Self {
|
||||
Self { key, default }
|
||||
}
|
||||
|
||||
/// Stable key for this flag.
|
||||
pub const fn key(self) -> &'static str {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// Declared default used when an evaluator is unavailable or cannot return a value.
|
||||
pub const fn default(self) -> i64 {
|
||||
self.default
|
||||
}
|
||||
}
|
||||
953
crates/buzz-feature-flags/src/launchdarkly.rs
Normal file
953
crates/buzz-feature-flags/src/launchdarkly.rs
Normal file
@@ -0,0 +1,953 @@
|
||||
use http::{uri::Authority, Uri};
|
||||
use launchdarkly_server_sdk::{
|
||||
BuildError, Client, ConfigBuildError, ConfigBuilder, Context, ContextBuilder,
|
||||
MultiContextBuilder,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag};
|
||||
|
||||
const COMMUNITY_CONTEXT_KIND: &str = "community";
|
||||
const PUBKEY_CONTEXT_KIND: &str = "pubkey";
|
||||
|
||||
/// Runtime inputs required to initialize a LaunchDarkly-backed evaluator.
|
||||
#[derive(Clone)]
|
||||
pub struct LaunchDarklyRuntimeConfig {
|
||||
/// SDK key used to authenticate with LaunchDarkly.
|
||||
pub sdk_key: String,
|
||||
/// Optional Relay Proxy endpoint used for all LaunchDarkly service traffic.
|
||||
pub relay_proxy_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LaunchDarklyRuntimeConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("LaunchDarklyRuntimeConfig")
|
||||
.field("sdk_key", &"[REDACTED]")
|
||||
.field("relay_proxy_endpoint", &self.relay_proxy_endpoint)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LaunchDarklyRuntimeConfig {
|
||||
/// Create a runtime config from explicit caller-provided values.
|
||||
pub fn new(sdk_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
sdk_key: sdk_key.into(),
|
||||
relay_proxy_endpoint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the optional Relay Proxy endpoint.
|
||||
pub fn with_relay_proxy_endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.relay_proxy_endpoint = Some(endpoint.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors building a LaunchDarkly evaluator from runtime inputs.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LaunchDarklyInitError {
|
||||
/// Caller provided an empty SDK key.
|
||||
#[error("launchdarkly sdk key must not be empty")]
|
||||
EmptySdkKey,
|
||||
/// Caller provided an empty relay-proxy endpoint.
|
||||
#[error("launchdarkly relay proxy endpoint must not be empty")]
|
||||
EmptyRelayProxyEndpoint,
|
||||
/// Caller provided an invalid relay-proxy endpoint.
|
||||
#[error("launchdarkly relay proxy endpoint must be an absolute http(s) URI with authority")]
|
||||
InvalidRelayProxyEndpoint(InvalidRelayProxyEndpointReason),
|
||||
/// LaunchDarkly SDK config build failed.
|
||||
#[error("launchdarkly config build failed: {0}")]
|
||||
ConfigBuild(#[from] ConfigBuildError),
|
||||
/// LaunchDarkly SDK client build failed.
|
||||
#[error("launchdarkly client build failed: {0}")]
|
||||
ClientBuild(#[from] BuildError),
|
||||
}
|
||||
|
||||
/// Why a relay-proxy endpoint failed pre-validation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InvalidRelayProxyEndpointReason {
|
||||
/// Endpoint could not be parsed as a URI.
|
||||
MalformedUri,
|
||||
/// Endpoint scheme is not `http` or `https`.
|
||||
InvalidScheme,
|
||||
/// Endpoint has no authority section (`host[:port]`).
|
||||
MissingAuthority,
|
||||
}
|
||||
|
||||
/// Errors starting a LaunchDarkly evaluator and waiting for initialization.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LaunchDarklyStartError {
|
||||
/// LaunchDarkly client did not initialize before timeout.
|
||||
#[error("launchdarkly initialization timed out after {0:?}")]
|
||||
InitializationTimeout(Duration),
|
||||
/// LaunchDarkly client reported failed initialization.
|
||||
#[error("launchdarkly initialization failed")]
|
||||
InitializationFailed,
|
||||
}
|
||||
|
||||
/// Feature-flag evaluator backed by the LaunchDarkly Rust server SDK.
|
||||
pub struct LaunchDarklyEvaluator {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LaunchDarklyEvaluator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("LaunchDarklyEvaluator").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LaunchDarklyEvaluator {
|
||||
/// Wrap an already-constructed LaunchDarkly client.
|
||||
pub(crate) fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Build a LaunchDarkly client from explicit runtime configuration.
|
||||
pub fn from_runtime_config(
|
||||
config: LaunchDarklyRuntimeConfig,
|
||||
) -> Result<Self, LaunchDarklyInitError> {
|
||||
let sdk_key = config.sdk_key.trim();
|
||||
if sdk_key.is_empty() {
|
||||
return Err(LaunchDarklyInitError::EmptySdkKey);
|
||||
}
|
||||
|
||||
let mut builder = ConfigBuilder::new(sdk_key);
|
||||
if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint {
|
||||
if relay_proxy_endpoint.trim().is_empty() {
|
||||
return Err(LaunchDarklyInitError::EmptyRelayProxyEndpoint);
|
||||
}
|
||||
let relay_proxy_endpoint = validate_relay_proxy_endpoint(&relay_proxy_endpoint)?;
|
||||
let mut service_endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new();
|
||||
service_endpoints.relay_proxy(&relay_proxy_endpoint);
|
||||
builder = builder.service_endpoints(&service_endpoints);
|
||||
}
|
||||
|
||||
let config = builder.build()?;
|
||||
let client = Client::build(config)?;
|
||||
Ok(Self::new(client))
|
||||
}
|
||||
|
||||
/// Start background tasks and wait for SDK initialization.
|
||||
///
|
||||
/// This method must run inside a Tokio runtime.
|
||||
pub async fn start_with_default_executor_and_wait(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
) -> Result<(), LaunchDarklyStartError> {
|
||||
self.client.start_with_default_executor();
|
||||
|
||||
match self.client.wait_for_initialization(timeout).await {
|
||||
Some(true) => Ok(()),
|
||||
Some(false) => Err(LaunchDarklyStartError::InitializationFailed),
|
||||
None => Err(LaunchDarklyStartError::InitializationTimeout(timeout)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gracefully stop background tasks and flush pending analytics events.
|
||||
///
|
||||
/// This call blocks the current thread until pending analytics delivery
|
||||
/// completes. Async callers should run it in a blocking shutdown path.
|
||||
pub fn close(&self) {
|
||||
self.client.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl FlagEvaluator for LaunchDarklyEvaluator {
|
||||
fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool {
|
||||
let context = match launchdarkly_context(context) {
|
||||
Ok(context) => context,
|
||||
Err(_) => return flag.default(),
|
||||
};
|
||||
self.client
|
||||
.bool_variation(&context, flag.key(), flag.default())
|
||||
}
|
||||
|
||||
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 {
|
||||
let context = match launchdarkly_context(context) {
|
||||
Ok(context) => context,
|
||||
Err(_) => return flag.default(),
|
||||
};
|
||||
|
||||
let detail =
|
||||
self.client
|
||||
.float_variation_detail(&context, flag.key(), flag.default() as f64);
|
||||
|
||||
if detail.variation_index.is_none() {
|
||||
return flag.default();
|
||||
}
|
||||
|
||||
let Some(value) = detail.value else {
|
||||
return flag.default();
|
||||
};
|
||||
|
||||
strict_f64_to_i64(value).unwrap_or_else(|| flag.default())
|
||||
}
|
||||
}
|
||||
|
||||
fn strict_f64_to_i64(value: f64) -> Option<i64> {
|
||||
if !value.is_finite() || value.fract() != 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
i64::try_from(value as i128).ok()
|
||||
}
|
||||
|
||||
fn validate_relay_proxy_endpoint(endpoint: &str) -> Result<String, LaunchDarklyInitError> {
|
||||
if endpoint.trim() != endpoint {
|
||||
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::MalformedUri,
|
||||
));
|
||||
}
|
||||
|
||||
if endpoint.contains('#') {
|
||||
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::MalformedUri,
|
||||
));
|
||||
}
|
||||
|
||||
let parsed = endpoint.parse::<Uri>().map_err(|_| {
|
||||
LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::MalformedUri,
|
||||
)
|
||||
})?;
|
||||
|
||||
if !matches!(parsed.scheme_str(), Some("http" | "https")) {
|
||||
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::InvalidScheme,
|
||||
));
|
||||
}
|
||||
|
||||
if parsed.query().is_some() {
|
||||
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::MalformedUri,
|
||||
));
|
||||
}
|
||||
|
||||
let Some(authority) = parsed.authority() else {
|
||||
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::MissingAuthority,
|
||||
));
|
||||
};
|
||||
|
||||
if authority.host().is_empty() {
|
||||
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::MissingAuthority,
|
||||
));
|
||||
}
|
||||
|
||||
if authority_has_explicit_port(authority) && authority.port_u16().is_none() {
|
||||
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::MalformedUri,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(endpoint.to_owned())
|
||||
}
|
||||
|
||||
fn authority_has_explicit_port(authority: &Authority) -> bool {
|
||||
let authority = authority
|
||||
.as_str()
|
||||
.rsplit_once('@')
|
||||
.map_or_else(|| authority.as_str(), |(_, host_and_port)| host_and_port);
|
||||
|
||||
if authority.starts_with('[') {
|
||||
return authority.contains("]:");
|
||||
}
|
||||
|
||||
authority
|
||||
.rsplit_once(':')
|
||||
.map(|(_, port)| !port.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build the LaunchDarkly context used for evaluation.
|
||||
///
|
||||
/// The adapter always includes a `community` kind context and optionally adds a
|
||||
/// global `pubkey` kind context when an actor is present.
|
||||
fn launchdarkly_context(context: &EvaluationContext) -> Result<Context, String> {
|
||||
let mut community = ContextBuilder::new(context.community().to_string());
|
||||
community.kind(COMMUNITY_CONTEXT_KIND);
|
||||
let community = community.build()?;
|
||||
|
||||
let Some(actor_pubkey) = context.actor_pubkey() else {
|
||||
return Ok(community);
|
||||
};
|
||||
|
||||
let mut actor = ContextBuilder::new(actor_pubkey.to_hex());
|
||||
actor.kind(PUBKEY_CONTEXT_KIND);
|
||||
let actor = actor.build()?;
|
||||
|
||||
MultiContextBuilder::of(vec![community, actor]).build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::{CommunityId, PublicKey};
|
||||
use launchdarkly_server_sdk::{
|
||||
Config, Flag, FlagBuilder, FlagValue, Kind, NullEventProcessorBuilder, TestData,
|
||||
};
|
||||
|
||||
fn community(id: &str) -> CommunityId {
|
||||
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
|
||||
}
|
||||
|
||||
fn actor() -> PublicKey {
|
||||
PublicKey::from_hex("5581946f95a03e6afb43027ec89b21507f040d35fd6f8594f168f4298f96f9cb")
|
||||
.expect("valid pubkey")
|
||||
}
|
||||
|
||||
fn kind(name: &'static str) -> Kind {
|
||||
Kind::try_from(name).expect("valid LaunchDarkly context kind")
|
||||
}
|
||||
|
||||
fn weighted_rollout_flag(key: &str, context_kind: Option<&str>) -> Flag {
|
||||
let context_kind_json = context_kind.map_or_else(String::new, |context_kind| {
|
||||
format!(",\"contextKind\":\"{context_kind}\"")
|
||||
});
|
||||
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{
|
||||
"key": "{key}",
|
||||
"version": 1,
|
||||
"on": true,
|
||||
"targets": [],
|
||||
"rules": [],
|
||||
"prerequisites": [],
|
||||
"fallthrough": {{
|
||||
"rollout": {{
|
||||
"variations": [
|
||||
{{ "variation": 0, "weight": 1 }},
|
||||
{{ "variation": 1, "weight": 99999 }}
|
||||
]
|
||||
{context_kind_json}
|
||||
}}
|
||||
}},
|
||||
"offVariation": 0,
|
||||
"variations": [false, true],
|
||||
"clientSideAvailability": {{
|
||||
"usingMobileKey": false,
|
||||
"usingEnvironmentId": false
|
||||
}},
|
||||
"salt": "saltyA"
|
||||
}}"#
|
||||
))
|
||||
.expect("valid preconfigured weighted rollout flag")
|
||||
}
|
||||
|
||||
fn test_config(test_data: &TestData) -> Config {
|
||||
ConfigBuilder::new("sdk-key")
|
||||
.data_source(test_data)
|
||||
.event_processor(&NullEventProcessorBuilder::new())
|
||||
.build()
|
||||
.expect("launchdarkly test config")
|
||||
}
|
||||
|
||||
fn default_test_config(test_data: &TestData) -> Config {
|
||||
ConfigBuilder::new("sdk-key")
|
||||
.data_source(test_data)
|
||||
.build()
|
||||
.expect("launchdarkly default test config")
|
||||
}
|
||||
|
||||
async fn started_evaluator(test_data: &TestData) -> LaunchDarklyEvaluator {
|
||||
let config = test_config(test_data);
|
||||
let client = Client::build(config).expect("launchdarkly test client");
|
||||
let evaluator = LaunchDarklyEvaluator::new(client);
|
||||
evaluator
|
||||
.start_with_default_executor_and_wait(Duration::from_secs(1))
|
||||
.await
|
||||
.expect("launchdarkly initialized");
|
||||
evaluator
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchdarkly_test_config_uses_null_event_processor() {
|
||||
let test_data = TestData::new();
|
||||
let config = test_config(&test_data);
|
||||
let default_config = default_test_config(&test_data);
|
||||
let endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new()
|
||||
.build()
|
||||
.expect("default service endpoints");
|
||||
let event_processor = config
|
||||
.event_processor_builder()
|
||||
.build(&endpoints, config.sdk_key(), None)
|
||||
.expect("event processor build");
|
||||
let default_event_processor = default_config
|
||||
.event_processor_builder()
|
||||
.build(&endpoints, default_config.sdk_key(), None)
|
||||
.expect("default event processor build");
|
||||
|
||||
event_processor.close();
|
||||
default_event_processor.close();
|
||||
assert!(
|
||||
event_processor.flush_blocking(Duration::from_millis(10)),
|
||||
"null event processor should remain a no-op after close"
|
||||
);
|
||||
assert!(
|
||||
!default_event_processor.flush_blocking(Duration::from_millis(10)),
|
||||
"default event processor should fail flush_blocking after close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_config_debug_redacts_sdk_key() {
|
||||
let config = LaunchDarklyRuntimeConfig::new("super-secret-sdk-key")
|
||||
.with_relay_proxy_endpoint("https://relay.internal");
|
||||
let debug = format!("{config:?}");
|
||||
|
||||
assert!(!debug.contains("super-secret-sdk-key"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
assert!(debug.contains("https://relay.internal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_relay_proxy_endpoint_returns_typed_error_without_panic() {
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
LaunchDarklyEvaluator::from_runtime_config(
|
||||
LaunchDarklyRuntimeConfig::new("sdk-key")
|
||||
.with_relay_proxy_endpoint("not a valid endpoint"),
|
||||
)
|
||||
});
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"invalid endpoint should return Err, not panic"
|
||||
);
|
||||
assert!(matches!(
|
||||
result.expect("catch_unwind result"),
|
||||
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_proxy_endpoint_rejects_non_http_scheme_and_missing_authority() {
|
||||
let invalid_scheme = LaunchDarklyEvaluator::from_runtime_config(
|
||||
LaunchDarklyRuntimeConfig::new("sdk-key")
|
||||
.with_relay_proxy_endpoint("ftp://relay.internal:8030"),
|
||||
);
|
||||
assert!(matches!(
|
||||
invalid_scheme,
|
||||
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
|
||||
InvalidRelayProxyEndpointReason::InvalidScheme
|
||||
))
|
||||
));
|
||||
|
||||
for endpoint in [
|
||||
"https://",
|
||||
"http:///missing-authority",
|
||||
"https:///still-missing-authority",
|
||||
"https://:8030/path-prefix",
|
||||
] {
|
||||
let result = LaunchDarklyEvaluator::from_runtime_config(
|
||||
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_proxy_endpoint_rejects_query_fragment_and_invalid_ports() {
|
||||
let invalid_cases = [
|
||||
"https://relay.internal/path?env=staging",
|
||||
"https://relay.internal/path#fragment",
|
||||
"https://relay.internal:notaport/path-prefix",
|
||||
"https://relay.internal:99999/path-prefix",
|
||||
];
|
||||
|
||||
for endpoint in invalid_cases {
|
||||
let error = LaunchDarklyEvaluator::from_runtime_config(
|
||||
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
||||
)
|
||||
.expect_err("endpoint should be rejected");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
LaunchDarklyInitError::InvalidRelayProxyEndpoint(_)
|
||||
));
|
||||
assert!(!format!("{error}").contains(endpoint));
|
||||
assert!(!format!("{error:?}").contains(endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_proxy_endpoint_rejects_leading_or_trailing_whitespace() {
|
||||
for endpoint in [
|
||||
" https://relay.internal:8030",
|
||||
"https://relay.internal:8030 ",
|
||||
"\thttps://relay.internal:8030",
|
||||
"https://relay.internal:8030\n",
|
||||
] {
|
||||
let result = LaunchDarklyEvaluator::from_runtime_config(
|
||||
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_proxy_endpoint_accepts_absolute_http_and_https_uris() {
|
||||
let valid_cases = [
|
||||
"http://relay.internal:8030",
|
||||
"https://relay.internal",
|
||||
"https://relay.internal:8443/path-prefix",
|
||||
];
|
||||
|
||||
for endpoint in valid_cases {
|
||||
let result = LaunchDarklyEvaluator::from_runtime_config(
|
||||
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"expected valid endpoint {endpoint} to be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_proxy_endpoint_error_does_not_leak_raw_endpoint_value() {
|
||||
let endpoint = "https://bad endpoint with spaces";
|
||||
let error = LaunchDarklyEvaluator::from_runtime_config(
|
||||
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
|
||||
)
|
||||
.expect_err("invalid endpoint should fail");
|
||||
|
||||
assert!(!format!("{error}").contains(endpoint));
|
||||
assert!(!format!("{error:?}").contains(endpoint));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn community_only_context_targets_named_community_kind() {
|
||||
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("community-only")
|
||||
.fallthrough_variation(false)
|
||||
.variation_for_key(kind("community"), community.to_string(), true),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
|
||||
assert!(evaluator.evaluate_bool(
|
||||
BooleanFlag::new("community-only", false),
|
||||
&EvaluationContext::for_community(community),
|
||||
));
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actor_context_targets_community_and_pubkey_kinds() {
|
||||
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let actor = actor();
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("by-community")
|
||||
.fallthrough_variation(false)
|
||||
.variation_for_key(kind("community"), community.to_string(), true),
|
||||
);
|
||||
test_data.update(
|
||||
FlagBuilder::new("by-pubkey")
|
||||
.fallthrough_variation(false)
|
||||
.variation_for_key(kind("pubkey"), actor.to_hex(), true),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context = EvaluationContext::for_actor(community, actor);
|
||||
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("by-community", false), &context));
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("by-pubkey", false), &context));
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_actor_can_receive_different_variations_by_community() {
|
||||
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
||||
let actor = actor();
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("community-rollout")
|
||||
.fallthrough_variation(false)
|
||||
.variation_for_key(kind("community"), community_a.to_string(), true),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let flag = BooleanFlag::new("community-rollout", false);
|
||||
|
||||
assert!(evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_a, actor),));
|
||||
assert!(!evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_b, actor),));
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn community_kind_weighted_rollout_uses_community_key_bucketing() {
|
||||
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let test_data = TestData::new();
|
||||
test_data.use_preconfigured_flag(weighted_rollout_flag(
|
||||
"community-weighted-rollout",
|
||||
Some("community"),
|
||||
));
|
||||
test_data
|
||||
.use_preconfigured_flag(weighted_rollout_flag("missing-context-kind-rollout", None));
|
||||
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context = EvaluationContext::for_community(community);
|
||||
|
||||
assert!(evaluator.evaluate_bool(
|
||||
BooleanFlag::new("community-weighted-rollout", false),
|
||||
&context,
|
||||
));
|
||||
assert!(!evaluator.evaluate_bool(
|
||||
BooleanFlag::new("missing-context-kind-rollout", true),
|
||||
&context,
|
||||
));
|
||||
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_flag_falls_back_to_declared_default() {
|
||||
let test_data = TestData::new();
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.missing", true), &context,));
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_type_falls_back_to_declared_default() {
|
||||
let test_data = TestData::new();
|
||||
test_data.update(FlagBuilder::new("relay.feature.bool").variation_for_all(true));
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.string")
|
||||
.value_for_all(FlagValue::Str("red".to_owned())),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context = EvaluationContext::for_actor(
|
||||
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||
actor(),
|
||||
);
|
||||
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.bool", false), &context,));
|
||||
assert!(
|
||||
!evaluator.evaluate_bool(BooleanFlag::new("relay.feature.string", false), &context,)
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn community_only_context_targets_integer_variation() {
|
||||
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("community-int")
|
||||
.variations([FlagValue::from(0_i64), FlagValue::from(13_i64)])
|
||||
.fallthrough_variation_index(0)
|
||||
.variation_index_for_key(kind("community"), community.to_string(), 1),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("community-int", -1),
|
||||
&EvaluationContext::for_community(community),
|
||||
),
|
||||
13
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actor_context_targets_integer_variation_by_community_and_pubkey() {
|
||||
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let actor = actor();
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("by-community-int")
|
||||
.variations([FlagValue::from(0_i64), FlagValue::from(8_i64)])
|
||||
.fallthrough_variation_index(0)
|
||||
.variation_index_for_key(kind("community"), community.to_string(), 1),
|
||||
);
|
||||
test_data.update(
|
||||
FlagBuilder::new("by-pubkey-int")
|
||||
.variations([FlagValue::from(0_i64), FlagValue::from(21_i64)])
|
||||
.fallthrough_variation_index(0)
|
||||
.variation_index_for_key(kind("pubkey"), actor.to_hex(), 1),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context = EvaluationContext::for_actor(community, actor);
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("by-community-int", -1), &context),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("by-pubkey-int", -1), &context),
|
||||
21
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_actor_can_receive_different_integer_variations_by_community() {
|
||||
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
||||
let actor = actor();
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("community-int-rollout")
|
||||
.variations([FlagValue::from(-11_i64), FlagValue::from(99_i64)])
|
||||
.fallthrough_variation_index(0)
|
||||
.variation_index_for_key(kind("community"), community_a.to_string(), 1),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("community-int-rollout", -1),
|
||||
&EvaluationContext::for_actor(community_a, actor),
|
||||
),
|
||||
99
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("community-int-rollout", -1),
|
||||
&EvaluationContext::for_actor(community_b, actor),
|
||||
),
|
||||
-11
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_integer_flag_falls_back_to_declared_default() {
|
||||
let test_data = TestData::new();
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("relay.feature.missing-int", -7), &context),
|
||||
-7
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_integer_flag_preserves_exact_declared_default_bytes() {
|
||||
let test_data = TestData::new();
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.missing-int-precise-default", default),
|
||||
&context,
|
||||
),
|
||||
default,
|
||||
"missing-flag fallback must preserve exact declared default"
|
||||
);
|
||||
}
|
||||
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_type_integer_falls_back_to_declared_default() {
|
||||
let test_data = TestData::new();
|
||||
test_data
|
||||
.update(FlagBuilder::new("relay.feature.int").value_for_all(FlagValue::from(17_i64)));
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-bool").value_for_all(FlagValue::from(true)),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context = EvaluationContext::for_actor(
|
||||
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||
actor(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("relay.feature.int", 0), &context),
|
||||
17
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("relay.feature.int-bool", -5), &context),
|
||||
-5
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_type_integer_flag_preserves_exact_declared_default_bytes() {
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-wrong-type-for-precise-default")
|
||||
.value_for_all(FlagValue::from(true)),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context = EvaluationContext::for_actor(
|
||||
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||
actor(),
|
||||
);
|
||||
|
||||
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.int-wrong-type-for-precise-default", default),
|
||||
&context,
|
||||
),
|
||||
default,
|
||||
"wrong-type fallback must preserve exact declared default"
|
||||
);
|
||||
}
|
||||
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_not_ready_preserves_exact_declared_default_bytes() {
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-provider-not-ready")
|
||||
.value_for_all(FlagValue::from(123_i64)),
|
||||
);
|
||||
|
||||
let config = test_config(&test_data);
|
||||
let client = Client::build(config).expect("launchdarkly test client");
|
||||
let evaluator = LaunchDarklyEvaluator::new(client);
|
||||
let context = EvaluationContext::for_actor(
|
||||
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||
actor(),
|
||||
);
|
||||
|
||||
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.int-provider-not-ready", default),
|
||||
&context,
|
||||
),
|
||||
default,
|
||||
"provider-not-ready fallback must preserve exact declared default"
|
||||
);
|
||||
}
|
||||
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fractional_integer_variation_falls_back_to_declared_default() {
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-fractional-positive")
|
||||
.value_for_all(FlagValue::from(17.75_f64)),
|
||||
);
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-fractional-negative")
|
||||
.value_for_all(FlagValue::from(-17.75_f64)),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context = EvaluationContext::for_actor(
|
||||
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||
actor(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.int-fractional-positive", 123),
|
||||
&context,
|
||||
),
|
||||
123
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.int-fractional-negative", -456),
|
||||
&context,
|
||||
),
|
||||
-456
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn integer_variation_accepts_exact_large_f64_backed_i64_values() {
|
||||
let test_data = TestData::new();
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-large-positive")
|
||||
.value_for_all(FlagValue::from(1_152_921_504_606_846_976_i64)),
|
||||
);
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-large-negative")
|
||||
.value_for_all(FlagValue::from(-1_152_921_504_606_846_976_i64)),
|
||||
);
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-min-boundary")
|
||||
.value_for_all(FlagValue::from(i64::MIN)),
|
||||
);
|
||||
test_data.update(
|
||||
FlagBuilder::new("relay.feature.int-max-rounds-out-of-range")
|
||||
.value_for_all(FlagValue::from(i64::MAX)),
|
||||
);
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
let context = EvaluationContext::for_actor(
|
||||
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||
actor(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.int-large-positive", 0),
|
||||
&context,
|
||||
),
|
||||
1_152_921_504_606_846_976_i64
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.int-large-negative", 0),
|
||||
&context,
|
||||
),
|
||||
-1_152_921_504_606_846_976_i64
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.int-min-boundary", 11),
|
||||
&context,
|
||||
),
|
||||
i64::MIN
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("relay.feature.int-max-rounds-out-of-range", -12),
|
||||
&context,
|
||||
),
|
||||
-12
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn negative_integer_variation_is_supported() {
|
||||
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let test_data = TestData::new();
|
||||
test_data.update(FlagBuilder::new("negative-int").value_for_all(FlagValue::from(-42_i64)));
|
||||
let evaluator = started_evaluator(&test_data).await;
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("negative-int", 3),
|
||||
&EvaluationContext::for_community(community),
|
||||
),
|
||||
-42
|
||||
);
|
||||
evaluator.close();
|
||||
}
|
||||
}
|
||||
217
crates/buzz-feature-flags/src/lib.rs
Normal file
217
crates/buzz-feature-flags/src/lib.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! Typed boolean and integer feature flags for Buzz server components.
|
||||
|
||||
use buzz_core::{CommunityId, PublicKey};
|
||||
|
||||
pub mod environment;
|
||||
pub use environment::{EnvironmentDiagnostic, EnvironmentEvaluator};
|
||||
|
||||
pub mod flags;
|
||||
pub use flags::{BooleanFlag, IntegerFlag};
|
||||
|
||||
#[cfg(feature = "launchdarkly")]
|
||||
/// LaunchDarkly-backed evaluator adapter.
|
||||
pub mod launchdarkly;
|
||||
|
||||
/// Stable targeting context for one Buzz community and an optional actor.
|
||||
///
|
||||
/// Community is always required because the same Nostr identity can participate
|
||||
/// in multiple isolated Buzz communities. Community-only contexts support
|
||||
/// background and system evaluations that have no actor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct EvaluationContext {
|
||||
community: CommunityId,
|
||||
actor_pubkey: Option<PublicKey>,
|
||||
}
|
||||
|
||||
impl EvaluationContext {
|
||||
/// Construct a community-only evaluation context.
|
||||
pub const fn for_community(community: CommunityId) -> Self {
|
||||
Self {
|
||||
community,
|
||||
actor_pubkey: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct an evaluation context for an actor within one community.
|
||||
pub const fn for_actor(community: CommunityId, actor_pubkey: PublicKey) -> Self {
|
||||
Self {
|
||||
community,
|
||||
actor_pubkey: Some(actor_pubkey),
|
||||
}
|
||||
}
|
||||
|
||||
/// Community that owns this evaluation.
|
||||
pub const fn community(&self) -> CommunityId {
|
||||
self.community
|
||||
}
|
||||
|
||||
/// Optional stable actor key for actor-targeted evaluation.
|
||||
pub const fn actor_pubkey(&self) -> Option<&PublicKey> {
|
||||
self.actor_pubkey.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates typed feature flags.
|
||||
pub trait FlagEvaluator: Send + Sync {
|
||||
/// Resolve a boolean flag for one Buzz evaluation context.
|
||||
///
|
||||
/// Implementations must return [`BooleanFlag::default`] whenever no valid
|
||||
/// boolean value is available, including provider absence or failure.
|
||||
fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool;
|
||||
|
||||
/// Resolve an integer flag for one Buzz evaluation context.
|
||||
///
|
||||
/// Implementations must return [`IntegerFlag::default`] whenever no valid
|
||||
/// integer value is available, including provider absence or failure.
|
||||
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64;
|
||||
}
|
||||
|
||||
/// Static evaluator that should return each flag's declared default.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct StaticEvaluator;
|
||||
|
||||
impl FlagEvaluator for StaticEvaluator {
|
||||
fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool {
|
||||
flag.default()
|
||||
}
|
||||
|
||||
fn evaluate_int(&self, flag: IntegerFlag, _context: &EvaluationContext) -> i64 {
|
||||
flag.default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::CommunityId;
|
||||
|
||||
fn community(id: &str) -> CommunityId {
|
||||
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
|
||||
}
|
||||
|
||||
fn actor() -> PublicKey {
|
||||
PublicKey::from_hex("c4f0623bdc8c4f7ecab9f7457f501f3e8f4efcf8f8f6ef6f4d76f42f5bb6f2cb")
|
||||
.expect("valid pubkey")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_evaluator_returns_declared_defaults_for_community() {
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(StaticEvaluator.evaluate_bool(BooleanFlag::new("enabled", true), &context));
|
||||
assert!(!StaticEvaluator.evaluate_bool(BooleanFlag::new("disabled", false), &context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_evaluator_returns_declared_integer_defaults_for_community() {
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert_eq!(
|
||||
StaticEvaluator.evaluate_int(IntegerFlag::new("positive", 7), &context),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
StaticEvaluator.evaluate_int(IntegerFlag::new("negative", -9), &context),
|
||||
-9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_launchdarkly_evaluator_can_target_community_through_public_trait() {
|
||||
struct CommunityEvaluator {
|
||||
enabled_community: CommunityId,
|
||||
}
|
||||
|
||||
impl FlagEvaluator for CommunityEvaluator {
|
||||
fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool {
|
||||
if context.community() == self.enabled_community {
|
||||
true
|
||||
} else {
|
||||
flag.default()
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 {
|
||||
if context.community() == self.enabled_community {
|
||||
41
|
||||
} else {
|
||||
flag.default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let enabled_community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let other_community = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
||||
let evaluator: &dyn FlagEvaluator = &CommunityEvaluator { enabled_community };
|
||||
|
||||
assert!(evaluator.evaluate_bool(
|
||||
BooleanFlag::new("community-targeted-bool", false),
|
||||
&EvaluationContext::for_community(enabled_community),
|
||||
));
|
||||
assert!(!evaluator.evaluate_bool(
|
||||
BooleanFlag::new("community-targeted-bool", false),
|
||||
&EvaluationContext::for_community(other_community),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("community-targeted-int", -1),
|
||||
&EvaluationContext::for_community(enabled_community),
|
||||
),
|
||||
41
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(
|
||||
IntegerFlag::new("community-targeted-int", -1),
|
||||
&EvaluationContext::for_community(other_community),
|
||||
),
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_actor_in_two_communities_is_distinguishable_for_integer_evaluation() {
|
||||
struct CommunityRolloutEvaluator {
|
||||
enabled_community: CommunityId,
|
||||
expected_actor: PublicKey,
|
||||
}
|
||||
|
||||
impl FlagEvaluator for CommunityRolloutEvaluator {
|
||||
fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool {
|
||||
flag.default()
|
||||
}
|
||||
|
||||
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 {
|
||||
if context.community() == self.enabled_community
|
||||
&& context.actor_pubkey() == Some(&self.expected_actor)
|
||||
{
|
||||
99
|
||||
} else {
|
||||
flag.default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let actor = actor();
|
||||
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
||||
let evaluator: &dyn FlagEvaluator = &CommunityRolloutEvaluator {
|
||||
enabled_community: community_a,
|
||||
expected_actor: actor,
|
||||
};
|
||||
let flag = IntegerFlag::new("community-rollout", -11);
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(flag, &EvaluationContext::for_actor(community_a, actor)),
|
||||
99
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(flag, &EvaluationContext::for_actor(community_b, actor)),
|
||||
-11
|
||||
);
|
||||
}
|
||||
}
|
||||
336
crates/buzz-feature-flags/tests/environment_evaluator.rs
Normal file
336
crates/buzz-feature-flags/tests/environment_evaluator.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use buzz_core::{CommunityId, PublicKey};
|
||||
use buzz_feature_flags::{
|
||||
BooleanFlag, EnvironmentDiagnostic, EnvironmentEvaluator, EvaluationContext, FlagEvaluator,
|
||||
IntegerFlag,
|
||||
};
|
||||
|
||||
const ENV_NAMESPACE: &str = "BUZZ_FEATURE_FLAG_";
|
||||
|
||||
fn community(id: &str) -> CommunityId {
|
||||
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
|
||||
}
|
||||
|
||||
fn actor(hex: &str) -> PublicKey {
|
||||
PublicKey::from_hex(hex).expect("valid pubkey")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_booleans_case_insensitive_with_trim() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([
|
||||
(
|
||||
format!("{ENV_NAMESPACE}RELAY_BOOL_TRUE"),
|
||||
" TrUe \t".to_owned(),
|
||||
),
|
||||
(
|
||||
format!("{ENV_NAMESPACE}RELAY_BOOL_FALSE"),
|
||||
"\n false ".to_owned(),
|
||||
),
|
||||
]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.true", false), &context));
|
||||
assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.bool.false", true), &context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_signed_negative_integers_with_trim() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([(
|
||||
format!("{ENV_NAMESPACE}DB_EVENTS_QUERY_VERSION"),
|
||||
" -17 ".to_owned(),
|
||||
)]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("db.events.query-version", 1), &context),
|
||||
-17
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_flag_values_fall_back_to_declared_defaults() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([] as [(String, String); 0]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.missing", true), &context));
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("relay.int.missing", -9), &context),
|
||||
-9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_empty_values_fall_back_to_declared_defaults() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([
|
||||
(
|
||||
format!("{ENV_NAMESPACE}RELAY_BOOL_INVALID"),
|
||||
"maybe".to_owned(),
|
||||
),
|
||||
(format!("{ENV_NAMESPACE}RELAY_BOOL_EMPTY"), " ".to_owned()),
|
||||
(
|
||||
format!("{ENV_NAMESPACE}RELAY_INT_INVALID"),
|
||||
"12.5".to_owned(),
|
||||
),
|
||||
]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.bool.invalid", false), &context));
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.empty", true), &context));
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("relay.int.invalid", 88), &context),
|
||||
88
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("!!!", -4), &context),
|
||||
-4,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluation_context_is_ignored_for_process_wide_values() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([(
|
||||
format!("{ENV_NAMESPACE}RELAY_PROCESS_WIDE"),
|
||||
"true".to_owned(),
|
||||
)]);
|
||||
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
||||
let actor_a = actor("c4f0623bdc8c4f7ecab9f7457f501f3e8f4efcf8f8f6ef6f4d76f42f5bb6f2cb");
|
||||
let actor_b = actor("f1e2d3c4b5a697887766554433221100ffeeddccbbaa99887766554433221100");
|
||||
|
||||
assert!(evaluator.evaluate_bool(
|
||||
BooleanFlag::new("relay.process-wide", false),
|
||||
&EvaluationContext::for_actor(community_a, actor_a),
|
||||
));
|
||||
assert!(evaluator.evaluate_bool(
|
||||
BooleanFlag::new("relay.process-wide", false),
|
||||
&EvaluationContext::for_actor(community_b, actor_b),
|
||||
));
|
||||
assert!(evaluator.evaluate_bool(
|
||||
BooleanFlag::new("relay.process-wide", false),
|
||||
&EvaluationContext::for_community(community_a),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_replaces_non_alnum_runs_and_trims_separators() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([(
|
||||
format!("{ENV_NAMESPACE}RELAY_FOO_BAR_BAZ_9"),
|
||||
"true".to_owned(),
|
||||
)]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(evaluator.evaluate_bool(
|
||||
BooleanFlag::new("___relay..foo---bar***baz??9___", false),
|
||||
&context,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colliding_flag_keys_share_normalized_environment_name() {
|
||||
let evaluator =
|
||||
EnvironmentEvaluator::from_pairs([(format!("{ENV_NAMESPACE}A_B"), "true".to_owned())]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("a-b", false), &context));
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("a_b", false), &context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_is_immutable_after_construction() {
|
||||
let mut source = vec![(format!("{ENV_NAMESPACE}SNAPSHOT_BOOL"), "true".to_owned())];
|
||||
|
||||
let evaluator = EnvironmentEvaluator::from_pairs(
|
||||
source
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str())),
|
||||
);
|
||||
|
||||
source[0].1 = "false".to_owned();
|
||||
source.push((format!("{ENV_NAMESPACE}SNAPSHOT_INT"), "99".to_owned()));
|
||||
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("snapshot.bool", false), &context));
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("snapshot.int", -1), &context),
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_does_not_expose_environment_contents() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([(
|
||||
format!("{ENV_NAMESPACE}SECRET_FLAG"),
|
||||
"super-secret-value".to_owned(),
|
||||
)]);
|
||||
|
||||
let debug = format!("{evaluator:?}");
|
||||
assert!(!debug.contains("super-secret-value"));
|
||||
assert!(!debug.contains("SECRET_FLAG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_present_values_default_and_diagnose_once_per_key_and_type() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([
|
||||
(
|
||||
format!("{ENV_NAMESPACE}RELAY_INVALID_BOOL"),
|
||||
"raw-secret-bool".to_owned(),
|
||||
),
|
||||
(
|
||||
format!("{ENV_NAMESPACE}RELAY_INVALID_INT"),
|
||||
"raw-secret-int".to_owned(),
|
||||
),
|
||||
("DATABASE_URL".to_owned(), "unrelated-secret".to_owned()),
|
||||
]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
let bool_flag = BooleanFlag::new("relay.invalid-bool", true);
|
||||
let int_flag = IntegerFlag::new("relay.invalid-int", -12);
|
||||
|
||||
assert!(evaluator.evaluate_bool(bool_flag, &context));
|
||||
assert!(evaluator.evaluate_bool(bool_flag, &context));
|
||||
assert_eq!(evaluator.evaluate_int(int_flag, &context), -12);
|
||||
assert_eq!(evaluator.evaluate_int(int_flag, &context), -12);
|
||||
|
||||
let diagnostics = evaluator.take_diagnostics();
|
||||
assert_eq!(
|
||||
diagnostics,
|
||||
vec![
|
||||
EnvironmentDiagnostic::InvalidBooleanValue {
|
||||
flag_key: "relay.invalid-bool",
|
||||
},
|
||||
EnvironmentDiagnostic::InvalidIntegerValue {
|
||||
flag_key: "relay.invalid-int",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
let rendered = format!("{diagnostics:?}");
|
||||
assert!(!rendered.contains("raw-secret-bool"));
|
||||
assert!(!rendered.contains("raw-secret-int"));
|
||||
assert!(!rendered.contains("unrelated-secret"));
|
||||
assert!(!rendered.contains("DATABASE_URL"));
|
||||
|
||||
assert!(evaluator.evaluate_bool(bool_flag, &context));
|
||||
assert_eq!(evaluator.evaluate_int(int_flag, &context), -12);
|
||||
assert!(evaluator.take_diagnostics().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trait_object_evaluation_reports_through_retained_concrete_owner() {
|
||||
let owner = Arc::new(EnvironmentEvaluator::from_pairs([(
|
||||
format!("{ENV_NAMESPACE}RELAY_INVALID"),
|
||||
"not-a-boolean".to_owned(),
|
||||
)]));
|
||||
let evaluator: Arc<dyn FlagEvaluator> = owner.clone();
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.invalid", true), &context));
|
||||
assert_eq!(
|
||||
owner.take_diagnostics(),
|
||||
vec![EnvironmentDiagnostic::InvalidBooleanValue {
|
||||
flag_key: "relay.invalid",
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_flag_key_reports_distinct_boolean_and_integer_diagnostics() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([(
|
||||
format!("{ENV_NAMESPACE}RELAY_SHARED_KEY"),
|
||||
"invalid-for-both-types".to_owned(),
|
||||
)]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.shared-key", false), &context));
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("relay.shared-key", -4), &context),
|
||||
-4
|
||||
);
|
||||
assert_eq!(
|
||||
evaluator.take_diagnostics(),
|
||||
vec![
|
||||
EnvironmentDiagnostic::InvalidBooleanValue {
|
||||
flag_key: "relay.shared-key",
|
||||
},
|
||||
EnvironmentDiagnostic::InvalidIntegerValue {
|
||||
flag_key: "relay.shared-key",
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_evaluators_share_dedup_before_and_after_drain() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([(
|
||||
format!("{ENV_NAMESPACE}RELAY_CLONED"),
|
||||
"not-a-boolean".to_owned(),
|
||||
)]);
|
||||
let clone = evaluator.clone();
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
let flag = BooleanFlag::new("relay.cloned", false);
|
||||
|
||||
assert!(!evaluator.evaluate_bool(flag, &context));
|
||||
assert!(!clone.evaluate_bool(flag, &context));
|
||||
assert_eq!(
|
||||
clone.take_diagnostics(),
|
||||
vec![EnvironmentDiagnostic::InvalidBooleanValue {
|
||||
flag_key: "relay.cloned",
|
||||
}]
|
||||
);
|
||||
assert!(evaluator.take_diagnostics().is_empty());
|
||||
|
||||
assert!(!evaluator.evaluate_bool(flag, &context));
|
||||
assert!(!clone.evaluate_bool(flag, &context));
|
||||
assert!(evaluator.take_diagnostics().is_empty());
|
||||
assert!(clone.take_diagnostics().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_values_default_without_diagnostics() {
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([] as [(String, String); 0]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
|
||||
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.missing-bool", true), &context));
|
||||
assert_eq!(
|
||||
evaluator.evaluate_int(IntegerFlag::new("relay.missing-int", -4), &context),
|
||||
-4
|
||||
);
|
||||
assert!(evaluator.take_diagnostics().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn namespaced_non_unicode_value_defaults_and_diagnoses_once() {
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
||||
|
||||
let evaluator = EnvironmentEvaluator::from_pairs([(
|
||||
OsString::from(format!("{ENV_NAMESPACE}RELAY_NON_UNICODE")),
|
||||
OsString::from_vec(vec![0x80]),
|
||||
)]);
|
||||
let context =
|
||||
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||
let flag = BooleanFlag::new("relay.non-unicode", false);
|
||||
|
||||
assert!(!evaluator.evaluate_bool(flag, &context));
|
||||
assert!(!evaluator.evaluate_bool(flag, &context));
|
||||
assert_eq!(
|
||||
evaluator.take_diagnostics(),
|
||||
vec![EnvironmentDiagnostic::InvalidBooleanValue {
|
||||
flag_key: "relay.non-unicode",
|
||||
}]
|
||||
);
|
||||
assert!(!evaluator.evaluate_bool(flag, &context));
|
||||
assert!(evaluator.take_diagnostics().is_empty());
|
||||
}
|
||||
2797
crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock
generated
vendored
Normal file
2797
crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
15
crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml
vendored
Normal file
15
crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "relay-feature-selection-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[features]
|
||||
static-feature-flags = []
|
||||
environment-feature-flags = []
|
||||
launchdarkly-feature-flags = ["buzz-feature-flags/launchdarkly"]
|
||||
|
||||
[dependencies]
|
||||
buzz-feature-flags = { path = "../../.." }
|
||||
103
crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs
vendored
Normal file
103
crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator};
|
||||
#[cfg(feature = "static-feature-flags")]
|
||||
use buzz_feature_flags::StaticEvaluator;
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
use buzz_feature_flags::launchdarkly::{
|
||||
LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig,
|
||||
};
|
||||
|
||||
#[cfg(not(any(
|
||||
feature = "static-feature-flags",
|
||||
feature = "environment-feature-flags",
|
||||
feature = "launchdarkly-feature-flags",
|
||||
)))]
|
||||
compile_error!(
|
||||
"select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags"
|
||||
);
|
||||
|
||||
#[cfg(any(
|
||||
all(feature = "static-feature-flags", feature = "environment-feature-flags"),
|
||||
all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"),
|
||||
all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"),
|
||||
))]
|
||||
compile_error!(
|
||||
"select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags"
|
||||
);
|
||||
|
||||
#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FeatureFlagRuntimeConfig;
|
||||
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FeatureFlagRuntimeConfig {
|
||||
pub sdk_key: String,
|
||||
pub relay_proxy_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
pub struct FeatureFlagCompositionRoot {
|
||||
pub feature_flags: Arc<dyn FlagEvaluator>,
|
||||
pub environment_diagnostics: Option<Arc<EnvironmentEvaluator>>,
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
pub launchdarkly_lifecycle: Option<Arc<LaunchDarklyEvaluator>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "static-feature-flags")]
|
||||
pub fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> FeatureFlagCompositionRoot {
|
||||
FeatureFlagCompositionRoot {
|
||||
feature_flags: Arc::new(StaticEvaluator),
|
||||
environment_diagnostics: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "environment-feature-flags")]
|
||||
pub fn build_feature_flag_evaluator(
|
||||
_config: FeatureFlagRuntimeConfig,
|
||||
) -> FeatureFlagCompositionRoot {
|
||||
let owner = Arc::new(EnvironmentEvaluator::from_process_environment());
|
||||
let feature_flags: Arc<dyn FlagEvaluator> = owner.clone();
|
||||
FeatureFlagCompositionRoot {
|
||||
feature_flags,
|
||||
environment_diagnostics: Some(owner),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
pub fn build_feature_flag_evaluator(
|
||||
config: FeatureFlagRuntimeConfig,
|
||||
) -> Result<FeatureFlagCompositionRoot, LaunchDarklyInitError> {
|
||||
let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key);
|
||||
if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint {
|
||||
runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint);
|
||||
}
|
||||
|
||||
let owner = Arc::new(LaunchDarklyEvaluator::from_runtime_config(runtime_config)?);
|
||||
let feature_flags: Arc<dyn FlagEvaluator> = owner.clone();
|
||||
Ok(FeatureFlagCompositionRoot {
|
||||
feature_flags,
|
||||
environment_diagnostics: None,
|
||||
launchdarkly_lifecycle: Some(owner),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "static-feature-flags")]
|
||||
pub fn compile_smoke() {
|
||||
let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig);
|
||||
}
|
||||
|
||||
#[cfg(feature = "environment-feature-flags")]
|
||||
pub fn compile_smoke() {
|
||||
let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig);
|
||||
}
|
||||
|
||||
#[cfg(feature = "launchdarkly-feature-flags")]
|
||||
pub fn compile_smoke() {
|
||||
let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig {
|
||||
sdk_key: "fixture-sdk-key".to_owned(),
|
||||
relay_proxy_endpoint: None,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use std::{
|
||||
env,
|
||||
ffi::OsString,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
const FIXTURE_LOCK_REGEN_RECIPE: &str =
|
||||
"cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml";
|
||||
|
||||
fn fixture_manifest() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.toml")
|
||||
}
|
||||
|
||||
fn fixture_lockfile() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.lock")
|
||||
}
|
||||
|
||||
fn crate_readme() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md")
|
||||
}
|
||||
|
||||
fn cargo_bin() -> OsString {
|
||||
env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
|
||||
}
|
||||
|
||||
fn cargo_check(target_dir: &Path, case_name: &str, features: &[&str]) -> std::process::Output {
|
||||
let mut command = Command::new(cargo_bin());
|
||||
command
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg("check")
|
||||
.arg("--quiet")
|
||||
.arg("--locked")
|
||||
.arg("--manifest-path")
|
||||
.arg(fixture_manifest())
|
||||
.arg("--no-default-features")
|
||||
.env("CARGO_TARGET_DIR", target_dir);
|
||||
|
||||
if !features.is_empty() {
|
||||
command.arg("--features").arg(features.join(","));
|
||||
}
|
||||
|
||||
command
|
||||
.output()
|
||||
.unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"failed to run cargo check for {case_name}: {error}\nfixture lock regeneration recipe: `{FIXTURE_LOCK_REGEN_RECIPE}`"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn format_valid_case_failed_status(case_name: &str, stdout: &str, stderr: &str) -> String {
|
||||
format!(
|
||||
"expected {case_name} to compile successfully\nstdout:\n{stdout}\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`"
|
||||
)
|
||||
}
|
||||
|
||||
fn format_invalid_case_missing_exact_one(case_name: &str, stderr: &str) -> String {
|
||||
format!(
|
||||
"expected {case_name} failure to explain the exact-one contract\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`"
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_valid_case_success(case_name: &str, output: &std::process::Output) {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
format_valid_case_failed_status(case_name, &stdout, &stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_invalid_case_has_exact_one_diagnostic(case_name: &str, output: &std::process::Output) {
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"expected {case_name} to fail compilation"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("select exactly one relay feature flag provider feature"),
|
||||
"{}",
|
||||
format_invalid_case_missing_exact_one(case_name, &stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_fixture_lock_regeneration_recipe_is_documented() {
|
||||
let readme = std::fs::read_to_string(crate_readme()).expect("read crate README");
|
||||
assert!(
|
||||
readme.contains(FIXTURE_LOCK_REGEN_RECIPE),
|
||||
"README.md must document fixture lock regeneration with `{FIXTURE_LOCK_REGEN_RECIPE}`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_readme_documents_fail_closed_launchdarkly_startup_contract() {
|
||||
let readme = std::fs::read_to_string(crate_readme()).expect("read crate README");
|
||||
|
||||
assert!(
|
||||
readme.contains("start_with_default_executor_and_wait"),
|
||||
"README.md must show LaunchDarkly startup via start_with_default_executor_and_wait"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("async fn build_feature_flag_evaluator"),
|
||||
"README.md must declare LaunchDarkly build_feature_flag_evaluator as async"
|
||||
);
|
||||
assert!(
|
||||
readme.contains(
|
||||
"start_with_default_executor_and_wait(Duration::from_secs(5))\n .await"
|
||||
),
|
||||
"README.md must await start_with_default_executor_and_wait"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("fail-closed"),
|
||||
"README.md must state fail-closed startup policy"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("exact-one compile_error guards"),
|
||||
"README.md must require exact-one compile_error guards in the consumer"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("future flag registry"),
|
||||
"README.md must defer environment-name uniqueness enforcement to a future registry"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("kind `community` plus")
|
||||
&& readme.contains("optional global kind `pubkey`"),
|
||||
"README.md must document LaunchDarkly adapter context kinds"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("rollouts must set `contextKind` to a kind present")
|
||||
&& readme.contains("in evaluation"),
|
||||
"README.md must document rollout contextKind requirements"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("omits `contextKind` defaults") && readme.contains("to `user`;"),
|
||||
"README.md must document LaunchDarkly default rollout context kind"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("bucket as")
|
||||
&& readme.contains("zero and pick the first positive-weight variation"),
|
||||
"README.md must document missing-kind rollout bucketing behavior"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("close()` blocks the calling thread")
|
||||
&& readme.contains("while it flushes analytics"),
|
||||
"README.md must document blocking LaunchDarkly close semantics"
|
||||
);
|
||||
assert!(
|
||||
readme.contains("offload it to a blocking shutdown path"),
|
||||
"README.md must document async shutdown offloading guidance for close()"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_case_failed_status_formatter_includes_fixture_lock_regeneration_recipe() {
|
||||
let message = format_valid_case_failed_status(
|
||||
"static",
|
||||
"",
|
||||
"error: lock file needs update but --locked was passed",
|
||||
);
|
||||
assert!(
|
||||
message.contains(FIXTURE_LOCK_REGEN_RECIPE),
|
||||
"valid-case failure formatter must include fixture lock recipe\nmessage:\n{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_case_missing_exact_one_formatter_includes_fixture_lock_regeneration_recipe() {
|
||||
let message =
|
||||
format_invalid_case_missing_exact_one("none", "error: unrelated nested cargo failure");
|
||||
assert!(
|
||||
message.contains(FIXTURE_LOCK_REGEN_RECIPE),
|
||||
"invalid-case failure formatter must include fixture lock recipe\nmessage:\n{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_artifact_provider_features_require_exactly_one_selection() {
|
||||
assert!(
|
||||
fixture_lockfile().is_file(),
|
||||
"relay feature selection fixture must check in Cargo.lock for locked nested cargo runs\nregenerate it with `{FIXTURE_LOCK_REGEN_RECIPE}`"
|
||||
);
|
||||
|
||||
let target_dir = TempDir::new().expect("temp target dir");
|
||||
|
||||
let valid_cases = [
|
||||
("static", &["static-feature-flags"][..]),
|
||||
("environment", &["environment-feature-flags"][..]),
|
||||
("launchdarkly", &["launchdarkly-feature-flags"][..]),
|
||||
];
|
||||
|
||||
for (case_name, features) in valid_cases {
|
||||
let output = cargo_check(target_dir.path(), case_name, features);
|
||||
assert_valid_case_success(case_name, &output);
|
||||
}
|
||||
|
||||
let invalid_cases = [
|
||||
("none", &[][..]),
|
||||
(
|
||||
"static-and-environment",
|
||||
&["static-feature-flags", "environment-feature-flags"][..],
|
||||
),
|
||||
(
|
||||
"static-and-launchdarkly",
|
||||
&["static-feature-flags", "launchdarkly-feature-flags"][..],
|
||||
),
|
||||
(
|
||||
"environment-and-launchdarkly",
|
||||
&["environment-feature-flags", "launchdarkly-feature-flags"][..],
|
||||
),
|
||||
(
|
||||
"all-three",
|
||||
&[
|
||||
"static-feature-flags",
|
||||
"environment-feature-flags",
|
||||
"launchdarkly-feature-flags",
|
||||
][..],
|
||||
),
|
||||
];
|
||||
|
||||
for (case_name, features) in invalid_cases {
|
||||
let output = cargo_check(target_dir.path(), case_name, features);
|
||||
assert_invalid_case_has_exact_one_diagnostic(case_name, &output);
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,11 @@ run_unit_tests() {
|
||||
run_test_step "buzz-backend-kubernetes tests" \
|
||||
cargo test -p buzz-backend-kubernetes -- --nocapture
|
||||
|
||||
# Keep fallback parity with `just test-unit`: one LaunchDarkly-feature run
|
||||
# exercises both default and feature-gated buzz-feature-flags tests.
|
||||
run_test_step "buzz-feature-flags tests (launchdarkly)" \
|
||||
cargo test -p buzz-feature-flags --features launchdarkly -- --nocapture
|
||||
|
||||
# buzz-agent model-capabilities corpus: the Rust half of the cross-language
|
||||
# drift guard. model_capabilities.rs embeds scripts/model-capabilities.json +
|
||||
# scripts/normative-corpus.json via include_str! and replays the full locked
|
||||
|
||||
Reference in New Issue
Block a user