From b80d501d8d90e43653a07009351c3e0cc10be230 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Tue, 7 Apr 2026 17:57:52 +0200 Subject: [PATCH 01/14] feat(options): Add objectstore-options crate with live-reloading sentry-options support Introduces the `objectstore-options` crate, which wraps `sentry-options` and exposes Objectstore-specific runtime configuration. Includes: - `Options` struct with a global `OnceLock>` snapshot refreshed every 5s - `init()` function that loads an initial snapshot and spawns a background refresh task - `Killswitch` type (plain data, deserialized from options) with usecase, scopes, and service fields - Test-mode `Options::get()` that deserializes fresh from a thread-local instance, enabling `sentry_options::testing::override_options` to work without calling `init()` - Objectstore killswitches JSON schema added to `sentry-options/schemas/objectstore/` - `objectstore_options::init(None)` wired into the server CLI startup path --- .plans/killswitches-options-integration.md | 74 ++++ AGENTS.md | 1 + Cargo.lock | 415 +++++++++++++++++- Cargo.toml | 6 +- README.md | 3 + objectstore-options/Cargo.toml | 20 + objectstore-options/src/lib.rs | 166 +++++++ objectstore-server/Cargo.toml | 1 + objectstore-server/src/cli.rs | 1 + .../schemas/objectstore/schema.json | 28 +- 10 files changed, 691 insertions(+), 24 deletions(-) create mode 100644 .plans/killswitches-options-integration.md create mode 100644 objectstore-options/Cargo.toml create mode 100644 objectstore-options/src/lib.rs diff --git a/.plans/killswitches-options-integration.md b/.plans/killswitches-options-integration.md new file mode 100644 index 00000000..8cd85f85 --- /dev/null +++ b/.plans/killswitches-options-integration.md @@ -0,0 +1,74 @@ +# Plan: Integrate Killswitches with objectstore-options + +## Goal + +Replace the static config-backed `Killswitches` with a live-reloading implementation +backed by `objectstore_options`, while keeping compiled glob matchers cached across +option refresh cycles. + +## Changes + +### 1. Move `Killswitch` to `objectstore-options` + +`objectstore_options::Killswitch` already exists as the plain data type. The +`objectstore_server::Killswitch` struct (with `service_matcher: OnceLock>`) +is removed entirely. All construction sites currently using the server type switch to +`objectstore_options::Killswitch`. + +### 2. Refactor `Killswitches` in `objectstore-server` + +- Make fields private; add a `Killswitches::new()` constructor. +- Remove `Deserialize`/`Serialize` derives (no longer loaded from YAML config). +- Remove the `From` impl (no longer needed). +- Remove `PartialEq` impl (no longer needed without `service_matcher` to skip). + +### 3. Introduce a global LRU matcher cache + +In `killswitches.rs`, add: + +```rust +static MATCHER_CACHE: Mutex> = ...; +const MATCHER_CACHE_SIZE: usize = 256; +``` + +Add `lru` as a dependency in `objectstore-server/Cargo.toml`. + +### 4. Move matching logic from `Killswitch` to `Killswitches` + +- Remove `impl Killswitch { fn matches(...) }`. +- Add a private `fn match_one(k: &objectstore_options::Killswitch, ctx, service) -> bool` + on `Killswitches` (or as a free function in the module) that uses `MATCHER_CACHE`. +- `Killswitches::matches` and `Killswitches::find` call `match_one` per entry. + +### 5. Wire up `Killswitches::matches` to live options + +`Killswitches::matches` calls `objectstore_options::Options::get()` on every invocation +to get the fresh killswitch list, rather than iterating stored entries. + +> This implies `Killswitches` itself becomes a zero-field unit struct (or is removed +> in favour of free functions). To be decided. + +### 6. Remove `killswitches` field from `Config` + +Once killswitches are driven by options, remove `pub killswitches: Killswitches` from +`Config` and its `default()` impl. Update all call sites that construct `Config` with +explicit killswitches (tests in `config.rs`, `extractors/id.rs`, `tests/limits.rs`) to +use `sentry_options::testing::set_override` instead. + +## Affected Files + +- `objectstore-options/src/lib.rs` — `Killswitch` type already present, no changes needed +- `objectstore-server/src/killswitches.rs` — main rework +- `objectstore-server/src/config.rs` — remove field, update tests +- `objectstore-server/src/extractors/id.rs` — update tests +- `objectstore-server/tests/limits.rs` — update integration test +- `objectstore-server/Cargo.toml` — add `lru` dependency + +## Open Questions + +- Does `Killswitches` become a unit struct (just a namespace for the `matches` function), + or do we keep it as a newtype for the list? If options are always fetched live, there + is nothing to store. +- The `limits.rs` integration test constructs a full `TestServer` with specific + killswitches. With config-backed killswitches gone, this test needs to set options + overrides and ensure `init` has been called or a test shim is in place. diff --git a/AGENTS.md b/AGENTS.md index ac2300ab..a39117fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,5 +109,6 @@ Do these checks after completing changes and before responding to the user. - `objectstore-server/` - Web server application - `objectstore-service/` - Core service logic and backends - `objectstore-types/` - Shared type definitions +- `objectstore-options/` - Runtime options backed by sentry-options - `clients/rust/` - Rust client library - `clients/python/` - Python client library diff --git a/Cargo.lock b/Cargo.lock index 0714d63e..e1c84f39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -186,6 +188,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -453,6 +461,21 @@ dependencies = [ "tower", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.0" @@ -477,6 +500,12 @@ dependencies = [ "objc2", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.1" @@ -493,6 +522,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -974,6 +1009,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1034,6 +1078,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1096,6 +1151,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1138,6 +1204,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1379,6 +1455,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -1939,6 +2017,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd94c1d7bfa9d30b5d4268df9fe8c5ed13fa600a6bd0dae02b04db86d575fc8a" +dependencies = [ + "ahash", + "base64", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "reqwest 0.12.28", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + [[package]] name = "jsonwebtoken" version = "10.3.0" @@ -2249,6 +2355,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2275,6 +2395,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2301,6 +2436,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2503,7 +2649,7 @@ dependencies = [ "objectstore-types", "percent-encoding", "reqwest 0.13.2", - "sentry-core", + "sentry-core 0.45.0", "serde", "tempfile", "thiserror 2.0.18", @@ -2519,7 +2665,7 @@ version = "0.1.0" dependencies = [ "anyhow", "console 0.16.2", - "sentry", + "sentry 0.45.0", "serde", "tracing", "tracing-subscriber", @@ -2536,6 +2682,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "objectstore-options" +version = "0.1.0" +dependencies = [ + "objectstore-log", + "sentry-options", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "objectstore-server" version = "0.1.0" @@ -2559,6 +2717,7 @@ dependencies = [ "num_cpus", "objectstore-log", "objectstore-metrics", + "objectstore-options", "objectstore-service", "objectstore-test", "objectstore-types", @@ -2568,7 +2727,7 @@ dependencies = [ "reqwest 0.12.28", "rustls", "secrecy", - "sentry", + "sentry 0.45.0", "serde", "serde_json", "stresstest", @@ -2598,7 +2757,7 @@ dependencies = [ "objectstore-types", "regex", "reqwest 0.12.28", - "sentry", + "sentry 0.45.0", "serde", "serde_json", "tempfile", @@ -2677,6 +2836,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.111" @@ -2685,6 +2853,7 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -2714,6 +2883,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -3297,6 +3472,21 @@ dependencies = [ "syn", ] +[[package]] +name = "referencing" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1cb02ef237bd757aba02cd648a4ffa628cd8e5852e2b9bb89aabf93dc5dcc7" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -3721,15 +3911,36 @@ dependencies = [ "httpdate", "native-tls", "reqwest 0.12.28", - "sentry-actix", - "sentry-backtrace", - "sentry-contexts", - "sentry-core", - "sentry-debug-images", + "sentry-actix 0.45.0", + "sentry-backtrace 0.45.0", + "sentry-contexts 0.45.0", + "sentry-core 0.45.0", + "sentry-debug-images 0.45.0", "sentry-log", - "sentry-panic", + "sentry-panic 0.45.0", "sentry-tower", - "sentry-tracing", + "sentry-tracing 0.45.0", + "tokio", + "ureq", +] + +[[package]] +name = "sentry" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92d893ba7469d361a6958522fa440e4e2bc8bf4c5803cd1bf40b9af63f8f9a8" +dependencies = [ + "cfg_aliases", + "httpdate", + "native-tls", + "reqwest 0.12.28", + "sentry-actix 0.46.2", + "sentry-backtrace 0.46.2", + "sentry-contexts 0.46.2", + "sentry-core 0.46.2", + "sentry-debug-images 0.46.2", + "sentry-panic 0.46.2", + "sentry-tracing 0.46.2", "tokio", "ureq", ] @@ -3744,7 +3955,20 @@ dependencies = [ "actix-web", "bytes", "futures-util", - "sentry-core", + "sentry-core 0.45.0", +] + +[[package]] +name = "sentry-actix" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56cb150fd6b55b3023714a3aaa1e3bdadfd44f164efc54fad69efc69aac36887" +dependencies = [ + "actix-http", + "actix-web", + "bytes", + "futures-util", + "sentry-core 0.46.2", ] [[package]] @@ -3755,7 +3979,18 @@ checksum = "f3253a495ab536f6de1746a58d5d7824b77d75e08e1a4b8ca6fb356839077ae0" dependencies = [ "backtrace", "regex", - "sentry-core", + "sentry-core 0.45.0", +] + +[[package]] +name = "sentry-backtrace" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f8784d0a27b5cd4b5f75769ffc84f0b7580e3c35e1af9cd83cb90b612d769cc" +dependencies = [ + "backtrace", + "regex", + "sentry-core 0.46.2", ] [[package]] @@ -3768,7 +4003,21 @@ dependencies = [ "libc", "os_info", "rustc_version", - "sentry-core", + "sentry-core 0.45.0", + "uname", +] + +[[package]] +name = "sentry-contexts" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e5eb42f4cd4f9fdfec9e3b07b25a4c9769df83d218a7e846658984d5948ad3e" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core 0.46.2", "uname", ] @@ -3779,7 +4028,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b6729c8e71ac968edbe9bf2dd4109c162e552b52bacd2b07e24ede1aba84a5" dependencies = [ "rand 0.9.2", - "sentry-types", + "sentry-types 0.45.0", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-core" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b1e7ca40f965db239da279bf278d87b7407469b98835f27f0c8e59ed189b06" +dependencies = [ + "rand 0.9.2", + "sentry-types 0.46.2", "serde", "serde_json", "url", @@ -3792,7 +4054,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc85b59c1dfb19912bfba1af73a592e2e5548cae241a79ecb805afab3333d04c" dependencies = [ "findshlibs", - "sentry-core", + "sentry-core 0.45.0", +] + +[[package]] +name = "sentry-debug-images" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "002561e49ea3a9de316e2efadc40fae553921b8ff41448f02ea85fd135a778d6" +dependencies = [ + "findshlibs", + "sentry-core 0.46.2", ] [[package]] @@ -3803,7 +4075,33 @@ checksum = "912fea629a3fc7bfcb97f7bde31a5c815df8526ba19088dcef3ae32ea1e25418" dependencies = [ "bitflags", "log", - "sentry-core", + "sentry-core 0.45.0", +] + +[[package]] +name = "sentry-options" +version = "1.0.5" +dependencies = [ + "num", + "sentry-options-validation", + "serde_json", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "sentry-options-validation" +version = "1.0.5" +dependencies = [ + "anyhow", + "chrono", + "jsonschema", + "openssl", + "sentry 0.46.2", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", ] [[package]] @@ -3812,8 +4110,18 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ac0471f04f8f97af0c17eeca2c516e23faa1c0271a55bc64371d9ce488c2d40" dependencies = [ - "sentry-backtrace", - "sentry-core", + "sentry-backtrace 0.45.0", + "sentry-core 0.45.0", +] + +[[package]] +name = "sentry-panic" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8906f8be87aea5ac7ef937323fb655d66607427f61007b99b7cb3504dc5a156c" +dependencies = [ + "sentry-backtrace 0.46.2", + "sentry-core 0.46.2", ] [[package]] @@ -3825,7 +4133,7 @@ dependencies = [ "axum", "http 1.4.0", "pin-project", - "sentry-core", + "sentry-core 0.45.0", "tower-layer", "tower-service", "url", @@ -3838,8 +4146,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "428f780866a613142dcc81b7f8551ae4d1c056f4df22b6d7ddd9154a9974eb03" dependencies = [ "bitflags", - "sentry-backtrace", - "sentry-core", + "sentry-backtrace 0.45.0", + "sentry-core 0.45.0", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-tracing" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b07eefe04486316c57aba08ab53dd44753c25102d1d3fe05775cc93a13262d9" +dependencies = [ + "bitflags", + "sentry-backtrace 0.46.2", + "sentry-core 0.46.2", "tracing-core", "tracing-subscriber", ] @@ -3861,6 +4182,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "sentry-types" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567711f01f86a842057e1fc17779eba33a336004227e1a1e7e6cc2599e22e259" +dependencies = [ + "debugid", + "hex", + "rand 0.9.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.228" @@ -3971,6 +4309,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4650,6 +4999,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4758,6 +5113,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4776,6 +5141,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index e67e2f66..6e5f7e3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ lto = "thin" objectstore-client = { path = "clients/rust" } objectstore-log = { path = "objectstore-log" } objectstore-metrics = { path = "objectstore-metrics" } +objectstore-options = { path = "objectstore-options" } objectstore-server = { path = "objectstore-server" } objectstore-service = { path = "objectstore-service" } objectstore-test = { path = "objectstore-test" } @@ -54,4 +55,7 @@ tokio-util = { version = "0.7.15", features = ["rt"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] } uuid = { version = "1.17.0", features = ["v4"] } -sentry-options = "1.0.1" + +[patch.crates-io] +sentry-options = { path = "../../../../sentry-options/clients/rust" } +sentry-options-validation = { path = "../../../../sentry-options/sentry-options-validation" } diff --git a/README.md b/README.md index 34c504e2..daa5482e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ The platform is split into the following core components: including metadata, scopes, expiration, and permissions. - `objectstore-metrics`: Metrics macros and DogStatsD initialization shared across service components. +- `objectstore-options`: Runtime options backed by + [`sentry-options`](https://crates.io/crates/sentry-options), providing + dynamic configuration to service components. - `clients`: The Rust and Python client library SDKs, which expose high-performance blob storage access. diff --git a/objectstore-options/Cargo.toml b/objectstore-options/Cargo.toml new file mode 100644 index 00000000..3d7161d5 --- /dev/null +++ b/objectstore-options/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "objectstore-options" +authors = ["Sentry "] +description = "Runtime options for Objectstore, backed by sentry-options" +homepage = "https://getsentry.github.io/objectstore/" +repository = "https://github.com/getsentry/objectstore" +license-file = "../LICENSE.md" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +sentry-options = "1.0.5" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +objectstore-log = { workspace = true } +tokio = { workspace = true, features = ["time"] } + +[dev-dependencies] diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs new file mode 100644 index 00000000..17773a31 --- /dev/null +++ b/objectstore-options/src/lib.rs @@ -0,0 +1,166 @@ +//! Runtime options for Objectstore, backed by [`sentry-options`]. +//! +//! [`sentry-options`]: https://crates.io/crates/sentry-options + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::{OnceLock, RwLock}; +use std::time::Duration; + +use serde::Deserialize; + +const NAMESPACE: &str = "objectstore"; +const SCHEMA: &str = include_str!("../../sentry-options/schemas/objectstore/schema.json"); +const REFRESH_INTERVAL: Duration = Duration::from_secs(5); + +static OPTIONS: OnceLock> = OnceLock::new(); + +#[cfg(test)] +pub use sentry_options::{Value, testing::OverrideGuard}; + +#[cfg(test)] +thread_local! { + static TEST_INNER: sentry_options::Options = + sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)]) + .expect("objectstore schema should be valid"); +} + +/// TODO: Doc comment. +#[cfg(not(test))] +pub type Ref = std::sync::RwLockReadGuard<'static, Options>; + +/// TODO: Doc comment. +#[cfg(test)] +pub type Ref = Options; + +/// Errors returned by this crate. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Options(#[from] sentry_options::OptionsError), + #[error("failed to deserialize option value")] + Deserialize(#[from] serde_json::Error), +} + +/// TODO: Doc comment. Also refer to `init`. +pub struct Options { + killswitches: Vec, +} + +impl Options { + /// TODO: Doc comment + #[cfg(not(test))] + pub fn get() -> Ref { + let options = OPTIONS.get().expect("options not initialized"); + options.read().unwrap_or_else(|p| p.into_inner()) + } + + /// TODO: Doc comment + #[cfg(test)] + pub fn get() -> Ref { + TEST_INNER.with(|inner| Self::deserialize(inner).expect("failed to deserialize options")) + } + + fn deserialize(options: &sentry_options::Options) -> Result { + Ok(Self { + killswitches: Deserialize::deserialize(options.get(NAMESPACE, "killswitches")?)?, + }) + } + + /// TODO: Doc comment + pub fn killswitches(&self) -> &[Killswitch] { + &self.killswitches + } +} + +/// Initializes the global options instance and spawns a background refresh task. +/// +/// If `base_dir` is provided, values are loaded from `{base_dir}/values/`. Otherwise, the +/// standard fallback chain is used: +/// +/// 1. `SENTRY_OPTIONS_DIR` environment variable +/// 2. `/etc/sentry-options` (if it exists) +/// 3. `sentry-options/` relative to the current working directory +/// 4. Schema defaults (if no values file is present) +/// +/// Idempotent: if already initialized, returns `Ok(())` without re-loading. +/// +/// Must be called from within a Tokio runtime. +pub fn init(base_dir: Option<&Path>) -> Result<(), Error> { + if OPTIONS.get().is_some() { + return Ok(()); + } + + let schemas = &[(NAMESPACE, SCHEMA)]; + let inner = match base_dir { + Some(dir) => sentry_options::Options::from_directory_and_schemas(dir, schemas)?, + None => sentry_options::Options::from_schemas(schemas)?, + }; + + // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the application + // will not silently run with defaults or fail later when options get accessed. + let _ = OPTIONS.set(RwLock::new(Options::deserialize(&inner)?)); + tokio::spawn(refresh(inner)); + + Ok(()) +} + +/// TODO: Doc comment +async fn refresh(inner: sentry_options::Options) { + let Some(snapshot) = OPTIONS.get() else { + return; + }; + + let mut interval = tokio::time::interval(REFRESH_INTERVAL); + interval.tick().await; // consume the immediate first tick + + loop { + interval.tick().await; + + match Options::deserialize(&inner) { + Ok(new_snapshot) => *snapshot.write().unwrap_or_else(|p| p.into_inner()) = new_snapshot, + Err(ref err) => { + objectstore_log::error!(!!err, "Failed to refresh objectstore options") + } + } + } +} + +/// Overrides the global options for testing purposes. +/// +/// This function is only available in test builds and allows temporarily overriding +/// specific options. The overrides are applied for the duration of the returned +/// `OverrideGuard`. +#[cfg(test)] +pub fn override_options(overrides: &[(&str, sentry_options::Value)]) -> OverrideGuard { + let overrides = overrides + .iter() + .map(|(key, value)| (NAMESPACE, *key, value.clone())) + .collect::>(); + + sentry_options::testing::override_options(&overrides).unwrap() +} + +/// A killswitch loaded from sentry-options. +/// +/// This is the plain data representation used for deserialization. See +/// [`objectstore_server::killswitches::Killswitch`] for the full type with matching logic. +#[derive(Clone, Debug, serde::Deserialize)] +pub struct Killswitch { + #[serde(default)] + pub usecase: Option, + #[serde(default)] + pub scopes: BTreeMap, + #[serde(default)] + pub service: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_is_valid() { + let _ = Options::get(); + } +} diff --git a/objectstore-server/Cargo.toml b/objectstore-server/Cargo.toml index 8898770e..42a98c56 100644 --- a/objectstore-server/Cargo.toml +++ b/objectstore-server/Cargo.toml @@ -30,6 +30,7 @@ mimalloc = { workspace = true } num_cpus = "1.17.0" objectstore-log = { workspace = true, features = ["init", "sentry"] } objectstore-metrics = { workspace = true } +objectstore-options = { workspace = true } objectstore-service = { workspace = true } objectstore-types = { workspace = true } rand = { workspace = true } diff --git a/objectstore-server/src/cli.rs b/objectstore-server/src/cli.rs index 9cc435d2..31f1d8db 100644 --- a/objectstore-server/src/cli.rs +++ b/objectstore-server/src/cli.rs @@ -115,6 +115,7 @@ pub fn execute() -> Result<()> { objectstore_log::debug!(?config); objectstore_metrics::init(&config.metrics)?; + objectstore_options::init(None)?; runtime.block_on(async move { match args.command { diff --git a/sentry-options/schemas/objectstore/schema.json b/sentry-options/schemas/objectstore/schema.json index 2ffee859..44ca5848 100644 --- a/sentry-options/schemas/objectstore/schema.json +++ b/sentry-options/schemas/objectstore/schema.json @@ -1,5 +1,31 @@ { "version": "1.0", "type": "object", - "properties": {} + "properties": { + "killswitches": { + "type": "array", + "default": [], + "description": "Runtime killswitches for disabling access to specific object contexts. Each entry matches requests by usecase, scope values, and optionally a downstream service glob pattern. When any killswitch matches, the request is rejected without forwarding to the storage backend.", + "items": { + "type": "object", + "properties": { + "usecase": { + "type": "string", + "optional": true + }, + "service": { + "type": "string", + "optional": true + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "optional": true + } + } + } + } + } } From 8d02f36900211e54ebcca2f213ee2e5dfee64408 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Thu, 9 Apr 2026 12:36:08 +0200 Subject: [PATCH 02/14] feat(options): Integrate killswitches with live-reloading sentry-options - Wire objectstore-options into objectstore-server killswitches, replacing the static config-backed implementation with live-reloading via sentry-options - Switch objectstore-options global to OnceLock> for lock-free reads - Patch sentry-options to feat/additional-properties-map-schema branch to support object-typed schema fields with additionalProperties --- .plans/killswitches-options-integration.md | 74 ----- Cargo.lock | 119 ++----- Cargo.toml | 5 +- objectstore-options/Cargo.toml | 4 + objectstore-options/src/lib.rs | 137 ++++---- objectstore-server/Cargo.toml | 3 + objectstore-server/src/cli.rs | 2 +- objectstore-server/src/config.rs | 7 +- objectstore-server/src/extractors/id.rs | 9 +- objectstore-server/src/killswitches.rs | 299 +++++++++++------- objectstore-server/tests/limits.rs | 3 +- objectstore-test/Cargo.toml | 1 + .../schemas/objectstore/schema.json | 27 +- 13 files changed, 320 insertions(+), 370 deletions(-) delete mode 100644 .plans/killswitches-options-integration.md diff --git a/.plans/killswitches-options-integration.md b/.plans/killswitches-options-integration.md deleted file mode 100644 index 8cd85f85..00000000 --- a/.plans/killswitches-options-integration.md +++ /dev/null @@ -1,74 +0,0 @@ -# Plan: Integrate Killswitches with objectstore-options - -## Goal - -Replace the static config-backed `Killswitches` with a live-reloading implementation -backed by `objectstore_options`, while keeping compiled glob matchers cached across -option refresh cycles. - -## Changes - -### 1. Move `Killswitch` to `objectstore-options` - -`objectstore_options::Killswitch` already exists as the plain data type. The -`objectstore_server::Killswitch` struct (with `service_matcher: OnceLock>`) -is removed entirely. All construction sites currently using the server type switch to -`objectstore_options::Killswitch`. - -### 2. Refactor `Killswitches` in `objectstore-server` - -- Make fields private; add a `Killswitches::new()` constructor. -- Remove `Deserialize`/`Serialize` derives (no longer loaded from YAML config). -- Remove the `From` impl (no longer needed). -- Remove `PartialEq` impl (no longer needed without `service_matcher` to skip). - -### 3. Introduce a global LRU matcher cache - -In `killswitches.rs`, add: - -```rust -static MATCHER_CACHE: Mutex> = ...; -const MATCHER_CACHE_SIZE: usize = 256; -``` - -Add `lru` as a dependency in `objectstore-server/Cargo.toml`. - -### 4. Move matching logic from `Killswitch` to `Killswitches` - -- Remove `impl Killswitch { fn matches(...) }`. -- Add a private `fn match_one(k: &objectstore_options::Killswitch, ctx, service) -> bool` - on `Killswitches` (or as a free function in the module) that uses `MATCHER_CACHE`. -- `Killswitches::matches` and `Killswitches::find` call `match_one` per entry. - -### 5. Wire up `Killswitches::matches` to live options - -`Killswitches::matches` calls `objectstore_options::Options::get()` on every invocation -to get the fresh killswitch list, rather than iterating stored entries. - -> This implies `Killswitches` itself becomes a zero-field unit struct (or is removed -> in favour of free functions). To be decided. - -### 6. Remove `killswitches` field from `Config` - -Once killswitches are driven by options, remove `pub killswitches: Killswitches` from -`Config` and its `default()` impl. Update all call sites that construct `Config` with -explicit killswitches (tests in `config.rs`, `extractors/id.rs`, `tests/limits.rs`) to -use `sentry_options::testing::set_override` instead. - -## Affected Files - -- `objectstore-options/src/lib.rs` — `Killswitch` type already present, no changes needed -- `objectstore-server/src/killswitches.rs` — main rework -- `objectstore-server/src/config.rs` — remove field, update tests -- `objectstore-server/src/extractors/id.rs` — update tests -- `objectstore-server/tests/limits.rs` — update integration test -- `objectstore-server/Cargo.toml` — add `lru` dependency - -## Open Questions - -- Does `Killswitches` become a unit struct (just a namespace for the `matches` function), - or do we keep it as a newtype for the list? If options are always fetched live, there - is nothing to store. -- The `limits.rs` integration test constructs a full `TestServer` with specific - killswitches. With config-backed killswitches gone, this test needs to set options - overrides and ensure `init` has been called or a test shim is in place. diff --git a/Cargo.lock b/Cargo.lock index e1c84f39..18e09cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,6 +209,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "argh" version = "0.1.14" @@ -2144,6 +2153,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2649,7 +2667,7 @@ dependencies = [ "objectstore-types", "percent-encoding", "reqwest 0.13.2", - "sentry-core 0.45.0", + "sentry-core 0.46.2", "serde", "tempfile", "thiserror 2.0.18", @@ -2686,6 +2704,7 @@ dependencies = [ name = "objectstore-options" version = "0.1.0" dependencies = [ + "arc-swap", "objectstore-log", "sentry-options", "serde", @@ -2712,6 +2731,7 @@ dependencies = [ "humantime", "humantime-serde", "jsonwebtoken", + "lru", "mimalloc", "nix", "num_cpus", @@ -2733,6 +2753,7 @@ dependencies = [ "stresstest", "tempfile", "thiserror 2.0.18", + "thread_local", "tokio", "tower", "tower-http", @@ -2774,6 +2795,7 @@ dependencies = [ name = "objectstore-test" version = "0.1.0" dependencies = [ + "objectstore-options", "objectstore-server", "objectstore-types", "tempfile", @@ -3911,15 +3933,15 @@ dependencies = [ "httpdate", "native-tls", "reqwest 0.12.28", - "sentry-actix 0.45.0", - "sentry-backtrace 0.45.0", - "sentry-contexts 0.45.0", + "sentry-actix", + "sentry-backtrace", + "sentry-contexts", "sentry-core 0.45.0", - "sentry-debug-images 0.45.0", + "sentry-debug-images", "sentry-log", - "sentry-panic 0.45.0", + "sentry-panic", "sentry-tower", - "sentry-tracing 0.45.0", + "sentry-tracing", "tokio", "ureq", ] @@ -3934,13 +3956,7 @@ dependencies = [ "httpdate", "native-tls", "reqwest 0.12.28", - "sentry-actix 0.46.2", - "sentry-backtrace 0.46.2", - "sentry-contexts 0.46.2", "sentry-core 0.46.2", - "sentry-debug-images 0.46.2", - "sentry-panic 0.46.2", - "sentry-tracing 0.46.2", "tokio", "ureq", ] @@ -3958,19 +3974,6 @@ dependencies = [ "sentry-core 0.45.0", ] -[[package]] -name = "sentry-actix" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56cb150fd6b55b3023714a3aaa1e3bdadfd44f164efc54fad69efc69aac36887" -dependencies = [ - "actix-http", - "actix-web", - "bytes", - "futures-util", - "sentry-core 0.46.2", -] - [[package]] name = "sentry-backtrace" version = "0.45.0" @@ -3982,17 +3985,6 @@ dependencies = [ "sentry-core 0.45.0", ] -[[package]] -name = "sentry-backtrace" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f8784d0a27b5cd4b5f75769ffc84f0b7580e3c35e1af9cd83cb90b612d769cc" -dependencies = [ - "backtrace", - "regex", - "sentry-core 0.46.2", -] - [[package]] name = "sentry-contexts" version = "0.45.0" @@ -4007,20 +3999,6 @@ dependencies = [ "uname", ] -[[package]] -name = "sentry-contexts" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e5eb42f4cd4f9fdfec9e3b07b25a4c9769df83d218a7e846658984d5948ad3e" -dependencies = [ - "hostname", - "libc", - "os_info", - "rustc_version", - "sentry-core 0.46.2", - "uname", -] - [[package]] name = "sentry-core" version = "0.45.0" @@ -4057,16 +4035,6 @@ dependencies = [ "sentry-core 0.45.0", ] -[[package]] -name = "sentry-debug-images" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "002561e49ea3a9de316e2efadc40fae553921b8ff41448f02ea85fd135a778d6" -dependencies = [ - "findshlibs", - "sentry-core 0.46.2", -] - [[package]] name = "sentry-log" version = "0.45.0" @@ -4081,6 +4049,7 @@ dependencies = [ [[package]] name = "sentry-options" version = "1.0.5" +source = "git+https://github.com/getsentry/sentry-options?branch=feat%2Fadditional-properties-map-schema#2ac74d3124b88018f917c6f08a39b770e3ded8ff" dependencies = [ "num", "sentry-options-validation", @@ -4092,6 +4061,7 @@ dependencies = [ [[package]] name = "sentry-options-validation" version = "1.0.5" +source = "git+https://github.com/getsentry/sentry-options?branch=feat%2Fadditional-properties-map-schema#2ac74d3124b88018f917c6f08a39b770e3ded8ff" dependencies = [ "anyhow", "chrono", @@ -4110,20 +4080,10 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ac0471f04f8f97af0c17eeca2c516e23faa1c0271a55bc64371d9ce488c2d40" dependencies = [ - "sentry-backtrace 0.45.0", + "sentry-backtrace", "sentry-core 0.45.0", ] -[[package]] -name = "sentry-panic" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8906f8be87aea5ac7ef937323fb655d66607427f61007b99b7cb3504dc5a156c" -dependencies = [ - "sentry-backtrace 0.46.2", - "sentry-core 0.46.2", -] - [[package]] name = "sentry-tower" version = "0.45.0" @@ -4146,25 +4106,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "428f780866a613142dcc81b7f8551ae4d1c056f4df22b6d7ddd9154a9974eb03" dependencies = [ "bitflags", - "sentry-backtrace 0.45.0", + "sentry-backtrace", "sentry-core 0.45.0", "tracing-core", "tracing-subscriber", ] -[[package]] -name = "sentry-tracing" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b07eefe04486316c57aba08ab53dd44753c25102d1d3fe05775cc93a13262d9" -dependencies = [ - "bitflags", - "sentry-backtrace 0.46.2", - "sentry-core 0.46.2", - "tracing-core", - "tracing-subscriber", -] - [[package]] name = "sentry-types" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 6e5f7e3c..3759cced 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ objectstore-types = { path = "objectstore-types", version = "0.1.5" } percent-encoding = "2.3" stresstest = { path = "stresstest" } +arc-swap = "1.7.1" anyhow = "1.0.69" async-trait = "0.1.88" bytes = "1.11.1" @@ -57,5 +58,5 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] } uuid = { version = "1.17.0", features = ["v4"] } [patch.crates-io] -sentry-options = { path = "../../../../sentry-options/clients/rust" } -sentry-options-validation = { path = "../../../../sentry-options/sentry-options-validation" } +sentry-options = { git = "https://github.com/getsentry/sentry-options", branch = "feat/additional-properties-map-schema" } +sentry-options-validation = { git = "https://github.com/getsentry/sentry-options", branch = "feat/additional-properties-map-schema" } diff --git a/objectstore-options/Cargo.toml b/objectstore-options/Cargo.toml index 3d7161d5..0dc7742d 100644 --- a/objectstore-options/Cargo.toml +++ b/objectstore-options/Cargo.toml @@ -10,6 +10,7 @@ edition = "2024" publish = false [dependencies] +arc-swap = { workspace = true } sentry-options = "1.0.5" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -17,4 +18,7 @@ thiserror = { workspace = true } objectstore-log = { workspace = true } tokio = { workspace = true, features = ["time"] } +[features] +testing = [] + [dev-dependencies] diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index 17773a31..9831449d 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -3,35 +3,18 @@ //! [`sentry-options`]: https://crates.io/crates/sentry-options use std::collections::BTreeMap; -use std::path::Path; -use std::sync::{OnceLock, RwLock}; +use std::sync::{Arc, OnceLock}; use std::time::Duration; -use serde::Deserialize; +use arc_swap::ArcSwap; +use serde::{Deserialize, Serialize}; const NAMESPACE: &str = "objectstore"; const SCHEMA: &str = include_str!("../../sentry-options/schemas/objectstore/schema.json"); const REFRESH_INTERVAL: Duration = Duration::from_secs(5); -static OPTIONS: OnceLock> = OnceLock::new(); - -#[cfg(test)] -pub use sentry_options::{Value, testing::OverrideGuard}; - -#[cfg(test)] -thread_local! { - static TEST_INNER: sentry_options::Options = - sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)]) - .expect("objectstore schema should be valid"); -} - -/// TODO: Doc comment. -#[cfg(not(test))] -pub type Ref = std::sync::RwLockReadGuard<'static, Options>; - -/// TODO: Doc comment. -#[cfg(test)] -pub type Ref = Options; +/// Global instance of the options, initialized by [`init`] and accessed via [`Options::get`]. +static OPTIONS: OnceLock> = OnceLock::new(); /// Errors returned by this crate. #[derive(Debug, thiserror::Error)] @@ -42,23 +25,39 @@ pub enum Error { Deserialize(#[from] serde_json::Error), } -/// TODO: Doc comment. Also refer to `init`. +/// Runtime options for Objectstore, loaded from sentry-options. +/// +/// Obtain a snapshot of the current options via [`Options::get`]. Before calling `get`, +/// the global instance must be initialized with [`init`]. +#[derive(Debug)] pub struct Options { killswitches: Vec, } impl Options { - /// TODO: Doc comment - #[cfg(not(test))] - pub fn get() -> Ref { - let options = OPTIONS.get().expect("options not initialized"); - options.read().unwrap_or_else(|p| p.into_inner()) + /// Returns a snapshot of the current options. + /// + /// The returned [`Arc`] holds the most recently loaded values. Callers may hold it across + /// await points without blocking updates — a new snapshot is swapped in atomically by the + /// background refresh task without invalidating existing references. + /// + /// # Panics + /// + /// Panics if [`init`] has not been called. + #[cfg(not(feature = "testing"))] + pub fn get() -> Arc { + OPTIONS.get().expect("options not initialized").load_full() } - /// TODO: Doc comment - #[cfg(test)] - pub fn get() -> Ref { - TEST_INNER.with(|inner| Self::deserialize(inner).expect("failed to deserialize options")) + /// Returns a snapshot of the current options, deserializing fresh from schema defaults. + /// + /// In test builds this bypasses the global instance and reads directly from the schema, so + /// [`init`] does not need to be called. Use [`override_options`] to test non-default values. + #[cfg(feature = "testing")] + pub fn get() -> Arc { + let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)]) + .expect("options schema should be valid"); + Arc::new(Self::deserialize(&inner).expect("failed to deserialize options")) } fn deserialize(options: &sentry_options::Options) -> Result { @@ -67,12 +66,39 @@ impl Options { }) } - /// TODO: Doc comment + /// Returns the list of active killswitches. pub fn killswitches(&self) -> &[Killswitch] { &self.killswitches } } +/// A killswitch that may disable access to certain object contexts. +/// +/// Note that at least one of the fields should be set, or else the killswitch will match all +/// contexts and discard all requests. +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub struct Killswitch { + /// Optional usecase to match. + /// + /// If `None`, matches any usecase. + #[serde(default)] + pub usecase: Option, + + /// Scopes to match. + /// + /// If empty, matches any scopes. Additional scopes in the context are ignored, so a killswitch + /// matches if all of the specified scopes are present in the request with matching values. + #[serde(default)] + pub scopes: BTreeMap, + + /// Optional service glob pattern to match. + /// + /// If `None`, matches any service (or absence of service header). + /// If specified, the request must have a matching `x-downstream-service` header. + #[serde(default)] + pub service: Option, +} + /// Initializes the global options instance and spawns a background refresh task. /// /// If `base_dir` is provided, values are loaded from `{base_dir}/values/`. Otherwise, the @@ -86,26 +112,19 @@ impl Options { /// Idempotent: if already initialized, returns `Ok(())` without re-loading. /// /// Must be called from within a Tokio runtime. -pub fn init(base_dir: Option<&Path>) -> Result<(), Error> { - if OPTIONS.get().is_some() { - return Ok(()); +pub fn init() -> Result<(), Error> { + if OPTIONS.get().is_none() { + // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the application + // will not silently run with defaults or fail later when options are accessed. + let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)])?; + let _ = OPTIONS.set(ArcSwap::from_pointee(Options::deserialize(&inner)?)); + tokio::spawn(refresh(inner)); } - let schemas = &[(NAMESPACE, SCHEMA)]; - let inner = match base_dir { - Some(dir) => sentry_options::Options::from_directory_and_schemas(dir, schemas)?, - None => sentry_options::Options::from_schemas(schemas)?, - }; - - // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the application - // will not silently run with defaults or fail later when options get accessed. - let _ = OPTIONS.set(RwLock::new(Options::deserialize(&inner)?)); - tokio::spawn(refresh(inner)); - Ok(()) } -/// TODO: Doc comment +/// Periodically reloads options from disk and atomically swaps in the new snapshot. async fn refresh(inner: sentry_options::Options) { let Some(snapshot) = OPTIONS.get() else { return; @@ -118,7 +137,7 @@ async fn refresh(inner: sentry_options::Options) { interval.tick().await; match Options::deserialize(&inner) { - Ok(new_snapshot) => *snapshot.write().unwrap_or_else(|p| p.into_inner()) = new_snapshot, + Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), Err(ref err) => { objectstore_log::error!(!!err, "Failed to refresh objectstore options") } @@ -131,8 +150,10 @@ async fn refresh(inner: sentry_options::Options) { /// This function is only available in test builds and allows temporarily overriding /// specific options. The overrides are applied for the duration of the returned /// `OverrideGuard`. -#[cfg(test)] -pub fn override_options(overrides: &[(&str, sentry_options::Value)]) -> OverrideGuard { +#[cfg(feature = "testing")] +pub fn override_options( + overrides: &[(&str, serde_json::Value)], +) -> sentry_options::testing::OverrideGuard { let overrides = overrides .iter() .map(|(key, value)| (NAMESPACE, *key, value.clone())) @@ -141,20 +162,6 @@ pub fn override_options(overrides: &[(&str, sentry_options::Value)]) -> Override sentry_options::testing::override_options(&overrides).unwrap() } -/// A killswitch loaded from sentry-options. -/// -/// This is the plain data representation used for deserialization. See -/// [`objectstore_server::killswitches::Killswitch`] for the full type with matching logic. -#[derive(Clone, Debug, serde::Deserialize)] -pub struct Killswitch { - #[serde(default)] - pub usecase: Option, - #[serde(default)] - pub scopes: BTreeMap, - #[serde(default)] - pub service: Option, -} - #[cfg(test)] mod tests { use super::*; diff --git a/objectstore-server/Cargo.toml b/objectstore-server/Cargo.toml index 42a98c56..9d5ccb5d 100644 --- a/objectstore-server/Cargo.toml +++ b/objectstore-server/Cargo.toml @@ -22,6 +22,8 @@ figment = { version = "0.10.19", features = ["env", "test", "yaml"] } futures = { workspace = true } futures-util = { workspace = true } globset = "0.4.15" +lru = "0.16.3" +thread_local = "1.1.9" http = { workspace = true } humantime = { workspace = true } humantime-serde = { workspace = true } @@ -57,6 +59,7 @@ papaya = "0.2.3" [dev-dependencies] nix = { version = "0.30.1", features = ["signal"] } +objectstore-options = { workspace = true, features = ["testing"] } objectstore-test = { workspace = true } stresstest = { workspace = true } tempfile = { workspace = true } diff --git a/objectstore-server/src/cli.rs b/objectstore-server/src/cli.rs index 31f1d8db..8e2dd6c8 100644 --- a/objectstore-server/src/cli.rs +++ b/objectstore-server/src/cli.rs @@ -115,7 +115,7 @@ pub fn execute() -> Result<()> { objectstore_log::debug!(?config); objectstore_metrics::init(&config.metrics)?; - objectstore_options::init(None)?; + objectstore_options::init()?; runtime.block_on(async move { match args.command { diff --git a/objectstore-server/src/config.rs b/objectstore-server/src/config.rs index cb62510d..62f0f0b7 100644 --- a/objectstore-server/src/config.rs +++ b/objectstore-server/src/config.rs @@ -918,19 +918,16 @@ mod tests { usecase: Some("broken_usecase".into()), scopes: BTreeMap::new(), service: None, - service_matcher: std::sync::OnceLock::new(), }, Killswitch { usecase: None, scopes: BTreeMap::from([("org".into(), "42".into())]), service: None, - service_matcher: std::sync::OnceLock::new(), }, Killswitch { usecase: None, scopes: BTreeMap::new(), service: Some("test-*".into()), - service_matcher: std::sync::OnceLock::new(), }, Killswitch { usecase: None, @@ -939,18 +936,16 @@ mod tests { ("project".into(), "4711".into()), ]), service: None, - service_matcher: std::sync::OnceLock::new(), }, Killswitch { usecase: Some("attachments".into()), scopes: BTreeMap::from([("org".into(), "42".into())]), service: Some("test-*".into()), - service_matcher: std::sync::OnceLock::new(), }, ]; let config = Config::load(Some(tempfile.path())).unwrap(); - assert_eq!(&config.killswitches.0, &expected,); + assert_eq!(config.killswitches.as_slice(), &expected); Ok(()) }); diff --git a/objectstore-server/src/extractors/id.rs b/objectstore-server/src/extractors/id.rs index 98c3b032..3e88618e 100644 --- a/objectstore-server/src/extractors/id.rs +++ b/objectstore-server/src/extractors/id.rs @@ -373,11 +373,10 @@ mod tests { #[tokio::test] async fn extract_object_id_killswitched() { let config = Config { - killswitches: Killswitches(vec![Killswitch { + killswitches: Killswitches::new(vec![Killswitch { usecase: Some("blocked".into()), scopes: BTreeMap::new(), service: None, - service_matcher: Default::default(), }]), ..Config::default() }; @@ -402,11 +401,10 @@ mod tests { #[tokio::test] async fn extract_object_context_killswitched() { let config = Config { - killswitches: Killswitches(vec![Killswitch { + killswitches: Killswitches::new(vec![Killswitch { usecase: Some("blocked".into()), scopes: BTreeMap::new(), service: None, - service_matcher: Default::default(), }]), ..Config::default() }; @@ -433,11 +431,10 @@ mod tests { #[tokio::test] async fn extract_object_id_killswitched_with_service() { let config = Config { - killswitches: Killswitches(vec![Killswitch { + killswitches: Killswitches::new(vec![Killswitch { usecase: None, scopes: BTreeMap::new(), service: Some("test-*".into()), - service_matcher: Default::default(), }]), ..Config::default() }; diff --git a/objectstore-server/src/killswitches.rs b/objectstore-server/src/killswitches.rs index b588f2c4..54a169e7 100644 --- a/objectstore-server/src/killswitches.rs +++ b/objectstore-server/src/killswitches.rs @@ -7,23 +7,58 @@ //! Killswitches are part of [`crate::config::Config`] and take effect on the next request after //! a configuration reload — no server restart is required. -use std::collections::BTreeMap; -use std::sync::OnceLock; +use std::cell::RefCell; +use std::num::NonZeroUsize; use globset::{Glob, GlobMatcher}; +use lru::LruCache; +use objectstore_options::Options; use objectstore_service::id::ObjectContext; -use serde::{Deserialize, Serialize}; +use thread_local::ThreadLocal; + +pub use objectstore_options::Killswitch; /// A list of killswitches that may disable access to certain object contexts. -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct Killswitches(pub Vec); +/// +/// This serializes and deserializes directly from a list of killswitches. +#[derive(Debug, Default)] +pub struct Killswitches { + /// The actual list of killswitches. + switches: Vec, + + /// Glob cache for fast matching of killswitches. + cache: ThreadLocal>>>, +} impl Killswitches { + /// Creates a new `Killswitches` instance with the given killswitches. + pub fn new(killswitches: Vec) -> Self { + Self { + switches: killswitches, + cache: ThreadLocal::new(), + } + } + /// Returns `true` if any of the contained killswitches matches the given context. /// /// On match, emits a `server.request.killswitched` metric counter and a `warn!` log. pub fn matches(&self, context: &ObjectContext, service: Option<&str>) -> bool { - let Some(killswitch) = self.find(context, service) else { + let options = Options::get(); + + let mut cache = self + .cache + .get_or(|| RefCell::new(LruCache::new(NonZeroUsize::MIN))) + .borrow_mut(); + + let total_count = self.switches.len() + options.killswitches().len(); + cache.resize(total_count.try_into().unwrap_or(NonZeroUsize::MIN)); + + let Some(killswitch) = self + .switches + .iter() + .chain(options.killswitches()) + .find(|s| matches(s, context, service, &mut cache)) + else { return false; }; @@ -32,110 +67,88 @@ impl Killswitches { true } - /// Returns the first killswitch that matches the given context, or `None` if none match. - pub fn find<'a>( - &'a self, - context: &ObjectContext, - service: Option<&str>, - ) -> Option<&'a Killswitch> { - self.0.iter().find(|s| s.matches(context, service)) + /// Returns a slice of the contained killswitches. + pub fn as_slice(&self) -> &[Killswitch] { + &self.switches } } -/// A killswitch that may disable access to certain object contexts. -/// -/// Note that at least one of the fields should be set, or else the killswitch will match all -/// contexts and discard all requests. -#[derive(Debug, Deserialize, Serialize)] -pub struct Killswitch { - /// Optional usecase to match. - /// - /// If `None`, matches any usecase. - #[serde(default)] - pub usecase: Option, - - /// Scopes to match. - /// - /// If empty, matches any scopes. Additional scopes in the context are ignored, so a killswitch - /// matches if all of the specified scopes are present in the request with matching values. - #[serde(default)] - pub scopes: BTreeMap, - - /// Optional service glob pattern to match. - /// - /// If `None`, matches any service (or absence of service header). - /// If specified, the request must have a matching `x-downstream-service` header. - #[serde(default)] - pub service: Option, - - /// Compiled glob matcher for the service pattern. - /// - /// This is lazily compiled on first use to avoid unwrap() calls and gracefully handle - /// invalid patterns by treating them as non-matches. - #[serde(skip)] - #[serde(default)] - pub service_matcher: OnceLock>, +impl serde::Serialize for Killswitches { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.switches.serialize(serializer) + } } -impl PartialEq for Killswitch { - fn eq(&self, other: &Self) -> bool { - self.usecase == other.usecase - && self.scopes == other.scopes - && self.service == other.service - // Skip service_matcher in comparison since it's derived from service +impl<'de> serde::Deserialize<'de> for Killswitches { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(Self::new(Vec::deserialize(deserializer)?)) } } -impl Killswitch { - /// Returns `true` if this killswitch matches the given context and service. - pub fn matches(&self, context: &ObjectContext, service: Option<&str>) -> bool { - if let Some(ref switch_usecase) = self.usecase - && switch_usecase != &context.usecase - { - return false; - } +/// Returns `true` if this killswitch matches the given context and service. +fn matches( + switch: &Killswitch, + context: &ObjectContext, + service: Option<&str>, + cache: &mut LruCache>, +) -> bool { + if let Some(ref switch_usecase) = switch.usecase + && switch_usecase != &context.usecase + { + return false; + } - for (scope_name, scope_value) in &self.scopes { - match context.scopes.get_value(scope_name) { - Some(value) if value == scope_value => (), - _ => return false, - } + for (scope_name, scope_value) in &switch.scopes { + match context.scopes.get_value(scope_name) { + Some(value) if value == scope_value => (), + _ => return false, } + } - // Check service pattern if specified - if let Some(ref pattern) = self.service { - // If pattern is specified but no service header present, don't match - let Some(service_value) = service else { - return false; - }; - - let matcher = self - .service_matcher - .get_or_init(|| Glob::new(pattern).ok().map(|g| g.compile_matcher())); - - match matcher { - Some(m) if m.is_match(service_value) => (), - _ => return false, - } - } + // Check service pattern if specified + if let Some(ref pattern) = switch.service { + // If pattern is specified but no service header present, don't match + let Some(service_value) = service else { + return false; + }; - true + let lookup = cache.get_or_insert_ref(pattern, || { + Glob::new(pattern).ok().map(|g| g.compile_matcher()) + }); + + match lookup { + Some(m) if m.is_match(service_value) => (), + _ => return false, + } } + + true } #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use objectstore_types::scope::{Scope, Scopes}; use super::*; + fn cache() -> LruCache> { + LruCache::new(NonZeroUsize::MIN) + } + #[test] fn test_matches_empty() { let switch = Killswitch { usecase: None, scopes: BTreeMap::new(), service: None, - service_matcher: OnceLock::new(), }; let context = ObjectContext { @@ -143,7 +156,7 @@ mod tests { scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; - assert!(switch.matches(&context, None)); + assert!(matches(&switch, &context, None, &mut cache())); } #[test] @@ -152,21 +165,25 @@ mod tests { usecase: Some("test".to_string()), scopes: BTreeMap::new(), service: None, - service_matcher: OnceLock::new(), }; let context = ObjectContext { usecase: "test".to_string(), scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; - assert!(switch.matches(&context, Some("anyservice"))); + assert!(matches(&switch, &context, Some("anyservice"), &mut cache())); // usecase differs let context = ObjectContext { usecase: "other".to_string(), scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; - assert!(!switch.matches(&context, Some("anyservice"))); + assert!(!matches( + &switch, + &context, + Some("anyservice"), + &mut cache() + )); } #[test] @@ -178,7 +195,6 @@ mod tests { ("project".to_string(), "456".to_string()), ]), service: None, - service_matcher: OnceLock::new(), }; // match, ignoring extra scope @@ -190,7 +206,7 @@ mod tests { Scope::create("extra", "789").unwrap(), ]), }; - assert!(switch.matches(&context, Some("anyservice"))); + assert!(matches(&switch, &context, Some("anyservice"), &mut cache())); // project differs let context = ObjectContext { @@ -200,14 +216,24 @@ mod tests { Scope::create("project", "999").unwrap(), ]), }; - assert!(!switch.matches(&context, Some("anyservice"))); + assert!(!matches( + &switch, + &context, + Some("anyservice"), + &mut cache() + )); // missing project let context = ObjectContext { usecase: "any".to_string(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; - assert!(!switch.matches(&context, Some("anyservice"))); + assert!(!matches( + &switch, + &context, + Some("anyservice"), + &mut cache() + )); } #[test] @@ -216,7 +242,6 @@ mod tests { usecase: Some("test".to_string()), scopes: BTreeMap::from([("org".to_string(), "123".to_string())]), service: Some("myservice-*".to_string()), - service_matcher: OnceLock::new(), }; // match with all filters @@ -224,35 +249,55 @@ mod tests { usecase: "test".to_string(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; - assert!(switch.matches(&context, Some("myservice-prod"))); + assert!(matches( + &switch, + &context, + Some("myservice-prod"), + &mut cache() + )); // usecase differs let context = ObjectContext { usecase: "other".to_string(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; - assert!(!switch.matches(&context, Some("myservice-prod"))); + assert!(!matches( + &switch, + &context, + Some("myservice-prod"), + &mut cache() + )); // scope differs let context = ObjectContext { usecase: "test".to_string(), scopes: Scopes::from_iter([Scope::create("org", "999").unwrap()]), }; - assert!(!switch.matches(&context, Some("myservice-prod"))); + assert!(!matches( + &switch, + &context, + Some("myservice-prod"), + &mut cache() + )); // service differs let context = ObjectContext { usecase: "test".to_string(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; - assert!(!switch.matches(&context, Some("otherservice"))); + assert!(!matches( + &switch, + &context, + Some("otherservice"), + &mut cache() + )); // missing service header let context = ObjectContext { usecase: "test".to_string(), scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]), }; - assert!(!switch.matches(&context, None)); + assert!(!matches(&switch, &context, None, &mut cache())); } #[test] @@ -261,7 +306,6 @@ mod tests { usecase: None, scopes: BTreeMap::new(), service: Some("myservice".to_string()), - service_matcher: OnceLock::new(), }; let context = ObjectContext { @@ -269,9 +313,14 @@ mod tests { scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]), }; - assert!(switch.matches(&context, Some("myservice"))); - assert!(!switch.matches(&context, Some("otherservice"))); - assert!(!switch.matches(&context, None)); + assert!(matches(&switch, &context, Some("myservice"), &mut cache())); + assert!(!matches( + &switch, + &context, + Some("otherservice"), + &mut cache() + )); + assert!(!matches(&switch, &context, None, &mut cache())); } #[test] @@ -280,7 +329,6 @@ mod tests { usecase: None, scopes: BTreeMap::new(), service: Some("myservice-*".to_string()), - service_matcher: OnceLock::new(), }; let context = ObjectContext { @@ -289,16 +337,41 @@ mod tests { }; // Matches with glob pattern - assert!(switch.matches(&context, Some("myservice-prod"))); - assert!(switch.matches(&context, Some("myservice-dev"))); - assert!(switch.matches(&context, Some("myservice-staging"))); + assert!(matches( + &switch, + &context, + Some("myservice-prod"), + &mut cache() + )); + assert!(matches( + &switch, + &context, + Some("myservice-dev"), + &mut cache() + )); + assert!(matches( + &switch, + &context, + Some("myservice-staging"), + &mut cache() + )); // Doesn't match different service - assert!(!switch.matches(&context, Some("otherservice"))); - assert!(!switch.matches(&context, Some("otherservice-prod"))); + assert!(!matches( + &switch, + &context, + Some("otherservice"), + &mut cache() + )); + assert!(!matches( + &switch, + &context, + Some("otherservice-prod"), + &mut cache() + )); // Doesn't match prefix without separator - assert!(!switch.matches(&context, Some("myservice"))); + assert!(!matches(&switch, &context, Some("myservice"), &mut cache())); } #[test] @@ -307,7 +380,6 @@ mod tests { usecase: None, scopes: BTreeMap::new(), service: Some("[invalid".to_string()), // Invalid glob pattern - service_matcher: OnceLock::new(), }; let context = ObjectContext { @@ -316,7 +388,12 @@ mod tests { }; // Invalid pattern should not match anything - assert!(!switch.matches(&context, Some("anyservice"))); - assert!(!switch.matches(&context, Some("[invalid"))); + assert!(!matches( + &switch, + &context, + Some("anyservice"), + &mut cache() + )); + assert!(!matches(&switch, &context, Some("[invalid"), &mut cache())); } } diff --git a/objectstore-server/tests/limits.rs b/objectstore-server/tests/limits.rs index f0b9d7e0..752687b0 100644 --- a/objectstore-server/tests/limits.rs +++ b/objectstore-server/tests/limits.rs @@ -66,11 +66,10 @@ async fn test_web_concurrency_limit() -> Result<()> { #[tokio::test] async fn test_killswitches() -> Result<()> { let server = TestServer::with_config(Config { - killswitches: Killswitches(vec![Killswitch { + killswitches: Killswitches::new(vec![Killswitch { usecase: Some("blocked".to_string()), scopes: BTreeMap::from_iter([("org".to_string(), "42".to_string())]), service: Some("test-*".to_string()), - service_matcher: Default::default(), }]), auth: AuthZ { enforce: false, diff --git a/objectstore-test/Cargo.toml b/objectstore-test/Cargo.toml index c97bcb33..6924c38b 100644 --- a/objectstore-test/Cargo.toml +++ b/objectstore-test/Cargo.toml @@ -10,6 +10,7 @@ edition = "2024" publish = false [dependencies] +objectstore-options = { workspace = true, features = ["testing"] } objectstore-server = { workspace = true } objectstore-types = { workspace = true } tempfile = { workspace = true } diff --git a/sentry-options/schemas/objectstore/schema.json b/sentry-options/schemas/objectstore/schema.json index 44ca5848..1c8e6892 100644 --- a/sentry-options/schemas/objectstore/schema.json +++ b/sentry-options/schemas/objectstore/schema.json @@ -8,23 +8,16 @@ "description": "Runtime killswitches for disabling access to specific object contexts. Each entry matches requests by usecase, scope values, and optionally a downstream service glob pattern. When any killswitch matches, the request is rejected without forwarding to the storage backend.", "items": { "type": "object", - "properties": { - "usecase": { - "type": "string", - "optional": true - }, - "service": { - "type": "string", - "optional": true - }, - "scopes": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "optional": true - } - } + "properties": +{ + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "optional": true + } +} } } } From b5046545d98137f6b10bae710ef79d31a5707a9f Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Thu, 9 Apr 2026 13:17:50 +0200 Subject: [PATCH 03/14] fix: Comment --- objectstore-options/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index 9831449d..1ceafce7 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -101,8 +101,7 @@ pub struct Killswitch { /// Initializes the global options instance and spawns a background refresh task. /// -/// If `base_dir` is provided, values are loaded from `{base_dir}/values/`. Otherwise, the -/// standard fallback chain is used: +/// The standard fallback chain is used: /// /// 1. `SENTRY_OPTIONS_DIR` environment variable /// 2. `/etc/sentry-options` (if it exists) From fd488acdab7189798f2f2f4ac3ad998c473db57d Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Thu, 9 Apr 2026 14:57:16 +0200 Subject: [PATCH 04/14] fix(options): Gate refresh spawn on successful OnceLock init; remove stale comment Only spawn the background refresh task when OPTIONS.set() succeeds, so concurrent callers cannot produce multiple refresh tasks and filesystem watchers. Also remove a low-value inline comment in killswitches.rs. --- objectstore-options/src/lib.rs | 11 +++++++---- objectstore-server/src/killswitches.rs | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index 1ceafce7..2a91e5c7 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -113,11 +113,14 @@ pub struct Killswitch { /// Must be called from within a Tokio runtime. pub fn init() -> Result<(), Error> { if OPTIONS.get().is_none() { - // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the application - // will not silently run with defaults or fail later when options are accessed. + // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the + // application will not silently run with defaults or fail later when options are accessed. let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)])?; - let _ = OPTIONS.set(ArcSwap::from_pointee(Options::deserialize(&inner)?)); - tokio::spawn(refresh(inner)); + let initial = Options::deserialize(&inner)?; + + if OPTIONS.set(ArcSwap::from_pointee(initial)).is_ok() { + tokio::spawn(refresh(inner)); + } } Ok(()) diff --git a/objectstore-server/src/killswitches.rs b/objectstore-server/src/killswitches.rs index 54a169e7..27796fe4 100644 --- a/objectstore-server/src/killswitches.rs +++ b/objectstore-server/src/killswitches.rs @@ -111,7 +111,6 @@ fn matches( } } - // Check service pattern if specified if let Some(ref pattern) = switch.service { // If pattern is specified but no service header present, don't match let Some(service_value) = service else { From 682ea67af330c3e94c205f9419d17973116f5cf6 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Thu, 9 Apr 2026 16:23:22 +0200 Subject: [PATCH 05/14] fix(options): Restore usecase and service fields in killswitch schema --- .../schemas/objectstore/schema.json | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/sentry-options/schemas/objectstore/schema.json b/sentry-options/schemas/objectstore/schema.json index 1c8e6892..44ca5848 100644 --- a/sentry-options/schemas/objectstore/schema.json +++ b/sentry-options/schemas/objectstore/schema.json @@ -8,16 +8,23 @@ "description": "Runtime killswitches for disabling access to specific object contexts. Each entry matches requests by usecase, scope values, and optionally a downstream service glob pattern. When any killswitch matches, the request is rejected without forwarding to the storage backend.", "items": { "type": "object", - "properties": -{ - "scopes": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "optional": true - } -} + "properties": { + "usecase": { + "type": "string", + "optional": true + }, + "service": { + "type": "string", + "optional": true + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "optional": true + } + } } } } From dca114b0cddf6e4d159e1313908c20ff01506a2b Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Fri, 3 Jul 2026 14:30:37 +0200 Subject: [PATCH 06/14] feat(options): Wire refresh to sentry-options propagation callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the tokio-spawned polling loop with a propagation callback registered via InitBuilder, and switch to the feat/expose-builder-build branch of sentry-options. Bump arc-swap and lru accordingly. WIP: refresh() still references the built `inner` instance, which is no longer in scope — the callback needs a path to reach it. Does not compile. --- Cargo.lock | 110 +++++++++------------------------ Cargo.toml | 12 ++-- objectstore-options/Cargo.toml | 2 +- objectstore-options/src/lib.rs | 59 ++++++++++-------- 4 files changed, 69 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d33ed667..85c8e082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1499,6 +1499,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -2227,11 +2232,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2752,7 +2757,7 @@ dependencies = [ "objectstore-types", "percent-encoding", "reqwest 0.13.4", - "sentry-core 0.48.3", + "sentry-core", "serde", "tempfile", "thiserror", @@ -2769,7 +2774,7 @@ version = "0.1.0" dependencies = [ "anyhow", "console", - "sentry 0.48.3", + "sentry", "serde", "tracing", "tracing-subscriber", @@ -2835,7 +2840,7 @@ dependencies = [ "reqwest 0.13.4", "rustls", "secrecy", - "sentry 0.48.3", + "sentry", "serde", "serde_json", "stresstest", @@ -2870,7 +2875,7 @@ dependencies = [ "quick-xml", "regex", "reqwest 0.13.4", - "sentry 0.48.3", + "sentry", "serde", "serde_json", "tempfile", @@ -2950,15 +2955,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.6.1+3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.117" @@ -2967,7 +2963,6 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -3720,20 +3715,16 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", - "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tower", "tower-http 0.6.11", "tower-service", @@ -4077,21 +4068,6 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "sentry" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92d893ba7469d361a6958522fa440e4e2bc8bf4c5803cd1bf40b9af63f8f9a8" -dependencies = [ - "cfg_aliases", - "httpdate", - "native-tls", - "reqwest 0.12.28", - "sentry-core 0.46.2", - "tokio", - "ureq", -] - [[package]] name = "sentry" version = "0.48.3" @@ -4105,7 +4081,7 @@ dependencies = [ "sentry-actix", "sentry-backtrace", "sentry-contexts", - "sentry-core 0.48.3", + "sentry-core", "sentry-debug-images", "sentry-log", "sentry-panic", @@ -4125,7 +4101,7 @@ dependencies = [ "actix-web", "bytes", "futures-util", - "sentry-core 0.48.3", + "sentry-core", ] [[package]] @@ -4136,7 +4112,7 @@ checksum = "134f552b6b147f77aeee46ece875d67a8bfc1d56fe3860d78446ed4c25bb5f9f" dependencies = [ "backtrace", "regex", - "sentry-core 0.48.3", + "sentry-core", ] [[package]] @@ -4149,23 +4125,10 @@ dependencies = [ "libc", "os_info", "rustc_version", - "sentry-core 0.48.3", + "sentry-core", "uname", ] -[[package]] -name = "sentry-core" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b1e7ca40f965db239da279bf278d87b7407469b98835f27f0c8e59ed189b06" -dependencies = [ - "rand 0.9.4", - "sentry-types 0.46.2", - "serde", - "serde_json", - "url", -] - [[package]] name = "sentry-core" version = "0.48.3" @@ -4173,7 +4136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cff96247f4dc36867511d108709e703eb354143e7893319d763d2dd1edb4f48" dependencies = [ "rand 0.9.4", - "sentry-types 0.48.3", + "sentry-types", "serde", "serde_json", "url", @@ -4186,7 +4149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b603a51b083141062a142d73e36391b14244b4bd0bfe28bcd80e8327bb1d7698" dependencies = [ "findshlibs", - "sentry-core 0.48.3", + "sentry-core", ] [[package]] @@ -4197,14 +4160,15 @@ checksum = "74df4d09a38fd59da258269ffd7f344bd308470117d68eda5a95b4fbd65683d0" dependencies = [ "bitflags", "log", - "sentry-core 0.48.3", + "sentry-core", ] [[package]] name = "sentry-options" -version = "1.0.5" -source = "git+https://github.com/getsentry/sentry-options?branch=feat%2Fadditional-properties-map-schema#0657a00934f85841e1609a6187282d8b05b59727" +version = "1.2.0" +source = "git+https://github.com/getsentry/sentry-options?branch=feat%2Fexpose-builder-build#1594fe5ff3365e7dcca2cc1dccf17d182c3ce142" dependencies = [ + "arc-swap", "num", "sentry-options-validation", "serde_json", @@ -4214,14 +4178,13 @@ dependencies = [ [[package]] name = "sentry-options-validation" -version = "1.0.5" -source = "git+https://github.com/getsentry/sentry-options?branch=feat%2Fadditional-properties-map-schema#0657a00934f85841e1609a6187282d8b05b59727" +version = "1.2.0" +source = "git+https://github.com/getsentry/sentry-options?branch=feat%2Fexpose-builder-build#1594fe5ff3365e7dcca2cc1dccf17d182c3ce142" dependencies = [ "anyhow", + "arc-swap", "chrono", "jsonschema", - "openssl", - "sentry 0.46.2", "serde", "serde_json", "tempfile", @@ -4235,7 +4198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61de3f4a1fc77d4c57e8bacd8ef064c26aaa88ea5b2541a761d37c7c3703b9a9" dependencies = [ "sentry-backtrace", - "sentry-core 0.48.3", + "sentry-core", ] [[package]] @@ -4247,7 +4210,7 @@ dependencies = [ "axum", "http 1.4.2", "pin-project", - "sentry-core 0.48.3", + "sentry-core", "tower-layer", "tower-service", "url", @@ -4261,28 +4224,11 @@ checksum = "bc59b27dae3bb495e37e6d62d252214840a2e7e1875531ee9fa2953df8985537" dependencies = [ "bitflags", "sentry-backtrace", - "sentry-core 0.48.3", + "sentry-core", "tracing-core", "tracing-subscriber", ] -[[package]] -name = "sentry-types" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567711f01f86a842057e1fc17779eba33a336004227e1a1e7e6cc2599e22e259" -dependencies = [ - "debugid", - "hex", - "rand 0.9.4", - "serde", - "serde_json", - "thiserror", - "time", - "url", - "uuid", -] - [[package]] name = "sentry-types" version = "0.48.3" diff --git a/Cargo.toml b/Cargo.toml index e4a7ce21..cefa432b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ objectstore-types = { path = "objectstore-types", version = "0.1.12" } stresstest = { path = "stresstest" } anyhow = "1.0.102" -arc-swap = "1.7.1" +arc-swap = "1.9.2" argh = "0.1.19" async-compression = "0.4.42" async-stream = "0.3.6" @@ -58,7 +58,7 @@ indicatif = "0.18.4" infer = { version = "0.19.0", default-features = false } insta = "1.48.0" jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } -lru = "0.16.3" +lru = "0.18.0" mediatype = "0.21.0" metrics = "0.24.6" metrics-exporter-dogstatsd = "0.9.8" @@ -75,7 +75,8 @@ regex = "1.12.4" reqwest = { version = "0.13.4", default-features = false } rustls = { version = "0.23.40", default-features = false } secrecy = "0.10.3" -sentry = { version = "0.48.3" } +sentry = "0.48.3" +sentry-options = "1.2.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" serde_yaml = "0.9.34-deprecated" @@ -83,7 +84,7 @@ sketches-ddsketch = "0.3.1" tempfile = "3.27.0" thiserror = "2.0.18" thread_local = "1.1.9" -jemalloc_pprof = { version = "0.9.0" } +jemalloc_pprof = "0.9.0" tikv-jemallocator = { version = "0.7.0", features = ["background_threads", "override_allocator_on_supported_platforms"] } tikv-jemalloc-ctl = { version = "0.7.0", features = ["stats"] } tokio = "1.52.3" @@ -100,5 +101,4 @@ zstd = "0.13.3" zstd-safe = "7.2.4" [patch.crates-io] -sentry-options = { git = "https://github.com/getsentry/sentry-options", branch = "feat/additional-properties-map-schema" } -sentry-options-validation = { git = "https://github.com/getsentry/sentry-options", branch = "feat/additional-properties-map-schema" } +sentry-options = { git = "https://github.com/getsentry/sentry-options", branch = "feat/expose-builder-build" } diff --git a/objectstore-options/Cargo.toml b/objectstore-options/Cargo.toml index 0dc7742d..6fe62941 100644 --- a/objectstore-options/Cargo.toml +++ b/objectstore-options/Cargo.toml @@ -11,7 +11,7 @@ publish = false [dependencies] arc-swap = { workspace = true } -sentry-options = "1.0.5" +sentry-options = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index 37e33121..d796b98c 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -7,11 +7,14 @@ use std::sync::{Arc, OnceLock}; use std::time::Duration; use arc_swap::ArcSwap; +use sentry_options::Options as Inner; use serde::{Deserialize, Serialize}; const NAMESPACE: &str = "objectstore"; const SCHEMA: &str = include_str!("../../sentry-options/schemas/objectstore/schema.json"); -const REFRESH_INTERVAL: Duration = Duration::from_secs(5); + +/// Global instance of the raw sentry options. +static INNER: OnceLock = OnceLock::new(); /// Global instance of the options, initialized by [`init`] and accessed via [`Options::get`]. static OPTIONS: OnceLock> = OnceLock::new(); @@ -55,8 +58,11 @@ impl Options { /// [`init`] does not need to be called. Use [`override_options`] to test non-default values. #[cfg(feature = "testing")] pub fn get() -> Arc { - let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)]) + let inner = Inner::builder() + .with_schemas(&[(NAMESPACE, SCHEMA)]) + .build() .expect("options schema should be valid"); + Arc::new(Self::deserialize(&inner).expect("failed to deserialize options")) } @@ -115,37 +121,40 @@ pub struct Killswitch { /// /// Must be called from within a Tokio runtime. pub fn init() -> Result<(), Error> { - if OPTIONS.get().is_none() { - // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the - // application will not silently run with defaults or fail later when options are accessed. - let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)])?; - let initial = Options::deserialize(&inner)?; - - if OPTIONS.set(ArcSwap::from_pointee(initial)).is_ok() { - tokio::spawn(refresh(inner)); - } + if OPTIONS.get().is_some() { + return Err(sentry_options::OptionsError::AlreadyInitialized.into()); } - Ok(()) + // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the + // application will not silently run with defaults or fail later when options are accessed. + let inner = Inner::builder() + .with_schemas(&[(NAMESPACE, SCHEMA)]) + .with_callback(refresh) + .build()?; + + OPTIONS + .set(ArcSwap::from_pointee(Options::deserialize(&inner)?)) + .map_err(|_| sentry_options::OptionsError::AlreadyInitialized)?; + + INNER + .set(inner) + .map_err(|_| sentry_options::OptionsError::AlreadyInitialized.into()) } /// Periodically reloads options from disk and atomically swaps in the new snapshot. -async fn refresh(inner: sentry_options::Options) { - let Some(snapshot) = OPTIONS.get() else { +fn refresh(namespace: &str, _delay: f64) { + if namespace != NAMESPACE { return; - }; - - let mut interval = tokio::time::interval(REFRESH_INTERVAL); - interval.tick().await; // consume the immediate first tick + } - loop { - interval.tick().await; + let (Some(snapshot), Some(inner)) = (OPTIONS.get(), INNER.get()) else { + return; + }; - match Options::deserialize(&inner) { - Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), - Err(ref err) => { - objectstore_log::error!(!!err, "Failed to refresh objectstore options") - } + match Options::deserialize(inner) { + Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), + Err(ref err) => { + objectstore_log::error!(!!err, "Failed to refresh objectstore options") } } } From 5ff30d6614fdfb9c057d13b6eb4e1b8360895a8e Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Mon, 6 Jul 2026 10:10:24 +0200 Subject: [PATCH 07/14] fix: Outdated docs --- objectstore-options/src/lib.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index d796b98c..54e033af 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -4,7 +4,6 @@ use std::collections::BTreeMap; use std::sync::{Arc, OnceLock}; -use std::time::Duration; use arc_swap::ArcSwap; use sentry_options::Options as Inner; @@ -117,9 +116,7 @@ pub struct Killswitch { /// 3. `sentry-options/` relative to the current working directory /// 4. Schema defaults (if no values file is present) /// -/// Idempotent: if already initialized, returns `Ok(())` without re-loading. -/// -/// Must be called from within a Tokio runtime. +/// Returns an `Err(AlreadyInitialized)` if called more than once. pub fn init() -> Result<(), Error> { if OPTIONS.get().is_some() { return Err(sentry_options::OptionsError::AlreadyInitialized.into()); From 57d256dd238b4173fba89164d83562d17a2107db Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Mon, 6 Jul 2026 10:31:06 +0200 Subject: [PATCH 08/14] test(options): Require testing feature for tests --- objectstore-options/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index 54e033af..c16edb89 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -177,6 +177,10 @@ pub fn override_options( mod tests { use super::*; + // Required for schema validation. + #[cfg(not(feature = "testing"))] + compile_error!("tests require the `testing` feature: run with `--features testing`"); + #[test] fn schema_is_valid() { let _ = Options::get(); From 2ccbbe64dab293a8035c32ce96ef02c8da5081b3 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Mon, 6 Jul 2026 10:34:59 +0200 Subject: [PATCH 09/14] build(options): Remove unused tokio dependency --- Cargo.lock | 1 - objectstore-options/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85c8e082..df828032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2801,7 +2801,6 @@ dependencies = [ "serde", "serde_json", "thiserror", - "tokio", ] [[package]] diff --git a/objectstore-options/Cargo.toml b/objectstore-options/Cargo.toml index 6fe62941..680cb738 100644 --- a/objectstore-options/Cargo.toml +++ b/objectstore-options/Cargo.toml @@ -16,7 +16,6 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } objectstore-log = { workspace = true } -tokio = { workspace = true, features = ["time"] } [features] testing = [] From ab53b6c6fa5c2c79122d20885f541a4d544e4f50 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Thu, 9 Apr 2026 16:10:26 +0200 Subject: [PATCH 10/14] feat(options): Add SentryOptions derive macro and typed-options crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces two new crates that replace the hand-written boilerplate in objectstore-options: - `objectstore-typed-options` — defines the `SentryOptions` trait, the shared `Error` type, and the generic background `refresh` loop. All dependencies consumed by generated code (arc-swap, serde, serde_json, sentry-options, tokio) are re-exported as hidden items so consumers do not need to declare them directly. Exposes a `derive` feature that re-exports the proc macro. - `objectstore-typed-options-derive` — provides `#[derive(SentryOptions)]`, which generates the global OnceLock singleton, trait impl, `get()`, `init()`, and (under the `testing` feature) `override_with()`. All generated paths are fully qualified through `objectstore_typed_options`, so the derive can be used from any crate. `objectstore-options` is reduced to the concrete struct definitions: - Its dependency list shrinks to `objectstore-typed-options` (with the `derive` feature) and `serde`. - The `init()` free function is gone; callers use `Options::init()`. --- Cargo.lock | 28 ++- Cargo.toml | 2 + objectstore-options/Cargo.toml | 6 +- objectstore-options/src/lib.rs | 130 +---------- objectstore-server/src/cli.rs | 2 +- objectstore-typed-options-derive/Cargo.toml | 18 ++ objectstore-typed-options-derive/src/lib.rs | 240 ++++++++++++++++++++ objectstore-typed-options/Cargo.toml | 22 ++ objectstore-typed-options/src/lib.rs | 149 ++++++++++++ 9 files changed, 464 insertions(+), 133 deletions(-) create mode 100644 objectstore-typed-options-derive/Cargo.toml create mode 100644 objectstore-typed-options-derive/src/lib.rs create mode 100644 objectstore-typed-options/Cargo.toml create mode 100644 objectstore-typed-options/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index df828032..a1c3e5e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2795,12 +2795,8 @@ dependencies = [ name = "objectstore-options" version = "0.1.0" dependencies = [ - "arc-swap", - "objectstore-log", - "sentry-options", + "objectstore-typed-options", "serde", - "serde_json", - "thiserror", ] [[package]] @@ -2899,6 +2895,28 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "objectstore-typed-options" +version = "0.1.0" +dependencies = [ + "arc-swap", + "objectstore-log", + "objectstore-typed-options-derive", + "sentry-options", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "objectstore-typed-options-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "objectstore-types" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index cefa432b..b9ce6f2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ objectstore-log = { path = "objectstore-log" } objectstore-metrics = { path = "objectstore-metrics" } objectstore-options = { path = "objectstore-options" } objectstore-server = { path = "objectstore-server" } +objectstore-typed-options = { path = "objectstore-typed-options" } +objectstore-typed-options-derive = { path = "objectstore-typed-options-derive" } objectstore-service = { path = "objectstore-service" } objectstore-test = { path = "objectstore-test" } objectstore-types = { path = "objectstore-types", version = "0.1.12" } diff --git a/objectstore-options/Cargo.toml b/objectstore-options/Cargo.toml index 680cb738..68d93622 100644 --- a/objectstore-options/Cargo.toml +++ b/objectstore-options/Cargo.toml @@ -10,12 +10,8 @@ edition = "2024" publish = false [dependencies] -arc-swap = { workspace = true } -sentry-options = { workspace = true } +objectstore-typed-options = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } -objectstore-log = { workspace = true } [features] testing = [] diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index c16edb89..d9fff60f 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -1,76 +1,28 @@ //! Runtime options for Objectstore, backed by [`sentry-options`]. //! +//! See the [`Options`] struct for details and usage instructions. +//! //! [`sentry-options`]: https://crates.io/crates/sentry-options use std::collections::BTreeMap; -use std::sync::{Arc, OnceLock}; -use arc_swap::ArcSwap; -use sentry_options::Options as Inner; +use objectstore_typed_options::SentryOptions; use serde::{Deserialize, Serialize}; -const NAMESPACE: &str = "objectstore"; -const SCHEMA: &str = include_str!("../../sentry-options/schemas/objectstore/schema.json"); - -/// Global instance of the raw sentry options. -static INNER: OnceLock = OnceLock::new(); - -/// Global instance of the options, initialized by [`init`] and accessed via [`Options::get`]. -static OPTIONS: OnceLock> = OnceLock::new(); - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error(transparent)] - Options(#[from] sentry_options::OptionsError), - #[error("failed to deserialize option value")] - Deserialize(#[from] serde_json::Error), -} +pub use objectstore_typed_options::Error; /// Runtime options for Objectstore, loaded from sentry-options. /// /// Obtain a snapshot of the current options via [`Options::get`]. Before calling `get`, -/// the global instance must be initialized with [`init`]. -#[derive(Debug)] +/// the global instance must be initialized with [`Options::init`]. +#[derive(Debug, SentryOptions)] +#[sentry_options(namespace = "objectstore", path = "../../sentry-options")] pub struct Options { + /// Active killswitches that may disable access to specific object contexts. killswitches: Vec, } impl Options { - /// Returns a snapshot of the current options. - /// - /// The returned [`Arc`] holds the most recently loaded values. Callers may hold it across - /// await points without blocking updates — a new snapshot is swapped in atomically by the - /// background refresh task without invalidating existing references. - /// - /// # Panics - /// - /// Panics if [`init`] has not been called. - #[cfg(not(feature = "testing"))] - pub fn get() -> Arc { - OPTIONS.get().expect("options not initialized").load_full() - } - - /// Returns a snapshot of the current options, deserializing fresh from schema defaults. - /// - /// In test builds this bypasses the global instance and reads directly from the schema, so - /// [`init`] does not need to be called. Use [`override_options`] to test non-default values. - #[cfg(feature = "testing")] - pub fn get() -> Arc { - let inner = Inner::builder() - .with_schemas(&[(NAMESPACE, SCHEMA)]) - .build() - .expect("options schema should be valid"); - - Arc::new(Self::deserialize(&inner).expect("failed to deserialize options")) - } - - fn deserialize(options: &sentry_options::Options) -> Result { - Ok(Self { - killswitches: Deserialize::deserialize(options.get(NAMESPACE, "killswitches")?)?, - }) - } - /// Returns the list of active killswitches. pub fn killswitches(&self) -> &[Killswitch] { &self.killswitches @@ -107,72 +59,6 @@ pub struct Killswitch { pub service: Option, } -/// Initializes the global options instance and spawns a background refresh task. -/// -/// The standard fallback chain is used: -/// -/// 1. `SENTRY_OPTIONS_DIR` environment variable -/// 2. `/etc/sentry-options` (if it exists) -/// 3. `sentry-options/` relative to the current working directory -/// 4. Schema defaults (if no values file is present) -/// -/// Returns an `Err(AlreadyInitialized)` if called more than once. -pub fn init() -> Result<(), Error> { - if OPTIONS.get().is_some() { - return Err(sentry_options::OptionsError::AlreadyInitialized.into()); - } - - // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the - // application will not silently run with defaults or fail later when options are accessed. - let inner = Inner::builder() - .with_schemas(&[(NAMESPACE, SCHEMA)]) - .with_callback(refresh) - .build()?; - - OPTIONS - .set(ArcSwap::from_pointee(Options::deserialize(&inner)?)) - .map_err(|_| sentry_options::OptionsError::AlreadyInitialized)?; - - INNER - .set(inner) - .map_err(|_| sentry_options::OptionsError::AlreadyInitialized.into()) -} - -/// Periodically reloads options from disk and atomically swaps in the new snapshot. -fn refresh(namespace: &str, _delay: f64) { - if namespace != NAMESPACE { - return; - } - - let (Some(snapshot), Some(inner)) = (OPTIONS.get(), INNER.get()) else { - return; - }; - - match Options::deserialize(inner) { - Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), - Err(ref err) => { - objectstore_log::error!(!!err, "Failed to refresh objectstore options") - } - } -} - -/// Overrides the global options for testing purposes. -/// -/// This function is only available in test builds and allows temporarily overriding -/// specific options. The overrides are applied for the duration of the returned -/// `OverrideGuard`. -#[cfg(feature = "testing")] -pub fn override_options( - overrides: &[(&str, serde_json::Value)], -) -> sentry_options::testing::OverrideGuard { - let overrides = overrides - .iter() - .map(|(key, value)| (NAMESPACE, *key, value.clone())) - .collect::>(); - - sentry_options::testing::override_options(&overrides).unwrap() -} - #[cfg(test)] mod tests { use super::*; diff --git a/objectstore-server/src/cli.rs b/objectstore-server/src/cli.rs index 8e2dd6c8..132ef7d5 100644 --- a/objectstore-server/src/cli.rs +++ b/objectstore-server/src/cli.rs @@ -115,7 +115,7 @@ pub fn execute() -> Result<()> { objectstore_log::debug!(?config); objectstore_metrics::init(&config.metrics)?; - objectstore_options::init()?; + objectstore_options::Options::init()?; runtime.block_on(async move { match args.command { diff --git a/objectstore-typed-options-derive/Cargo.toml b/objectstore-typed-options-derive/Cargo.toml new file mode 100644 index 00000000..137b330f --- /dev/null +++ b/objectstore-typed-options-derive/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "objectstore-typed-options-derive" +authors = ["Sentry "] +description = "Derive macro for sentry-options backed runtime configuration" +homepage = "https://getsentry.github.io/objectstore/" +repository = "https://github.com/getsentry/objectstore" +license-file = "../LICENSE.md" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0.95" +quote = "1.0.40" +syn = { version = "2.0.101", features = ["full"] } diff --git a/objectstore-typed-options-derive/src/lib.rs b/objectstore-typed-options-derive/src/lib.rs new file mode 100644 index 00000000..bc9075f1 --- /dev/null +++ b/objectstore-typed-options-derive/src/lib.rs @@ -0,0 +1,240 @@ +//! Derive macro for [`objectstore_typed_options::SentryOptions`]. +//! +//! See the [`objectstore-typed-options`] crate for full documentation and usage examples. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, Fields, LitStr, parse_macro_input}; + +/// Derives the `SentryOptions` trait and generates runtime option machinery. +/// +/// # Container attributes +/// +/// - `namespace` — the sentry-options namespace (e.g. `"objectstore"`) +/// - `path` — relative path to the `sentry-options/` directory; the schema is resolved as +/// `{path}/schemas/{namespace}/schema.json` +/// +/// # Generated code +/// +/// For each struct field, `deserialize` calls `Deserialize::deserialize(options.get(NAMESPACE, "")?)`. +/// +/// Additionally generates: +/// - `SentryOptions` trait impl with `NAMESPACE`, `SCHEMA`, and `deserialize` +/// - Inherent `get() -> Arc`, `init() -> Result<(), Error>`, and (under `testing` feature) +/// `override_with()` +/// - Module-scoped `OnceLock` statics for the singleton snapshot (`ArcSwap`) and the raw +/// sentry-options handle that the live-reload callback reads from +#[proc_macro_derive(SentryOptions, attributes(sentry_options))] +pub fn derive_sentry_options(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match expand(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +struct Attrs { + namespace: LitStr, + path: LitStr, +} + +fn parse_attrs(input: &DeriveInput) -> syn::Result { + let mut namespace: Option = None; + let mut path: Option = None; + + for attr in &input.attrs { + if !attr.path().is_ident("sentry_options") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("namespace") { + let value = meta.value()?; + namespace = Some(value.parse::()?); + Ok(()) + } else if meta.path.is_ident("path") { + let value = meta.value()?; + path = Some(value.parse::()?); + Ok(()) + } else { + Err(meta.error("unknown sentry_options attribute")) + } + })?; + } + + let namespace = namespace + .ok_or_else(|| syn::Error::new(input.ident.span(), "missing `namespace` attribute"))?; + let path = + path.ok_or_else(|| syn::Error::new(input.ident.span(), "missing `path` attribute"))?; + + Ok(Attrs { namespace, path }) +} + +fn expand(input: DeriveInput) -> syn::Result { + let attrs = parse_attrs(&input)?; + let name = &input.ident; + + let fields = match &input.data { + syn::Data::Struct(data) => match &data.fields { + Fields::Named(named) => &named.named, + _ => { + return Err(syn::Error::new( + name.span(), + "SentryOptions can only be derived on structs with named fields", + )); + } + }, + _ => { + return Err(syn::Error::new( + name.span(), + "SentryOptions can only be derived on structs", + )); + } + }; + + let namespace_str = &attrs.namespace; + let path_str = &attrs.path; + + // Build deserialize body: one line per field. + let field_deserializations: Vec<_> = fields + .iter() + .map(|f| { + let field_name = f.ident.as_ref().expect("named field"); + let field_key = field_name.to_string(); + quote! { + #field_name: ::objectstore_typed_options::serde::Deserialize::deserialize( + options.get(Self::NAMESPACE, #field_key)? + )? + } + }) + .collect(); + + Ok(quote! { + static __OPTIONS: ::std::sync::OnceLock< + ::objectstore_typed_options::arc_swap::ArcSwap<#name> + > = ::std::sync::OnceLock::new(); + + static __INNER: ::std::sync::OnceLock< + ::objectstore_typed_options::sentry_options::Options + > = ::std::sync::OnceLock::new(); + + impl ::objectstore_typed_options::SentryOptions for #name { + const NAMESPACE: &str = #namespace_str; + const SCHEMA: &str = include_str!( + concat!(#path_str, "/schemas/", #namespace_str, "/schema.json") + ); + + fn deserialize( + options: &::objectstore_typed_options::sentry_options::Options, + ) -> ::std::result::Result { + ::std::result::Result::Ok(Self { + #(#field_deserializations),* + }) + } + } + + impl #name { + /// Returns a snapshot of the current options. + /// + /// The returned [`Arc`] holds the most recently loaded values. Callers may hold + /// it across await points without blocking updates — a new snapshot is swapped in + /// atomically by the background refresh task without invalidating existing + /// references. + /// + /// # Panics + /// + /// Panics if [`init`](Self::init) has not been called. + #[cfg(not(feature = "testing"))] + pub fn get() -> ::std::sync::Arc { + __OPTIONS + .get() + .expect("options not initialized") + .load_full() + } + + /// Returns a snapshot of the current options, deserializing fresh from schema + /// defaults. + /// + /// In test builds this bypasses the global instance and reads directly from the + /// schema, so [`init`](Self::init) does not need to be called. Use + /// [`override_with`](Self::override_with) to test non-default values. + #[cfg(feature = "testing")] + pub fn get() -> ::std::sync::Arc { + use ::objectstore_typed_options::SentryOptions as _; + + let inner = ::objectstore_typed_options::sentry_options::Options::builder() + .with_schemas(&[(Self::NAMESPACE, Self::SCHEMA)]) + .build() + .expect("options schema should be valid"); + + ::std::sync::Arc::new( + Self::deserialize(&inner).expect("failed to deserialize options"), + ) + } + + /// Initializes the global options instance and registers a live-reload callback. + /// + /// The standard fallback chain is used: + /// + /// 1. `SENTRY_OPTIONS_DIR` environment variable + /// 2. `/etc/sentry-options` (if it exists) + /// 3. `sentry-options/` relative to the current working directory + /// 4. Schema defaults (if no values file is present) + /// + /// Returns an `Err(AlreadyInitialized)` if called more than once. + pub fn init() -> ::std::result::Result<(), ::objectstore_typed_options::Error> { + use ::objectstore_typed_options::SentryOptions as _; + + if __OPTIONS.get().is_some() { + return ::std::result::Result::Err( + ::objectstore_typed_options::sentry_options::OptionsError::AlreadyInitialized.into(), + ); + } + + // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the + // application will not silently run with defaults or fail later when options are + // accessed. + let inner = ::objectstore_typed_options::sentry_options::Options::builder() + .with_schemas(&[(Self::NAMESPACE, Self::SCHEMA)]) + .with_callback(|namespace: &str, delay: f64| { + ::objectstore_typed_options::refresh::( + &__OPTIONS, &__INNER, namespace, delay, + ) + }) + .build()?; + + __OPTIONS + .set(::objectstore_typed_options::arc_swap::ArcSwap::from_pointee( + Self::deserialize(&inner)?, + )) + .map_err(|_| { + ::objectstore_typed_options::sentry_options::OptionsError::AlreadyInitialized + })?; + + __INNER.set(inner).map_err(|_| { + ::objectstore_typed_options::sentry_options::OptionsError::AlreadyInitialized.into() + }) + } + + /// Overrides the global options for testing purposes. + /// + /// This function is only available in test builds and allows temporarily + /// overriding specific options. The overrides are applied for the duration of + /// the returned [`OverrideGuard`](objectstore_typed_options::sentry_options::testing::OverrideGuard). + #[cfg(feature = "testing")] + pub fn override_with( + overrides: &[(&str, ::objectstore_typed_options::serde_json::Value)], + ) -> ::objectstore_typed_options::sentry_options::testing::OverrideGuard { + use ::objectstore_typed_options::SentryOptions as _; + + let overrides = overrides + .iter() + .map(|(key, value)| (Self::NAMESPACE, *key, value.clone())) + .collect::<::std::vec::Vec<_>>(); + + ::objectstore_typed_options::sentry_options::testing::override_options(&overrides) + .expect("failed to override options") + } + } + }) +} diff --git a/objectstore-typed-options/Cargo.toml b/objectstore-typed-options/Cargo.toml new file mode 100644 index 00000000..3a79a378 --- /dev/null +++ b/objectstore-typed-options/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "objectstore-typed-options" +authors = ["Sentry "] +description = "Runtime typed options trait and support machinery, backed by sentry-options" +homepage = "https://getsentry.github.io/objectstore/" +repository = "https://github.com/getsentry/objectstore" +license-file = "../LICENSE.md" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +arc-swap = { workspace = true } +objectstore-log = { workspace = true } +objectstore-typed-options-derive = { workspace = true, optional = true } +sentry-options = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[features] +derive = ["dep:objectstore-typed-options-derive"] diff --git a/objectstore-typed-options/src/lib.rs b/objectstore-typed-options/src/lib.rs new file mode 100644 index 00000000..222a80e9 --- /dev/null +++ b/objectstore-typed-options/src/lib.rs @@ -0,0 +1,149 @@ +//! Typed, live-reloading runtime options backed by sentry-options. +//! +//! Annotate a plain Rust struct with `#[derive(SentryOptions)]` and it gains a global +//! singleton, schema validation at startup, atomic live-reloading on option changes, +//! and a test-friendly override mechanism — without any hand-written boilerplate. +//! +//! # Usage +//! +//! Annotate a named struct with `#[derive(SentryOptions)]` and the two required container +//! attributes: +//! +//! - `namespace` — the sentry-options namespace string +//! - `path` — relative path (from the source file) to the `sentry-options/` directory; +//! the schema is resolved as `{path}/schemas/{namespace}/schema.json` +//! +//! Each struct field must implement [`serde::Deserialize`] and corresponds to a key of the +//! same name within the namespace. +//! +//! # Generated API +//! +//! - `T::get() -> Arc` — Returns an atomic snapshot of the current values. Panics if +//! `init` was not called (non-test builds). +//! - `T::init() -> Result<(), Error>` — Loads initial values, sets the global singleton, and +//! registers a propagation callback that live-reloads on changes. Returns an +//! `Err(AlreadyInitialized)` if called more than once. +//! - `T::override_with(…)` — *(testing feature only)* Temporarily overrides option values; +//! reverts when the returned guard is dropped. +//! +//! When sentry-options detects changed values it invokes the registered callback, which +//! reloads and atomically swaps in the new snapshot without blocking readers. +//! +//! # Testing +//! +//! Compile with the `testing` feature to enable a test-friendly variant of `get()` that +//! deserializes fresh from schema defaults on every call, bypassing the global singleton. +//! This means [`init`](SentryOptions) does not need to be called in tests. +//! +//! A minimal schema validity test — which every options struct should have — looks like: +//! +//! ```rust,no_run +//! # use objectstore_typed_options::SentryOptions; +//! # #[derive(Debug, SentryOptions)] +//! # #[sentry_options(namespace = "objectstore", path = "../../sentry-options")] +//! # struct Options {} +//! #[cfg(test)] +//! mod tests { +//! use super::*; +//! +//! #[test] +//! fn schema_is_valid() { +//! let _ = Options::get(); +//! } +//! } +//! ``` +//! +//! # Example +//! +//! ```rust,no_run +//! use objectstore_typed_options::SentryOptions; +//! +//! // `path` points to the sentry-options directory; the schema is resolved as +//! // `{path}/schemas/{namespace}/schema.json` and embedded at compile time. +//! #[derive(Debug, SentryOptions)] +//! #[sentry_options( +//! namespace = "objectstore", +//! path = "../../sentry-options" +//! )] +//! pub struct Options { +//! max_retries: u32, +//! allowed_orgs: Vec, +//! } +//! +//! impl Options { +//! pub fn max_retries(&self) -> u32 { +//! self.max_retries +//! } +//! +//! pub fn allowed_orgs(&self) -> &[u32] { +//! &self.allowed_orgs +//! } +//! } +//! +//! // At startup: +//! Options::init().expect("failed to load options"); +//! +//! // At call sites: +//! println!("max_retries = {}", Options::get().max_retries()); +//! ``` + +use std::sync::{Arc, OnceLock}; + +use arc_swap::ArcSwap; + +// Re-exported for use by generated code from `#[derive(SentryOptions)]`. Not public API. +#[doc(hidden)] +pub use {arc_swap, sentry_options, serde, serde_json}; + +#[cfg(feature = "derive")] +pub use objectstore_typed_options_derive::SentryOptions; + +/// Errors returned by sentry-options operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Options(#[from] sentry_options::OptionsError), + #[error("failed to deserialize option value")] + Deserialize(#[from] serde_json::Error), +} + +/// Trait implemented by option structs that are backed by sentry-options. +/// +/// Typically derived via `#[derive(SentryOptions)]` rather than implemented manually. +pub trait SentryOptions: Sized + Send + Sync + 'static { + /// The sentry-options namespace for this type. + const NAMESPACE: &str; + + /// The raw JSON schema string (embedded at compile time via `include_str!`). + const SCHEMA: &str; + + /// Deserializes an instance from the loaded sentry-options values. + fn deserialize(options: &sentry_options::Options) -> Result; +} + +/// Reloads options and atomically swaps in the new snapshot. +/// +/// Registered as the sentry-options propagation callback by the generated +/// [`init`](SentryOptions) implementation and invoked whenever values change. Not intended for +/// direct use. +pub fn refresh( + options: &'static OnceLock>, + inner: &'static OnceLock, + namespace: &str, + _delay: f64, +) { + if namespace != T::NAMESPACE { + return; + } + + let (Some(snapshot), Some(inner)) = (options.get(), inner.get()) else { + return; + }; + + match T::deserialize(inner) { + Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), + Err(ref err) => { + objectstore_log::error!(!!err, "Failed to refresh objectstore options") + } + } +} From db06791925a7d45b9cd5458f7b2c65d75d9885e7 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Thu, 9 Apr 2026 17:24:58 +0200 Subject: [PATCH 11/14] fix(options): Wrap derive output in const _: () = {} to scope __OPTIONS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two structs deriving SentryOptions in the same module previously produced a duplicate symbol error because __OPTIONS was emitted at module scope with a fixed name. Wrapping all generated items in const _: () = { … } gives each derived struct its own anonymous scope, matching the standard serde-style hygiene idiom. --- objectstore-typed-options-derive/src/lib.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/objectstore-typed-options-derive/src/lib.rs b/objectstore-typed-options-derive/src/lib.rs index bc9075f1..c6e79e33 100644 --- a/objectstore-typed-options-derive/src/lib.rs +++ b/objectstore-typed-options-derive/src/lib.rs @@ -1,6 +1,6 @@ -//! Derive macro for [`objectstore_typed_options::SentryOptions`]. +//! Derive macro for `SentryOptions`. //! -//! See the [`objectstore-typed-options`] crate for full documentation and usage examples. +//! See the `objectstore-typed-options` crate for full documentation and usage examples. use proc_macro::TokenStream; use quote::quote; @@ -16,14 +16,16 @@ use syn::{DeriveInput, Fields, LitStr, parse_macro_input}; /// /// # Generated code /// -/// For each struct field, `deserialize` calls `Deserialize::deserialize(options.get(NAMESPACE, "")?)`. +/// For each struct field, `deserialize` calls `Deserialize::deserialize(options.get(NAMESPACE, +/// "")?)`. /// /// Additionally generates: /// - `SentryOptions` trait impl with `NAMESPACE`, `SCHEMA`, and `deserialize` /// - Inherent `get() -> Arc`, `init() -> Result<(), Error>`, and (under `testing` feature) /// `override_with()` -/// - Module-scoped `OnceLock` statics for the singleton snapshot (`ArcSwap`) and the raw -/// sentry-options handle that the live-reload callback reads from +/// - `OnceLock` statics for the singleton snapshot (`ArcSwap`) and the raw sentry-options +/// handle that the live-reload callback reads from, wrapped in `const _: () = { … }` to avoid +/// symbol conflicts when multiple structs derive `SentryOptions` in the same module #[proc_macro_derive(SentryOptions, attributes(sentry_options))] pub fn derive_sentry_options(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); @@ -110,6 +112,7 @@ fn expand(input: DeriveInput) -> syn::Result { .collect(); Ok(quote! { + const _: () = { static __OPTIONS: ::std::sync::OnceLock< ::objectstore_typed_options::arc_swap::ArcSwap<#name> > = ::std::sync::OnceLock::new(); @@ -236,5 +239,6 @@ fn expand(input: DeriveInput) -> syn::Result { .expect("failed to override options") } } + }; // end const _: () = { ... } }) } From e0fc4933403f6807d6de619b6c67f20e56a53ae4 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Mon, 6 Jul 2026 11:01:25 +0200 Subject: [PATCH 12/14] feat(options): Support sentry_options(rename) on derived fields --- objectstore-typed-options-derive/src/lib.rs | 43 ++++++++++++++++++--- objectstore-typed-options/src/lib.rs | 6 ++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/objectstore-typed-options-derive/src/lib.rs b/objectstore-typed-options-derive/src/lib.rs index c6e79e33..bc4b43cb 100644 --- a/objectstore-typed-options-derive/src/lib.rs +++ b/objectstore-typed-options-derive/src/lib.rs @@ -14,10 +14,16 @@ use syn::{DeriveInput, Fields, LitStr, parse_macro_input}; /// - `path` — relative path to the `sentry-options/` directory; the schema is resolved as /// `{path}/schemas/{namespace}/schema.json` /// +/// # Field attributes +/// +/// - `rename` — the option key to look up for this field, overriding the default (the field's +/// Rust identifier). Use it when the schema key differs from the field name, e.g. +/// `#[sentry_options(rename = "max-retries")] max_retries: u32`. +/// /// # Generated code /// /// For each struct field, `deserialize` calls `Deserialize::deserialize(options.get(NAMESPACE, -/// "")?)`. +/// "")?)`, where `` is the `rename` value if present and otherwise the field name. /// /// Additionally generates: /// - `SentryOptions` trait impl with `NAMESPACE`, `SCHEMA`, and `deserialize` @@ -72,6 +78,31 @@ fn parse_attrs(input: &DeriveInput) -> syn::Result { Ok(Attrs { namespace, path }) } +/// Returns the option key for a field: the `#[sentry_options(rename = "...")]` value if present, +/// otherwise the field's Rust identifier. +fn parse_field_key(field: &syn::Field) -> syn::Result { + let field_name = field.ident.as_ref().expect("named field"); + let mut rename: Option = None; + + for attr in &field.attrs { + if !attr.path().is_ident("sentry_options") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename") { + let value = meta.value()?; + rename = Some(value.parse::()?); + Ok(()) + } else { + Err(meta.error("unknown sentry_options field attribute")) + } + })?; + } + + Ok(rename.map_or_else(|| field_name.to_string(), |lit| lit.value())) +} + fn expand(input: DeriveInput) -> syn::Result { let attrs = parse_attrs(&input)?; let name = &input.ident; @@ -98,18 +129,18 @@ fn expand(input: DeriveInput) -> syn::Result { let path_str = &attrs.path; // Build deserialize body: one line per field. - let field_deserializations: Vec<_> = fields + let field_deserializations = fields .iter() .map(|f| { let field_name = f.ident.as_ref().expect("named field"); - let field_key = field_name.to_string(); - quote! { + let field_key = parse_field_key(f)?; + Ok(quote! { #field_name: ::objectstore_typed_options::serde::Deserialize::deserialize( options.get(Self::NAMESPACE, #field_key)? )? - } + }) }) - .collect(); + .collect::>>()?; Ok(quote! { const _: () = { diff --git a/objectstore-typed-options/src/lib.rs b/objectstore-typed-options/src/lib.rs index 222a80e9..d2d02004 100644 --- a/objectstore-typed-options/src/lib.rs +++ b/objectstore-typed-options/src/lib.rs @@ -14,7 +14,8 @@ //! the schema is resolved as `{path}/schemas/{namespace}/schema.json` //! //! Each struct field must implement [`serde::Deserialize`] and corresponds to a key of the -//! same name within the namespace. +//! same name within the namespace. To look up a key that differs from the field name, annotate +//! the field with `#[sentry_options(rename = "...")]`. //! //! # Generated API //! @@ -66,6 +67,7 @@ //! path = "../../sentry-options" //! )] //! pub struct Options { +//! #[sentry_options(rename = "retries")] //! max_retries: u32, //! allowed_orgs: Vec, //! } @@ -143,7 +145,7 @@ pub fn refresh( match T::deserialize(inner) { Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), Err(ref err) => { - objectstore_log::error!(!!err, "Failed to refresh objectstore options") + objectstore_log::error!(!!err, "Failed to refresh sentry options") } } } From 8495f53467e1a8b32854e415d219ed3fc668e226 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Mon, 6 Jul 2026 12:13:29 +0200 Subject: [PATCH 13/14] ref(options): Inline refresh into derive, delegate logging to log_error --- objectstore-typed-options-derive/src/lib.rs | 33 ++++++++++++++++--- objectstore-typed-options/src/lib.rs | 35 +++++---------------- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/objectstore-typed-options-derive/src/lib.rs b/objectstore-typed-options-derive/src/lib.rs index bc4b43cb..3c4ca88f 100644 --- a/objectstore-typed-options-derive/src/lib.rs +++ b/objectstore-typed-options-derive/src/lib.rs @@ -152,6 +152,33 @@ fn expand(input: DeriveInput) -> syn::Result { ::objectstore_typed_options::sentry_options::Options > = ::std::sync::OnceLock::new(); + /// Reloads options and atomically swaps in the new snapshot. + /// + /// Registered as the sentry-options propagation callback by `init` and invoked + /// whenever values change. + fn __refresh(namespace: &str, _delay: f64) { + if namespace != #namespace_str { + return; + } + + let (::std::option::Option::Some(snapshot), ::std::option::Option::Some(inner)) = + (__OPTIONS.get(), __INNER.get()) + else { + return; + }; + + match <#name as ::objectstore_typed_options::SentryOptions>::deserialize(inner) { + ::std::result::Result::Ok(new_snapshot) => { + snapshot.store(::std::sync::Arc::new(new_snapshot)) + } + ::std::result::Result::Err(ref err) => { + ::objectstore_typed_options::log_error( + #namespace_str, "failed to refresh options", err, + ) + } + } + } + impl ::objectstore_typed_options::SentryOptions for #name { const NAMESPACE: &str = #namespace_str; const SCHEMA: &str = include_str!( @@ -230,11 +257,7 @@ fn expand(input: DeriveInput) -> syn::Result { // accessed. let inner = ::objectstore_typed_options::sentry_options::Options::builder() .with_schemas(&[(Self::NAMESPACE, Self::SCHEMA)]) - .with_callback(|namespace: &str, delay: f64| { - ::objectstore_typed_options::refresh::( - &__OPTIONS, &__INNER, namespace, delay, - ) - }) + .with_callback(__refresh) .build()?; __OPTIONS diff --git a/objectstore-typed-options/src/lib.rs b/objectstore-typed-options/src/lib.rs index d2d02004..461c49ee 100644 --- a/objectstore-typed-options/src/lib.rs +++ b/objectstore-typed-options/src/lib.rs @@ -89,10 +89,6 @@ //! println!("max_retries = {}", Options::get().max_retries()); //! ``` -use std::sync::{Arc, OnceLock}; - -use arc_swap::ArcSwap; - // Re-exported for use by generated code from `#[derive(SentryOptions)]`. Not public API. #[doc(hidden)] pub use {arc_swap, sentry_options, serde, serde_json}; @@ -123,29 +119,12 @@ pub trait SentryOptions: Sized + Send + Sync + 'static { fn deserialize(options: &sentry_options::Options) -> Result; } -/// Reloads options and atomically swaps in the new snapshot. +/// Logs an error against a namespace. /// -/// Registered as the sentry-options propagation callback by the generated -/// [`init`](SentryOptions) implementation and invoked whenever values change. Not intended for -/// direct use. -pub fn refresh( - options: &'static OnceLock>, - inner: &'static OnceLock, - namespace: &str, - _delay: f64, -) { - if namespace != T::NAMESPACE { - return; - } - - let (Some(snapshot), Some(inner)) = (options.get(), inner.get()) else { - return; - }; - - match T::deserialize(inner) { - Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), - Err(ref err) => { - objectstore_log::error!(!!err, "Failed to refresh sentry options") - } - } +/// Called by the callback generated by `#[derive(SentryOptions)]` so that the dependency on the +/// logging facility stays in this crate rather than being forced onto every deriving crate. Not +/// intended for direct use. +#[doc(hidden)] +pub fn log_error(namespace: &str, message: &str, err: &Error) { + objectstore_log::error!(!!err, namespace, "{message}"); } From 3c84c445364c17d7769a35fcf425f687edc18370 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Fri, 10 Apr 2026 11:50:50 +0200 Subject: [PATCH 14/14] feat(options): Auto-generate sentry-options schema from Rust types Add schema generation support to objectstore-typed-options so the sentry-options schema stays in sync with the Rust struct definitions. Previously the schema was hand-written and had already drifted. The conversion logic lives in objectstore_typed_options::schema and is gated behind the testing feature. Consumers add JsonSchema derives (also test-only) and call assert_schema_matches_golden_file in a single test. The schema remains checked in so sentry-options-automator can access it without running Rust, and so reviewers can inspect schema changes in PRs. The golden file test catches drift immediately and prints a colored unified diff; UPDATE_SCHEMA=1 regenerates it. Co-Authored-By: Claude --- Cargo.lock | 27 ++ objectstore-options/Cargo.toml | 2 + objectstore-options/src/lib.rs | 14 ++ objectstore-typed-options/Cargo.toml | 3 + objectstore-typed-options/src/lib.rs | 3 + objectstore-typed-options/src/schema.rs | 235 ++++++++++++++++++ .../schemas/objectstore/schema.json | 36 +-- 7 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 objectstore-typed-options/src/schema.rs diff --git a/Cargo.lock b/Cargo.lock index a1c3e5e8..59f376eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2796,6 +2796,7 @@ name = "objectstore-options" version = "0.1.0" dependencies = [ "objectstore-typed-options", + "schemars 1.2.1", "serde", ] @@ -2902,9 +2903,11 @@ dependencies = [ "arc-swap", "objectstore-log", "objectstore-typed-options-derive", + "schemars 1.2.1", "sentry-options", "serde", "serde_json", + "similar", "thiserror", ] @@ -4012,10 +4015,23 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4293,6 +4309,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.150" diff --git a/objectstore-options/Cargo.toml b/objectstore-options/Cargo.toml index 68d93622..162f5705 100644 --- a/objectstore-options/Cargo.toml +++ b/objectstore-options/Cargo.toml @@ -17,3 +17,5 @@ serde = { workspace = true, features = ["derive"] } testing = [] [dev-dependencies] +objectstore-typed-options = { workspace = true, features = ["derive", "testing"] } +schemars = "1.2.1" diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index d9fff60f..7966f083 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -16,9 +16,11 @@ pub use objectstore_typed_options::Error; /// Obtain a snapshot of the current options via [`Options::get`]. Before calling `get`, /// the global instance must be initialized with [`Options::init`]. #[derive(Debug, SentryOptions)] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[sentry_options(namespace = "objectstore", path = "../../sentry-options")] pub struct Options { /// Active killswitches that may disable access to specific object contexts. + #[cfg_attr(test, schemars(default))] killswitches: Vec, } @@ -34,6 +36,7 @@ impl Options { /// Note that at least one of the fields should be set, or else the killswitch will match all /// contexts and discard all requests. #[derive(Debug, Deserialize, Serialize, PartialEq)] +#[cfg_attr(test, derive(schemars::JsonSchema))] pub struct Killswitch { /// Optional usecase to match. /// @@ -61,6 +64,8 @@ pub struct Killswitch { #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::*; // Required for schema validation. @@ -71,4 +76,13 @@ mod tests { fn schema_is_valid() { let _ = Options::get(); } + + #[test] + fn schema_matches_golden_file() { + let schema_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../sentry-options/schemas/objectstore/schema.json"); + objectstore_typed_options::schema::assert_schema_matches_golden_file::( + &schema_path, + ); + } } diff --git a/objectstore-typed-options/Cargo.toml b/objectstore-typed-options/Cargo.toml index 3a79a378..d87e1a41 100644 --- a/objectstore-typed-options/Cargo.toml +++ b/objectstore-typed-options/Cargo.toml @@ -13,6 +13,8 @@ publish = false arc-swap = { workspace = true } objectstore-log = { workspace = true } objectstore-typed-options-derive = { workspace = true, optional = true } +schemars = { version = "1.2.1", optional = true } +similar = { version = "2.7.0", optional = true } sentry-options = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -20,3 +22,4 @@ thiserror = { workspace = true } [features] derive = ["dep:objectstore-typed-options-derive"] +testing = ["dep:schemars", "dep:similar"] diff --git a/objectstore-typed-options/src/lib.rs b/objectstore-typed-options/src/lib.rs index 461c49ee..13e1c183 100644 --- a/objectstore-typed-options/src/lib.rs +++ b/objectstore-typed-options/src/lib.rs @@ -89,6 +89,9 @@ //! println!("max_retries = {}", Options::get().max_retries()); //! ``` +#[cfg(feature = "testing")] +pub mod schema; + // Re-exported for use by generated code from `#[derive(SentryOptions)]`. Not public API. #[doc(hidden)] pub use {arc_swap, sentry_options, serde, serde_json}; diff --git a/objectstore-typed-options/src/schema.rs b/objectstore-typed-options/src/schema.rs new file mode 100644 index 00000000..5a7182bf --- /dev/null +++ b/objectstore-typed-options/src/schema.rs @@ -0,0 +1,235 @@ +use std::path::Path; + +use serde_json::{Map, Value}; + +/// Converts the JSON Schema produced by schemars for `T` into sentry-options schema format. +/// +/// The sentry-options format has strict per-level rules about which keys are allowed: +/// +/// - **Root**: `version` (added here), `type`, `properties` +/// - **Top-level property** (inside `properties`): `type`, `description`, `default`, `items`, +/// `additionalProperties` +/// - **Items** (array element schema): `type`, `properties`, `additionalProperties` +/// - **Nested property** (inside an items object's `properties`): `type`, +/// `additionalProperties`, `optional` +/// +/// `$ref` references are inlined from `$defs`. Nullable types (`"type": ["T", "null"]` or +/// `"anyOf": [T, null]`) are unwrapped to the base type; fields detected as nullable are +/// marked `"optional": true` at the nested-property level. +pub fn generate_sentry_schema() -> Value { + let schema = schemars::schema_for!(T); + let json = serde_json::to_value(&schema).expect("schema serialization cannot fail"); + + let defs = match json.get("$defs") { + Some(Value::Object(map)) => map.clone(), + _ => Map::new(), + }; + + let converted = convert_schema(&json, &defs, Level::Root); + + let mut root = Map::new(); + root.insert("version".to_string(), Value::String("1.0".to_string())); + if let Value::Object(map) = converted { + for (key, value) in map { + root.insert(key, value); + } + } + + Value::Object(root) +} + +/// Asserts that the sentry-options schema for `T` matches the golden file at `schema_path`. +/// +/// If the `UPDATE_SCHEMA` environment variable is set to `"1"`, the golden file is +/// regenerated instead of compared. +pub fn assert_schema_matches_golden_file(schema_path: &Path) { + let generated = generate_sentry_schema::(); + let generated_str = + serde_json::to_string_pretty(&generated).expect("schema serialization cannot fail") + "\n"; + + if std::env::var("UPDATE_SCHEMA").as_deref() == Ok("1") { + std::fs::write(schema_path, &generated_str).expect("write schema golden file"); + return; + } + + let existing = std::fs::read_to_string(schema_path).expect("read schema golden file"); + if existing != generated_str { + panic!( + "schema golden file is out of date:\n{}\nRun with UPDATE_SCHEMA=1 to regenerate.", + render_diff(&existing, &generated_str), + ); + } +} + +fn render_diff(old: &str, new: &str) -> String { + use similar::{ChangeTag, TextDiff}; + + let diff = TextDiff::from_lines(old, new); + let mut out = String::new(); + + for change in diff.iter_all_changes() { + let (prefix, color_open, color_close) = match change.tag() { + ChangeTag::Equal => (" ", "", ""), + ChangeTag::Delete => ("- ", "\x1b[31m", "\x1b[0m"), + ChangeTag::Insert => ("+ ", "\x1b[32m", "\x1b[0m"), + }; + out.push_str(&format!( + "{}{}{}{}", + color_open, prefix, change, color_close + )); + } + + out +} + +// --- conversion internals --- + +/// Tracks which structural level of the sentry-options schema format is being produced. +#[derive(Clone, Copy)] +enum Level { + /// The root object (`{ "version", "type", "properties" }`). + Root, + /// A key directly inside the root `properties` map. Allowed: `type`, `description`, + /// `default`, `items`, `additionalProperties`. + TopLevelProp, + /// The `items` schema of an array-typed top-level property. Allowed: `type`, `properties`, + /// `additionalProperties`. + Items, + /// A key inside the `properties` of an items object. Allowed: `type`, + /// `additionalProperties`, `optional`. + NestedProp, +} + +fn convert_schema(schema: &Value, defs: &Map, level: Level) -> Value { + match schema { + Value::Object(map) => convert_object(map, defs, level), + other => other.clone(), + } +} + +fn convert_object(map: &Map, defs: &Map, level: Level) -> Value { + // Inline $ref references before any level-specific handling. + if let Some(def_schema) = map + .get("$ref") + .and_then(|v| v.as_str()) + .and_then(|s| s.strip_prefix("#/$defs/")) + .and_then(|name| defs.get(name)) + { + return convert_schema(def_schema, defs, level); + } + + match level { + Level::Root => { + let mut out = Map::new(); + for key in ["type", "properties"] { + let Some(value) = map.get(key) else { + continue; + }; + let converted = if key == "properties" { + convert_properties(value, defs, Level::TopLevelProp) + } else { + value.clone() + }; + out.insert(key.to_string(), converted); + } + Value::Object(out) + } + + Level::TopLevelProp => { + let mut out = Map::new(); + out.insert("type".to_string(), scalar_type(map)); + for key in ["description", "default", "items", "additionalProperties"] { + let Some(value) = map.get(key) else { + continue; + }; + let converted = match key { + "items" => convert_schema(value, defs, Level::Items), + "additionalProperties" => convert_schema(value, defs, Level::NestedProp), + _ => value.clone(), + }; + out.insert(key.to_string(), converted); + } + Value::Object(out) + } + + Level::Items => { + let mut out = Map::new(); + for key in ["type", "properties", "additionalProperties"] { + let Some(value) = map.get(key) else { + continue; + }; + let converted = match key { + "properties" => convert_properties(value, defs, Level::NestedProp), + "additionalProperties" => convert_schema(value, defs, Level::NestedProp), + _ => value.clone(), + }; + out.insert(key.to_string(), converted); + } + Value::Object(out) + } + + Level::NestedProp => { + let is_optional = is_optional(map); + let mut out = Map::new(); + out.insert("type".to_string(), scalar_type(map)); + if let Some(value) = map.get("additionalProperties") { + out.insert( + "additionalProperties".to_string(), + convert_schema(value, defs, Level::NestedProp), + ); + } + if is_optional { + out.insert("optional".to_string(), Value::Bool(true)); + } + Value::Object(out) + } + } +} + +/// Extracts the scalar type string, unwrapping nullable array types like `["string", "null"]`. +fn scalar_type(map: &Map) -> Value { + match map.get("type") { + Some(Value::Array(types)) => { + let non_null: Vec<&Value> = types + .iter() + .filter(|t| t.as_str() != Some("null")) + .collect(); + if non_null.len() == 1 { + return (*non_null[0]).clone(); + } + Value::Array(types.clone()) + } + Some(t) => t.clone(), + None => Value::Null, + } +} + +/// Returns `true` if the field is optional: nullable type, anyOf with null, or has a default. +fn is_optional(map: &Map) -> bool { + if map.contains_key("default") { + return true; + } + if matches!(map.get("type"), Some(Value::Array(types)) if types.iter().any(|t| t.as_str() == Some("null"))) + { + return true; + } + if matches!( + map.get("anyOf"), + Some(Value::Array(variants)) + if variants.iter().any(|v| v.get("type").is_some_and(|t| t == "null")) + ) { + return true; + } + false +} + +fn convert_properties(props: &Value, defs: &Map, level: Level) -> Value { + let Value::Object(map) = props else { + return props.clone(); + }; + let converted = map + .iter() + .map(|(k, v)| (k.clone(), convert_schema(v, defs, level))) + .collect(); + Value::Object(converted) +} diff --git a/sentry-options/schemas/objectstore/schema.json b/sentry-options/schemas/objectstore/schema.json index 44ca5848..d16d23ae 100644 --- a/sentry-options/schemas/objectstore/schema.json +++ b/sentry-options/schemas/objectstore/schema.json @@ -1,31 +1,31 @@ { - "version": "1.0", - "type": "object", "properties": { "killswitches": { - "type": "array", "default": [], - "description": "Runtime killswitches for disabling access to specific object contexts. Each entry matches requests by usecase, scope values, and optionally a downstream service glob pattern. When any killswitch matches, the request is rejected without forwarding to the storage backend.", + "description": "Active killswitches that may disable access to specific object contexts.", "items": { - "type": "object", "properties": { - "usecase": { - "type": "string", - "optional": true - }, - "service": { - "type": "string", - "optional": true - }, "scopes": { - "type": "object", "additionalProperties": { "type": "string" }, - "optional": true + "optional": true, + "type": "object" + }, + "service": { + "optional": true, + "type": "string" + }, + "usecase": { + "optional": true, + "type": "string" } - } - } + }, + "type": "object" + }, + "type": "array" } - } + }, + "type": "object", + "version": "1.0" }