quonfig 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +10 -2
- data/lib/quonfig/bound_client.rb +15 -7
- data/lib/quonfig/client.rb +129 -18
- data/lib/quonfig/config_envelope.rb +16 -1
- data/lib/quonfig/config_loader.rb +61 -19
- data/lib/quonfig/duration.rb +56 -7
- data/lib/quonfig/evaluator.rb +28 -12
- data/lib/quonfig/resolver.rb +62 -16
- data/lib/quonfig/sse_config_client.rb +8 -0
- data/lib/quonfig/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea0ee1cb9b012622117d24f82d00e9ce5fdf61d160562846c6c9b6fa03b4a524
|
|
4
|
+
data.tar.gz: 2aa051314de0c538eb77218e015368dab3c4fd5c0405ea5469b75c84817314c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a0d54912f70d45cec55a53c758636d8bb7448eac31c1aee6a19463e6575247e8bc0374ed9ff3b279630b00031556dacb6657ffef7a9f16ac87ec5eb2a2f65ef
|
|
7
|
+
data.tar.gz: 8838087ac35f83bc5c8761cba0c58a1a8a3fc1a0b3472c6de0b81c20a71d950f4ec1759098ce72e129fd6b4dadd8dd5b6190416d58014cf9dffb99b2bfc9cdd0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.0 - 2026-10-02
|
|
4
|
+
|
|
5
|
+
- **New: public `Client#get_or_raise` and `Client#get_duration_details` (also on `BoundClient`) (qfg-2agi.27).** `get_or_raise(key, default:, context:)` returns the value like `get`; a missing key with no `default:` raises `MissingDefaultError` even under `on_no_default: :return_nil`, and an unset ENV_VAR (`MissingEnvVarError`), an uncoercible ENV_VAR or malformed stored duration (`EnvVarParseError`) or a decryption failure (`DecryptionError`) raises. `get_duration_details` returns integer milliseconds in `EvaluationDetails`; a non-duration config is `ERROR`/`TYPE_MISMATCH` and a malformed value is `ERROR` with a nil value. The shared integration suite's init-timeout `get_or_raise` case now calls the public method instead of falling back to `get`.
|
|
6
|
+
- **Fix: durations follow the one grammar all Quonfig SDKs share, and a malformed duration returns the default instead of a made-up number (qfg-2agi.10).** Grammar (integration-test-data `tests/duration/grammar.yaml`): `P[nD][T[nH][nM][n[.fff]S]]`, at least one component, a fraction only on seconds (at most 9 digits), at most `P36500D`. Before, the pattern was unanchored, so `xxPT5Sxx` read as 5000 ms, `PT5M3H` as 300000 ms and `garbage` as 0. Milliseconds are now rounded half up on both the stored and the ENV_VAR path (`PT1.9999S` from an env var was 1999, now 2000).
|
|
7
|
+
- `get_duration` on a malformed value (stored or ENV_VAR) returns `default:` and logs one warning per key. With no default it returns `nil` under `on_no_default: :return_nil` and raises `Quonfig::Errors::EnvVarParseError` under the default `:raise` policy.
|
|
8
|
+
- `get_duration` on a config whose value type is not duration now raises `TypeMismatchError`, like `get_int` on a string config. Before, an int config's number was returned as milliseconds.
|
|
9
|
+
- `get` on a malformed stored duration returns `nil` (was `0` or a wrong number).
|
|
10
|
+
- `Quonfig::Duration.parse` raises `ArgumentError` on an invalid string (it returned `0`). New `Quonfig::Duration.parse_millis` (Integer ms or `nil`) and `Quonfig::Duration.valid?`. Values that used fractions on D/H/M (`PT0.5H`, `PT1.5M`) are now invalid; write `PT30M`, `PT90S`.
|
|
11
|
+
- **Fix: `Client#get` on a duration provided by an ENV_VAR now returns integer milliseconds, the same as a stored duration (qfg-2agi.22).** Before, it returned the raw ISO string (`PT30M` came back as `"PT30M"` from the env var but `1800000` when stored). `get_duration` was already correct.
|
|
12
|
+
- **Behaviour change (bug fix): a named context from a newer tier now replaces the whole same-named context from an older tier, instead of being merged into it property by property (qfg-2agi.36).** Tiers, oldest to newest: `global_context`, `with_context` / `in_context` scopes (outer to inner), per-call context. Example: with `global_context: { user: { 'role' => 'admin' } }`, a call passing `user: { 'plan' => 'pro' }` now evaluates with `user.plan` only; `user.role` is no longer present. Named contexts the newer tier does not mention (for example `team`) are still kept. A symbol name and a string name (`:user` vs `'user'`) count as the same named context. This is the documented rule. Callers that relied on the old property-level merge should pass every attribute of a named context they set.
|
|
13
|
+
- **Fix (security): evaluation-summary telemetry no longer sends the plaintext of `confidential` or `decryptWith` config values (qfg-2agi.15).** `selectedValue` now carries the `*****<hash>` redacted form the resolver already computed; before, `Client#get` reported the decrypted value. Non-confidential values are reported unchanged.
|
|
14
|
+
- **Tests: the shared integration suite now drives every case through the public `Quonfig::Client` (qfg-2agi.33).** Each case uses a datadir client and the typed getter named by its `type:` (`get_string`, `get_int`, `get_float`, `get_bool`, `get_string_list`, `get_json`, `get_duration`), `enabled?` or `get_or_raise`. Context tiers go through `global_context:`, `with_context` and `in_context`. Telemetry cases flush the client's real reporter to a local HTTP server and check the POST. Before, most cases called the internal resolver, and the harness redacted telemetry values itself, so a broken typed getter, a negated `enabled?` or dropped redaction left the suite green. No library change.
|
|
15
|
+
- **Tests: the shared integration suite now checks every duration case through the public `Client#get_duration`, with an exact integer millisecond comparison (qfg-2agi.4).** Before, it checked the internal resolver, so a bug in `get_duration` could not fail the suite. No library change.
|
|
16
|
+
|
|
17
|
+
## 1.6.0 - 2026-09-28
|
|
18
|
+
|
|
19
|
+
- **Change: a weighted rollout that hashes on a property missing from the context now hashes an empty value, so every such caller gets the same variant for that flag (qfg-9dxb.8).** Before, such evaluations got a random variant on every call. It is the same variant a context with the property set to `""` gets, and a variant with weight 0 is not served. The reason is still `SPLIT`. The `flag_metadata` of `get_*_details` results includes `'hashPropertyMissing' => true` when the property is missing, and the SDK logs one warning per flag. A weighted rollout with no hash property configured still picks a random variant on every evaluation, unchanged. Users whose context has the property keep the same variant as in 1.5.0.
|
|
20
|
+
- **Change: once the client holds a numbered config generation, a payload with generation 0 is ignored and the client keeps its current config (qfg-9dxb.9).** Only a delivery server with a damaged git store sends one. In 1.5.0 such a payload installed and could move the client back to older config. `held_generation` never goes backward. A client that has never received a real generation (for example one talking to `qfg serve`) still installs every payload.
|
|
21
|
+
- **Fix: a segment or `decryptWith` key that references itself (directly or through a chain) no longer crashes with `SystemStackError` (qfg-9dxb.7).** The looping segment reference evaluates as a missing segment. A `decryptWith` loop raises `DecryptionError`.
|
|
22
|
+
- **Fix: an HTTP 200 or SSE event that is not a config payload (for example `{}` from a proxy) is ignored instead of deleting all configs (qfg-9dxb.3).** Over HTTP the SDK tries the next server.
|
|
23
|
+
|
|
3
24
|
## 1.5.0 - 2026-09-25
|
|
4
25
|
|
|
5
26
|
- **Telemetry transport policy (qfg-y8je.8).** The telemetry POST had no timeout of its own (Faraday's defaults, 60s connect + 60s read); it now has a 15s overall deadline (`telemetry_timeout_ms`) and a 5s connect + TLS deadline (`telemetry_connect_timeout_ms`). A failed batch is kept byte-for-byte and resent (never merged with newer data, so the server dedups a resend of a batch that did land). Resends happen no sooner than 30s after a failure and honor `Retry-After` up to 10 min. The retained queue is capped at 5 batches / 2MB / 5 min (oldest dropped). At most one POST is in flight. 401/403/404 disable telemetry for the process with one ERROR; any other 4xx drops that batch with one ERROR. Before this, a failed batch was simply lost.
|
data/README.md
CHANGED
|
@@ -259,7 +259,7 @@ Quonfig::Client.new(
|
|
|
259
259
|
fallback_poll_enabled: true,
|
|
260
260
|
fallback_poll_interval_ms: 60_000,
|
|
261
261
|
init_timeout_ms: 10_000,
|
|
262
|
-
on_no_default: :
|
|
262
|
+
on_no_default: :raise,
|
|
263
263
|
global_context: {},
|
|
264
264
|
datadir: '/path/to/workspace',
|
|
265
265
|
environment: 'production',
|
|
@@ -277,7 +277,7 @@ Quonfig::Client.new(
|
|
|
277
277
|
| `fallback_poll_enabled` | `Boolean` | `true` | Engage HTTP polling as a fallback when SSE is unavailable for >= 2x `fallback_poll_interval_ms`. Deprecated alias: `enable_polling`. |
|
|
278
278
|
| `fallback_poll_interval_ms` | `Integer` (ms) | `60_000` | Interval between fallback HTTP polls, in milliseconds. Deprecated alias: `poll_interval` (seconds, multiplied by 1000 internally). |
|
|
279
279
|
| `init_timeout_ms` | `Integer` (ms) | `10_000` | Maximum time to wait for the initial config load, in milliseconds. Deprecated alias: `initialization_timeout_sec` (seconds, multiplied by 1000 internally). |
|
|
280
|
-
| `on_no_default` | `Symbol` | `:
|
|
280
|
+
| `on_no_default` | `Symbol` | `:raise` | Behavior when a key has no value and no default: `:raise` (raise `Quonfig::Errors::MissingDefaultError`) or `:return_nil`. |
|
|
281
281
|
| `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
|
|
282
282
|
| `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
|
|
283
283
|
| `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
|
|
@@ -361,6 +361,14 @@ client.get_json('homepage.layout')
|
|
|
361
361
|
client.enabled?('beta-feature', user: { key: 'user-123' })
|
|
362
362
|
```
|
|
363
363
|
|
|
364
|
+
`get_or_raise(key, default:, context:)` returns the value like `get`, but a
|
|
365
|
+
missing key without a `default:` always raises
|
|
366
|
+
`Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to), and
|
|
367
|
+
an unset or malformed ENV_VAR value, a malformed stored duration, or a
|
|
368
|
+
decryption failure raises its error. `get_duration_details(key, context:)`
|
|
369
|
+
returns `Quonfig::EvaluationDetails` with the duration in integer milliseconds,
|
|
370
|
+
like the other `*_details` getters.
|
|
371
|
+
|
|
364
372
|
## Dynamic log levels (SemanticLogger)
|
|
365
373
|
|
|
366
374
|
Quonfig can drive per-class log levels at runtime. Set config keys like
|
data/lib/quonfig/bound_client.rb
CHANGED
|
@@ -40,6 +40,10 @@ module Quonfig
|
|
|
40
40
|
@client.get_json(key, default: default, context: @context)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
def get_or_raise(key, default: NO_DEFAULT_PROVIDED)
|
|
44
|
+
@client.get_or_raise(key, default: default, context: @context)
|
|
45
|
+
end
|
|
46
|
+
|
|
43
47
|
# ---- Details getters ----------------------------------------------
|
|
44
48
|
|
|
45
49
|
def get_bool_details(key)
|
|
@@ -66,15 +70,18 @@ module Quonfig
|
|
|
66
70
|
@client.get_json_details(key, context: @context)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
def get_duration_details(key)
|
|
74
|
+
@client.get_duration_details(key, context: @context)
|
|
75
|
+
end
|
|
76
|
+
|
|
69
77
|
def enabled?(feature_name)
|
|
70
78
|
@client.enabled?(feature_name, @context)
|
|
71
79
|
end
|
|
72
80
|
|
|
73
|
-
# Returns a new BoundClient whose bound context is
|
|
74
|
-
#
|
|
75
|
-
# context (
|
|
76
|
-
#
|
|
77
|
-
# preserved.
|
|
81
|
+
# Returns a new BoundClient whose bound context is this bound context
|
|
82
|
+
# plus +additional+ (qfg-2agi.24): a named context in +additional+
|
|
83
|
+
# REPLACES the whole same-named context (no per-property merge); named
|
|
84
|
+
# contexts it does not mention are kept. Names compare as strings.
|
|
78
85
|
def in_context(additional)
|
|
79
86
|
self.class.new(@client, merge_contexts(@context, additional || {}))
|
|
80
87
|
end
|
|
@@ -87,9 +94,10 @@ module Quonfig
|
|
|
87
94
|
|
|
88
95
|
def merge_contexts(left, right)
|
|
89
96
|
merged = {}
|
|
90
|
-
left.each
|
|
97
|
+
left.each { |name, ctx| merged[name] = ctx.dup }
|
|
91
98
|
right.each do |name, ctx|
|
|
92
|
-
merged
|
|
99
|
+
merged.delete_if { |existing, _| existing.to_s == name.to_s }
|
|
100
|
+
merged[name] = ctx.dup
|
|
93
101
|
end
|
|
94
102
|
merged
|
|
95
103
|
end
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'timeout'
|
|
5
|
+
require 'set'
|
|
5
6
|
|
|
6
7
|
module Quonfig
|
|
7
8
|
# Public Quonfig SDK client.
|
|
@@ -63,6 +64,9 @@ module Quonfig
|
|
|
63
64
|
@evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
|
|
64
65
|
@resolver = Quonfig::Resolver.new(@store, @evaluator)
|
|
65
66
|
@semantic_logger_filters = {}
|
|
67
|
+
# Keys already warned about for a malformed duration value (qfg-2agi.10).
|
|
68
|
+
@malformed_duration_warned = Set.new
|
|
69
|
+
@malformed_duration_mutex = Mutex.new
|
|
66
70
|
@sse_client = nil
|
|
67
71
|
@poll_supervisor = nil
|
|
68
72
|
@stopped = false
|
|
@@ -161,14 +165,70 @@ module Quonfig
|
|
|
161
165
|
typed_get(key, :string_list, default: default, context: context)
|
|
162
166
|
end
|
|
163
167
|
|
|
168
|
+
# Integer milliseconds for a duration config (qfg-2agi.10).
|
|
169
|
+
#
|
|
170
|
+
# - A config whose valueType is not duration raises TypeMismatchError,
|
|
171
|
+
# like get_int on a string config.
|
|
172
|
+
# - Values follow the shared grammar (Quonfig::Duration); ms are rounded
|
|
173
|
+
# half up on both the stored and the ENV_VAR path.
|
|
174
|
+
# - A malformed value (stored or ENV_VAR) returns +default+ and logs one
|
|
175
|
+
# warning per key. With no default it returns nil under
|
|
176
|
+
# on_no_default: :return_nil and raises EnvVarParseError under :raise.
|
|
164
177
|
def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
165
|
-
|
|
178
|
+
ensure_initialized_after_fork
|
|
179
|
+
ctx = build_context(context)
|
|
180
|
+
record_context_for_telemetry(ctx)
|
|
181
|
+
result =
|
|
182
|
+
begin
|
|
183
|
+
@resolver.get(key, ctx)
|
|
184
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
185
|
+
nil
|
|
186
|
+
rescue Quonfig::Errors::EnvVarParseError => e
|
|
187
|
+
return malformed_duration(key, default, e)
|
|
188
|
+
end
|
|
189
|
+
return handle_missing(key, default) if result.nil?
|
|
190
|
+
|
|
191
|
+
record_evaluation_for_telemetry(result)
|
|
192
|
+
duration_millis(key, result, default)
|
|
166
193
|
end
|
|
167
194
|
|
|
168
195
|
def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
169
196
|
typed_get(key, :json, default: default, context: context)
|
|
170
197
|
end
|
|
171
198
|
|
|
199
|
+
# Like +get+, but never applies the +on_no_default+ policy (qfg-2agi.27):
|
|
200
|
+
#
|
|
201
|
+
# - A missing key (or a config with no matching value) returns +default:+
|
|
202
|
+
# when one is given and otherwise raises MissingDefaultError, even
|
|
203
|
+
# under on_no_default: :return_nil.
|
|
204
|
+
# - A config provided by an unset ENV_VAR raises MissingEnvVarError; an
|
|
205
|
+
# ENV_VAR value that cannot be coerced to the config's type raises
|
|
206
|
+
# EnvVarParseError; a decryption failure raises DecryptionError.
|
|
207
|
+
# - A malformed stored duration raises EnvVarParseError (a given
|
|
208
|
+
# +default:+ is not applied; it only covers a missing key).
|
|
209
|
+
def get_or_raise(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
210
|
+
ensure_initialized_after_fork
|
|
211
|
+
ctx = build_context(context)
|
|
212
|
+
record_context_for_telemetry(ctx)
|
|
213
|
+
result =
|
|
214
|
+
begin
|
|
215
|
+
@resolver.get(key, ctx)
|
|
216
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
if result.nil?
|
|
220
|
+
raise Quonfig::Errors::MissingDefaultError, key if default == NO_DEFAULT_PROVIDED
|
|
221
|
+
|
|
222
|
+
return default
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
record_evaluation_for_telemetry(result)
|
|
226
|
+
value = result.unwrapped_value
|
|
227
|
+
raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, value)
|
|
228
|
+
|
|
229
|
+
value
|
|
230
|
+
end
|
|
231
|
+
|
|
172
232
|
# ---- Details getters ----------------------------------------------
|
|
173
233
|
#
|
|
174
234
|
# Mirrors the typed getters above but returns a +Quonfig::EvaluationDetails+
|
|
@@ -201,6 +261,13 @@ module Quonfig
|
|
|
201
261
|
evaluate_details(key, :json, context)
|
|
202
262
|
end
|
|
203
263
|
|
|
264
|
+
# Integer milliseconds in +value+ (qfg-2agi.27). A config whose valueType
|
|
265
|
+
# is not duration is ERROR / TYPE_MISMATCH; a malformed value (stored or
|
|
266
|
+
# ENV_VAR) is ERROR with a nil value.
|
|
267
|
+
def get_duration_details(key, context: NO_DEFAULT_PROVIDED)
|
|
268
|
+
evaluate_details(key, :duration, context)
|
|
269
|
+
end
|
|
270
|
+
|
|
204
271
|
def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
|
|
205
272
|
value = get(feature_name, false, jit_context)
|
|
206
273
|
[true, 'true'].include?(value)
|
|
@@ -1153,7 +1220,9 @@ module Quonfig
|
|
|
1153
1220
|
config_type: config_field(config, :type),
|
|
1154
1221
|
conditional_value_index: result.rule_index,
|
|
1155
1222
|
weighted_value_index: result.weighted_value_index,
|
|
1156
|
-
|
|
1223
|
+
# Confidential / decryptWith values ship as the `*****<hash>` redacted
|
|
1224
|
+
# form, never the plaintext (qfg-2agi.15).
|
|
1225
|
+
selected_value: result.reportable_value.nil? ? result.unwrapped_value : result.reportable_value,
|
|
1157
1226
|
reason: result.wire_reason
|
|
1158
1227
|
)
|
|
1159
1228
|
rescue StandardError => e
|
|
@@ -1516,7 +1585,7 @@ module Quonfig
|
|
|
1516
1585
|
|
|
1517
1586
|
# Combine the customer-supplied globalContext with the optional dev
|
|
1518
1587
|
# context loaded from ~/.quonfig/tokens.json. Dev context goes UNDER the
|
|
1519
|
-
# customer's so
|
|
1588
|
+
# customer's, so an explicit customer `quonfig-user` context replaces it.
|
|
1520
1589
|
def build_initial_global_context(options)
|
|
1521
1590
|
customer = normalize_context(options.global_context)
|
|
1522
1591
|
return customer unless dev_context_enabled?(options)
|
|
@@ -1549,21 +1618,19 @@ module Quonfig
|
|
|
1549
1618
|
raise ArgumentError, "Quonfig context must be a Hash, got #{ctx.class}"
|
|
1550
1619
|
end
|
|
1551
1620
|
|
|
1552
|
-
#
|
|
1553
|
-
#
|
|
1554
|
-
#
|
|
1621
|
+
# Combine two context tiers (qfg-2agi.24): a named context in +right+
|
|
1622
|
+
# REPLACES the whole same-named context in +left+ (no per-property merge);
|
|
1623
|
+
# named contexts only in +left+ survive. Names compare as strings, so
|
|
1624
|
+
# +:user+ and +'user'+ are the same named context.
|
|
1555
1625
|
def merge_contexts(left, right)
|
|
1556
1626
|
return right || {} if left.nil? || left.empty?
|
|
1557
1627
|
return left if right.nil? || right.empty?
|
|
1558
1628
|
|
|
1559
1629
|
merged = {}
|
|
1560
|
-
left.each
|
|
1630
|
+
left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
|
|
1561
1631
|
right.each do |name, ctx|
|
|
1562
|
-
merged
|
|
1563
|
-
|
|
1564
|
-
else
|
|
1565
|
-
ctx.is_a?(Hash) ? ctx.dup : ctx
|
|
1566
|
-
end
|
|
1632
|
+
merged.delete_if { |existing, _| existing.to_s == name.to_s }
|
|
1633
|
+
merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx
|
|
1567
1634
|
end
|
|
1568
1635
|
merged
|
|
1569
1636
|
end
|
|
@@ -1623,6 +1690,10 @@ module Quonfig
|
|
|
1623
1690
|
raw_value = result.unwrapped_value
|
|
1624
1691
|
|
|
1625
1692
|
begin
|
|
1693
|
+
if expected_type == :duration
|
|
1694
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', raw_value) unless duration_result?(result)
|
|
1695
|
+
raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, raw_value)
|
|
1696
|
+
end
|
|
1626
1697
|
coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
|
|
1627
1698
|
rescue Quonfig::Errors::TypeMismatchError => e
|
|
1628
1699
|
return Quonfig::EvaluationDetails.new(
|
|
@@ -1636,13 +1707,15 @@ module Quonfig
|
|
|
1636
1707
|
end
|
|
1637
1708
|
|
|
1638
1709
|
reason = result.of_reason
|
|
1710
|
+
flag_metadata = build_flag_metadata(
|
|
1711
|
+
config_id, config_type, result.rule_index, result.weighted_value_index, reason
|
|
1712
|
+
)
|
|
1713
|
+
flag_metadata['hashPropertyMissing'] = true if result.hash_property_missing
|
|
1639
1714
|
Quonfig::EvaluationDetails.new(
|
|
1640
1715
|
value: coerced,
|
|
1641
1716
|
reason: reason,
|
|
1642
1717
|
variant: build_variant(reason, result.rule_index, result.weighted_value_index),
|
|
1643
|
-
flag_metadata:
|
|
1644
|
-
config_id, config_type, result.rule_index, result.weighted_value_index, reason
|
|
1645
|
-
)
|
|
1718
|
+
flag_metadata: flag_metadata
|
|
1646
1719
|
)
|
|
1647
1720
|
rescue StandardError => e
|
|
1648
1721
|
Quonfig::EvaluationDetails.new(
|
|
@@ -1699,6 +1772,42 @@ module Quonfig
|
|
|
1699
1772
|
coerce_and_check(key, value, expected_type)
|
|
1700
1773
|
end
|
|
1701
1774
|
|
|
1775
|
+
def duration_millis(key, result, default)
|
|
1776
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', result.unwrapped_value) unless duration_result?(result)
|
|
1777
|
+
|
|
1778
|
+
value = result.unwrapped_value
|
|
1779
|
+
millis = value.is_a?(Integer) ? value : Quonfig::Duration.parse_millis(value)
|
|
1780
|
+
return millis unless millis.nil?
|
|
1781
|
+
|
|
1782
|
+
malformed_duration(key, default, malformed_stored_duration_error(result))
|
|
1783
|
+
end
|
|
1784
|
+
|
|
1785
|
+
def duration_result?(result)
|
|
1786
|
+
config = result.config || {}
|
|
1787
|
+
value_type = config['valueType'] || config[:valueType] || config['value_type'] || config[:value_type]
|
|
1788
|
+
value_type == 'duration' || result.type == 'duration'
|
|
1789
|
+
end
|
|
1790
|
+
|
|
1791
|
+
# A stored duration string outside the shared grammar unwraps to nil.
|
|
1792
|
+
def malformed_stored_duration?(result, unwrapped)
|
|
1793
|
+
unwrapped.nil? && result.type == 'duration' && !result.raw_value.nil?
|
|
1794
|
+
end
|
|
1795
|
+
|
|
1796
|
+
def malformed_stored_duration_error(result)
|
|
1797
|
+
Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, 'stored value')
|
|
1798
|
+
end
|
|
1799
|
+
|
|
1800
|
+
def malformed_duration(key, default, error)
|
|
1801
|
+
if @malformed_duration_mutex.synchronize { @malformed_duration_warned.add?(key.to_s) }
|
|
1802
|
+
LOG.warn "[quonfig] #{key} is not a valid ISO-8601 duration; " \
|
|
1803
|
+
"#{default == NO_DEFAULT_PROVIDED ? 'no default given' : 'returning the default'}"
|
|
1804
|
+
end
|
|
1805
|
+
return default unless default == NO_DEFAULT_PROVIDED
|
|
1806
|
+
raise error if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
|
|
1807
|
+
|
|
1808
|
+
nil
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1702
1811
|
def coerce_and_check(key, value, expected_type)
|
|
1703
1812
|
case expected_type
|
|
1704
1813
|
when :bool
|
|
@@ -1711,10 +1820,12 @@ module Quonfig
|
|
|
1711
1820
|
|
|
1712
1821
|
arr
|
|
1713
1822
|
when :duration
|
|
1714
|
-
return value
|
|
1715
|
-
|
|
1823
|
+
return value if value.is_a?(Integer)
|
|
1824
|
+
|
|
1825
|
+
millis = Quonfig::Duration.parse_millis(value)
|
|
1826
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value) if millis.nil?
|
|
1716
1827
|
|
|
1717
|
-
|
|
1828
|
+
millis
|
|
1718
1829
|
when :json
|
|
1719
1830
|
# JSON values are returned as-is (Hash, Array, or scalar from the wire).
|
|
1720
1831
|
value
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Quonfig
|
|
4
|
-
ConfigEnvelope = Struct.new(:configs, :meta, keyword_init: true)
|
|
4
|
+
ConfigEnvelope = Struct.new(:configs, :meta, keyword_init: true) do
|
|
5
|
+
# qfg-9dxb.3: true when a decoded wire payload is a config envelope — a
|
|
6
|
+
# Hash carrying a +meta+ object with a non-empty +version+. api-delivery and
|
|
7
|
+
# `qfg serve` always send one; a `{}` or `{"error":"x"}` 200 from a
|
|
8
|
+
# misbehaving proxy/WAF does not, and must never be installed (it would
|
|
9
|
+
# read as "zero configs" and wipe every key on an established client).
|
|
10
|
+
def self.wire_envelope?(data)
|
|
11
|
+
return false unless data.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
meta = data['meta']
|
|
14
|
+
return false unless meta.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
version = meta['version']
|
|
17
|
+
!version.nil? && !version.to_s.empty?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
5
20
|
end
|
|
@@ -325,7 +325,11 @@ module Quonfig
|
|
|
325
325
|
envelope = parse_envelope(response.body)
|
|
326
326
|
result = install_envelope(envelope, source: source, source_index: index)
|
|
327
327
|
# Write this leg's ETag back AFTER the response (per-leg, race-free).
|
|
328
|
-
|
|
328
|
+
# Exception (qfg-9dxb.9): an ignored gen<=0 payload keeps the previous
|
|
329
|
+
# ETag. Its ETag is the git sha, and the server may later repair the
|
|
330
|
+
# generation for the SAME sha; remembering it would 304 that repair.
|
|
331
|
+
ignored_unversioned = result == :not_modified && extract_generation(envelope.meta || {}) <= 0
|
|
332
|
+
set_etag_for(index, new_etag) unless ignored_unversioned
|
|
329
333
|
# install_envelope returns :not_modified when the reject-older guard drops
|
|
330
334
|
# an equal/older payload — surface that so the caller doesn't double-count.
|
|
331
335
|
result == :not_modified ? :not_modified : :updated
|
|
@@ -339,6 +343,12 @@ module Quonfig
|
|
|
339
343
|
@logger.info "Config fetch failed: status #{response.status} from #{source}"
|
|
340
344
|
:failed
|
|
341
345
|
end
|
|
346
|
+
rescue NonEnvelopeError => e
|
|
347
|
+
# qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
|
|
348
|
+
# hedge/failover proceeds and this leg's ETag is never stored (so a junk
|
|
349
|
+
# 200 cannot pin itself through later 304s).
|
|
350
|
+
@logger.warn "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg"
|
|
351
|
+
:failed
|
|
342
352
|
rescue Faraday::ConnectionFailed => e
|
|
343
353
|
@logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
|
|
344
354
|
:failed
|
|
@@ -355,8 +365,14 @@ module Quonfig
|
|
|
355
365
|
@etag_mutex.synchronize { @etags[index || 0] = value }
|
|
356
366
|
end
|
|
357
367
|
|
|
368
|
+
# Raised by #parse_envelope for a 200 whose body is not a config envelope
|
|
369
|
+
# (qfg-9dxb.3). Caught in #fetch_from as a leg error.
|
|
370
|
+
class NonEnvelopeError < StandardError; end
|
|
371
|
+
|
|
358
372
|
def parse_envelope(body)
|
|
359
373
|
data = body.is_a?(String) ? JSON.parse(body) : body
|
|
374
|
+
raise NonEnvelopeError, 'missing meta.version' unless Quonfig::ConfigEnvelope.wire_envelope?(data)
|
|
375
|
+
|
|
360
376
|
Quonfig::ConfigEnvelope.new(
|
|
361
377
|
configs: data['configs'] || [],
|
|
362
378
|
meta: data['meta'] || {}
|
|
@@ -375,23 +391,37 @@ module Quonfig
|
|
|
375
391
|
incoming_gen = extract_generation(meta)
|
|
376
392
|
|
|
377
393
|
@install_mutex.synchronize do
|
|
378
|
-
# Reject-older install guard (canonical ordering, §5f
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
# generation
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
|
|
394
|
+
# Reject-older install guard (canonical ordering, §5f; mirrors sdk-go
|
|
395
|
+
# shouldInstall). The rule:
|
|
396
|
+
# - fresh client (nothing installed yet) -> install, whatever arrives
|
|
397
|
+
# - incoming generation <= 0 (unversioned) -> install ONLY if the held
|
|
398
|
+
# generation is 0 (the client has never held a real generation)
|
|
399
|
+
# - otherwise -> install iff incoming strictly exceeds held
|
|
400
|
+
# A same-generation snapshot is a no-op (no store churn, no install-count
|
|
401
|
+
# bump, no resolved-from change) so a duplicate leg never flaps an
|
|
402
|
+
# established client, and an OLDER snapshot (a stale secondary reached on
|
|
403
|
+
# failover) is dropped so the client never regresses. Reject-older is the
|
|
404
|
+
# whole rule — no source ranking; a newer primary landing late heals
|
|
405
|
+
# forward automatically. Applies on every network install path (initial
|
|
406
|
+
# fetch, failover/poll fetch, SSE snapshot, SSE update, fallback poller);
|
|
407
|
+
# datadir install bypasses this (it is the local source of truth and goes
|
|
408
|
+
# through Client#apply_datadir_envelope).
|
|
409
|
+
#
|
|
410
|
+
# Unversioned payloads (qfg-9dxb.9): the pre-watermark servers that sent
|
|
411
|
+
# gen 0 on every payload are long gone. Today gen 0 comes only from a
|
|
412
|
+
# server whose git store is damaged (rev-count failed) — the least
|
|
413
|
+
# trustworthy source — so it must not override a held real generation.
|
|
414
|
+
# A client that has only ever seen gen 0 (e.g. `qfg serve`) keeps
|
|
415
|
+
# installing each gen 0 payload.
|
|
416
|
+
unless should_install?(incoming_gen)
|
|
417
|
+
if incoming_gen <= 0
|
|
418
|
+
# Unversioned payload while a real generation is held: not provably
|
|
419
|
+
# older (it carries no ordering info), so a silent no-op — NOT
|
|
420
|
+
# counted as guardRejected.
|
|
421
|
+
@logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{source})"
|
|
422
|
+
return :not_modified
|
|
423
|
+
end
|
|
424
|
+
|
|
395
425
|
if incoming_gen < @held_generation
|
|
396
426
|
@logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{source})"
|
|
397
427
|
# Failover observability (qfg-41nh.18): count the guard rejection.
|
|
@@ -431,7 +461,11 @@ module Quonfig
|
|
|
431
461
|
@version = meta['version'] || meta[:version] || @version
|
|
432
462
|
@environment_id = meta['environment'] || meta[:environment] || @environment_id
|
|
433
463
|
|
|
434
|
-
|
|
464
|
+
# qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
|
|
465
|
+
# ordering info and must never LOWER a positive held generation. Under
|
|
466
|
+
# the qfg-9dxb.9 rule it only installs when held is nil/0 anyway, so
|
|
467
|
+
# this keeps held at 0 there; a fresh client seeds off it as usual.
|
|
468
|
+
@held_generation = incoming_gen if @held_generation.nil? || incoming_gen.positive?
|
|
435
469
|
@install_count += 1
|
|
436
470
|
@resolved_from_index = source_index unless source_index.nil?
|
|
437
471
|
# Failover observability (qfg-41nh.18): record which leg served this
|
|
@@ -457,6 +491,14 @@ module Quonfig
|
|
|
457
491
|
end
|
|
458
492
|
end
|
|
459
493
|
|
|
494
|
+
# The install decision for a network payload (see #install_envelope).
|
|
495
|
+
def should_install?(incoming_gen)
|
|
496
|
+
return true if @held_generation.nil?
|
|
497
|
+
return @held_generation.zero? if incoming_gen <= 0
|
|
498
|
+
|
|
499
|
+
incoming_gen > @held_generation
|
|
500
|
+
end
|
|
501
|
+
|
|
460
502
|
# Read Meta.generation (qfg-7h5d.1.1) — the monotonic per-branch commit
|
|
461
503
|
# counter the backend stamps on every envelope. Absent/garbage → 0 (an old
|
|
462
504
|
# backend that doesn't emit it, or fixture mode with no FIXTURE_GENERATION).
|
data/lib/quonfig/duration.rb
CHANGED
|
@@ -1,27 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Quonfig
|
|
4
|
+
# ISO-8601 duration parsing for the grammar every Quonfig SDK shares
|
|
5
|
+
# (integration-test-data/tests/duration/grammar.yaml, qfg-2agi.29):
|
|
6
|
+
#
|
|
7
|
+
# \AP(?:[0-9]+D)?(?:T(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+(?:\.[0-9]{1,9})?S)?)?\z
|
|
8
|
+
#
|
|
9
|
+
# plus: at least one component, no dangling T, a fraction only on S (at
|
|
10
|
+
# most 9 digits), total magnitude <= P36500D. Anchored with \A/\z (Ruby's
|
|
11
|
+
# ^/$ match at line boundaries) and [0-9] (never \d).
|
|
12
|
+
#
|
|
13
|
+
# Milliseconds use exact Rational arithmetic, rounded half up.
|
|
4
14
|
class Duration
|
|
5
|
-
PATTERN =
|
|
15
|
+
PATTERN = /\AP(?:(?<days>[0-9]+)D)?(?:T(?<time>(?:(?<hours>[0-9]+)H)?(?:(?<minutes>[0-9]+)M)?(?:(?<seconds>[0-9]+(?:\.[0-9]{1,9})?)S)?))?\z/
|
|
6
16
|
MINUTES_IN_SECONDS = 60
|
|
7
17
|
HOURS_IN_SECONDS = 60 * MINUTES_IN_SECONDS
|
|
8
18
|
DAYS_IN_SECONDS = 24 * HOURS_IN_SECONDS
|
|
19
|
+
MAX_SECONDS = 36_500 * DAYS_IN_SECONDS
|
|
9
20
|
|
|
10
21
|
def initialize(definition)
|
|
11
22
|
@seconds = self.class.parse(definition)
|
|
12
23
|
end
|
|
13
24
|
|
|
25
|
+
# Integer milliseconds for a valid duration string, or nil when +definition+
|
|
26
|
+
# is not a String or does not match the grammar.
|
|
27
|
+
def self.parse_millis(definition)
|
|
28
|
+
seconds = exact_seconds(definition)
|
|
29
|
+
return nil if seconds.nil?
|
|
30
|
+
|
|
31
|
+
(seconds * 1000).round(half: :up).to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.valid?(definition)
|
|
35
|
+
!exact_seconds(definition).nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Seconds as a Float. Raises ArgumentError when +definition+ is not a valid
|
|
39
|
+
# duration (it used to return 0 for anything unparseable).
|
|
14
40
|
def self.parse(definition)
|
|
41
|
+
seconds = exact_seconds(definition)
|
|
42
|
+
raise ArgumentError, "invalid ISO-8601 duration: #{definition.inspect}" if seconds.nil?
|
|
43
|
+
|
|
44
|
+
seconds.to_f
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Exact Rational seconds, or nil when invalid.
|
|
48
|
+
def self.exact_seconds(definition)
|
|
49
|
+
return nil unless definition.is_a?(String)
|
|
50
|
+
|
|
15
51
|
match = PATTERN.match(definition)
|
|
16
|
-
return
|
|
52
|
+
return nil if match.nil?
|
|
53
|
+
return nil if match[:days].nil? && match[:time].nil?
|
|
54
|
+
return nil if match[:time] && match[:time].empty?
|
|
55
|
+
|
|
56
|
+
total = (Integer(match[:days] || '0', 10) * DAYS_IN_SECONDS) +
|
|
57
|
+
(Integer(match[:hours] || '0', 10) * HOURS_IN_SECONDS) +
|
|
58
|
+
(Integer(match[:minutes] || '0', 10) * MINUTES_IN_SECONDS) +
|
|
59
|
+
decimal_seconds(match[:seconds])
|
|
60
|
+
return nil if total > MAX_SECONDS
|
|
61
|
+
|
|
62
|
+
total
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.decimal_seconds(text)
|
|
66
|
+
return 0 if text.nil?
|
|
17
67
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
minutes = match[:minutes]&.to_f || 0
|
|
21
|
-
seconds = match[:seconds]&.to_f || 0
|
|
68
|
+
whole, frac = text.split('.', 2)
|
|
69
|
+
return Integer(whole, 10) if frac.nil?
|
|
22
70
|
|
|
23
|
-
(
|
|
71
|
+
Integer(whole, 10) + Rational(Integer(frac, 10), 10**frac.length)
|
|
24
72
|
end
|
|
73
|
+
private_class_method :decimal_seconds
|
|
25
74
|
|
|
26
75
|
def in_seconds
|
|
27
76
|
@seconds
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -131,10 +131,15 @@ module Quonfig
|
|
|
131
131
|
|
|
132
132
|
# --- Rule evaluation ------------------------------------------------
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
# +seg_path+ holds the keys of the configs being evaluated above this one
|
|
135
|
+
# through IN_SEG / NOT_IN_SEG, so segment resolution can detect a
|
|
136
|
+
# reference cycle instead of recursing until SystemStackError
|
|
137
|
+
# (qfg-9dxb.7). It is a path, not a global visited set, so a diamond (two
|
|
138
|
+
# segments that both reference a third) still resolves.
|
|
139
|
+
def evaluate_rules(rules, context, config, seg_path = [])
|
|
135
140
|
rules.each_with_index do |rule, index|
|
|
136
141
|
criteria = Array(hget(rule, :criteria) || [])
|
|
137
|
-
next unless all_criteria_match?(criteria, context, config)
|
|
142
|
+
next unless all_criteria_match?(criteria, context, config, seg_path)
|
|
138
143
|
|
|
139
144
|
value_hash = hget(rule, :value)
|
|
140
145
|
return EvalResult.new(value: value_hash, rule_index: index, config: config)
|
|
@@ -142,8 +147,8 @@ module Quonfig
|
|
|
142
147
|
nil
|
|
143
148
|
end
|
|
144
149
|
|
|
145
|
-
def all_criteria_match?(criteria, context, config)
|
|
146
|
-
criteria.all? { |c| evaluate_criterion(c, context, config) }
|
|
150
|
+
def all_criteria_match?(criteria, context, config, seg_path)
|
|
151
|
+
criteria.all? { |c| evaluate_criterion(c, context, config, seg_path) }
|
|
147
152
|
end
|
|
148
153
|
|
|
149
154
|
# --- Per-operator evaluation ---------------------------------------
|
|
@@ -151,7 +156,7 @@ module Quonfig
|
|
|
151
156
|
# Faithful port of sdk-node/src/operators.ts evaluateCriterion. Matches
|
|
152
157
|
# context-exists / missing-context semantics (e.g. PROP_IS_NOT_ONE_OF is
|
|
153
158
|
# true when context is missing).
|
|
154
|
-
def evaluate_criterion(criterion, context,
|
|
159
|
+
def evaluate_criterion(criterion, context, config, seg_path)
|
|
155
160
|
property_name = hget(criterion, :propertyName) || ''
|
|
156
161
|
operator = hget(criterion, :operator)
|
|
157
162
|
match_value = hget(criterion, :valueToMatch)
|
|
@@ -296,7 +301,7 @@ module Quonfig
|
|
|
296
301
|
when OP_IN_SEG, OP_NOT_IN_SEG
|
|
297
302
|
if match_value
|
|
298
303
|
segment_key = to_s_nil(hget(match_value, :value))
|
|
299
|
-
found, result = resolve_segment(segment_key, context)
|
|
304
|
+
found, result = resolve_segment(segment_key, context, config, seg_path)
|
|
300
305
|
return operator == OP_NOT_IN_SEG unless found
|
|
301
306
|
|
|
302
307
|
return result == (operator == OP_IN_SEG)
|
|
@@ -319,16 +324,22 @@ module Quonfig
|
|
|
319
324
|
|
|
320
325
|
# --- Segment resolution -------------------------------------------
|
|
321
326
|
|
|
322
|
-
def resolve_segment(segment_key, context)
|
|
327
|
+
def resolve_segment(segment_key, context, config, seg_path)
|
|
323
328
|
return [false, false] if segment_key.nil? || segment_key.empty?
|
|
324
329
|
|
|
330
|
+
# A reference back onto the current evaluation path is a cycle. Treat
|
|
331
|
+
# it like a missing segment (IN_SEG false, NOT_IN_SEG true), matching
|
|
332
|
+
# sdk-go (qfg-9dxb.4).
|
|
333
|
+
current_key = hget(config, :key).to_s
|
|
334
|
+
return [false, false] if segment_key == current_key || seg_path.include?(segment_key)
|
|
335
|
+
|
|
325
336
|
seg_config = @store.get(segment_key)
|
|
326
337
|
return [false, false] if seg_config.nil?
|
|
327
338
|
|
|
328
339
|
# Segments have no environment-specific rules in the JSON shape; we
|
|
329
340
|
# evaluate against default rules only (mirrors sdk-node behaviour —
|
|
330
341
|
# evaluate_config with env_id='' falls through to default).
|
|
331
|
-
match = evaluate_rules(default_rules_of(seg_config), context, seg_config)
|
|
342
|
+
match = evaluate_rules(default_rules_of(seg_config), context, seg_config, seg_path + [current_key])
|
|
332
343
|
return [false, false] if match.nil?
|
|
333
344
|
|
|
334
345
|
raw = match.raw_value
|
|
@@ -424,14 +435,18 @@ module Quonfig
|
|
|
424
435
|
REASON_TARGETING_MATCH = 2
|
|
425
436
|
REASON_SPLIT = 3
|
|
426
437
|
|
|
427
|
-
attr_reader :value, :rule_index, :config, :reportable_value
|
|
438
|
+
attr_reader :value, :rule_index, :config, :reportable_value, :hash_property_missing
|
|
428
439
|
attr_accessor :weighted_value_index
|
|
429
440
|
|
|
430
|
-
def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil
|
|
441
|
+
def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil,
|
|
442
|
+
hash_property_missing: false)
|
|
431
443
|
@value = value
|
|
432
444
|
@rule_index = rule_index
|
|
433
445
|
@config = config
|
|
434
446
|
@weighted_value_index = weighted_value_index
|
|
447
|
+
# True when a weighted rollout's hash property was missing from the
|
|
448
|
+
# context and an empty value was hashed instead (qfg-9dxb.8).
|
|
449
|
+
@hash_property_missing = hash_property_missing
|
|
435
450
|
# Telemetry-safe substitute for #unwrapped_value. Set by Resolver when
|
|
436
451
|
# the underlying Value was confidential / decryptWith, so callers
|
|
437
452
|
# (the eval-summary aggregator) never see the plaintext. Mirrors
|
|
@@ -541,8 +556,9 @@ module Quonfig
|
|
|
541
556
|
when Numeric
|
|
542
557
|
raw.to_i
|
|
543
558
|
when String
|
|
544
|
-
|
|
545
|
-
|
|
559
|
+
# nil for a malformed value, so get() never returns the raw string or
|
|
560
|
+
# a made-up number; Client#get_duration applies the default.
|
|
561
|
+
Quonfig::Duration.parse_millis(raw)
|
|
546
562
|
when Hash
|
|
547
563
|
secs = (raw['seconds'] || raw[:seconds] || 0).to_f
|
|
548
564
|
nanos = (raw['nanos'] || raw[:nanos] || 0).to_f
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
|
+
require 'set'
|
|
4
5
|
|
|
5
6
|
module Quonfig
|
|
6
7
|
# Public-API resolver: looks up a config by key in a ConfigStore and runs
|
|
@@ -16,6 +17,7 @@ module Quonfig
|
|
|
16
17
|
# production read path (with config_loader, SSE updates, telemetry), see
|
|
17
18
|
# Quonfig::ConfigResolver — the two coexist during the JSON migration.
|
|
18
19
|
class Resolver
|
|
20
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
19
21
|
TRUE_VALUES = %w[true 1 t yes].freeze
|
|
20
22
|
# Prefix the eval-summary aggregator stamps onto redacted confidential
|
|
21
23
|
# values before the 5-char MD5 hash. Matches CONFIDENTIAL_PREFIX in
|
|
@@ -29,6 +31,9 @@ module Quonfig
|
|
|
29
31
|
def initialize(store, evaluator)
|
|
30
32
|
@store = store
|
|
31
33
|
@evaluator = evaluator
|
|
34
|
+
# Config keys already warned about a missing hash property (qfg-9dxb.8).
|
|
35
|
+
@hash_missing_warned = Set.new
|
|
36
|
+
@hash_missing_mutex = Mutex.new
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
def raw(key)
|
|
@@ -50,7 +55,8 @@ module Quonfig
|
|
|
50
55
|
return nil if eval_result.nil?
|
|
51
56
|
|
|
52
57
|
weighted_index = nil
|
|
53
|
-
|
|
58
|
+
hash_missing = []
|
|
59
|
+
resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing) do |idx|
|
|
54
60
|
weighted_index = idx
|
|
55
61
|
end
|
|
56
62
|
EvalResult.new(
|
|
@@ -58,7 +64,8 @@ module Quonfig
|
|
|
58
64
|
rule_index: eval_result.rule_index,
|
|
59
65
|
config: config,
|
|
60
66
|
weighted_value_index: weighted_index,
|
|
61
|
-
reportable_value: redacted_reportable_value(eval_result.value)
|
|
67
|
+
reportable_value: redacted_reportable_value(eval_result.value),
|
|
68
|
+
hash_property_missing: !hash_missing.empty?
|
|
62
69
|
)
|
|
63
70
|
end
|
|
64
71
|
|
|
@@ -68,32 +75,42 @@ module Quonfig
|
|
|
68
75
|
# - confidential + decryptWith → look up the key config, decrypt
|
|
69
76
|
# - everything else passes through unchanged
|
|
70
77
|
def resolve_value(value, config, context = nil, &on_weighted_index)
|
|
78
|
+
resolve_value_on_path(value, config, context, [], &on_weighted_index)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Integration shims for code that expects a ConfigResolver. Keep these
|
|
82
|
+
# narrow; the real ConfigResolver still owns the production hot path.
|
|
83
|
+
def symbolize_json_names?
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# resolve_value plus +key_path+: the config keys already being resolved
|
|
90
|
+
# above this one through decryptWith, so a decryptWith cycle raises a
|
|
91
|
+
# DecryptionError instead of recursing until SystemStackError
|
|
92
|
+
# (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
|
|
93
|
+
# gets an entry for each weighted rollout whose hash property was missing
|
|
94
|
+
# from the context (qfg-9dxb.8).
|
|
95
|
+
def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
|
|
71
96
|
return nil if value.nil?
|
|
72
97
|
|
|
73
98
|
type = vget(value, :type, 'type')
|
|
74
99
|
|
|
75
100
|
return resolve_provided(value, config) if type == 'provided'
|
|
76
101
|
|
|
77
|
-
return resolve_weighted(value, config, context, &on_weighted_index) if type == 'weighted_values'
|
|
102
|
+
return resolve_weighted(value, config, context, key_path, hash_missing, &on_weighted_index) if type == 'weighted_values'
|
|
78
103
|
|
|
79
104
|
confidential = vget(value, :confidential, 'confidential')
|
|
80
105
|
decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
|
|
81
106
|
if confidential && decrypt_with && !decrypt_with.to_s.empty?
|
|
82
107
|
return resolve_decryption(value, config, context,
|
|
83
|
-
decrypt_with)
|
|
108
|
+
decrypt_with, key_path)
|
|
84
109
|
end
|
|
85
110
|
|
|
86
111
|
value
|
|
87
112
|
end
|
|
88
113
|
|
|
89
|
-
# Integration shims for code that expects a ConfigResolver. Keep these
|
|
90
|
-
# narrow; the real ConfigResolver still owns the production hot path.
|
|
91
|
-
def symbolize_json_names?
|
|
92
|
-
false
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
private
|
|
96
|
-
|
|
97
114
|
# If +value+ is confidential or has a decryptWith key, return the
|
|
98
115
|
# `*****<5-hex>` redacted string the eval-summary telemetry aggregator
|
|
99
116
|
# should ship in place of the resolved plaintext. The hash is computed
|
|
@@ -162,7 +179,12 @@ module Quonfig
|
|
|
162
179
|
# (or fall back to a per-call random) into [0,1), then walk the variant
|
|
163
180
|
# weights until cumulative weight >= bucket. Recurses through
|
|
164
181
|
# resolve_value so nested provided/encrypted variants work too.
|
|
165
|
-
|
|
182
|
+
#
|
|
183
|
+
# A hash property that is configured but missing from the context (or
|
|
184
|
+
# nil) hashes an empty value, the same bucket as a present "", and warns
|
|
185
|
+
# once per config key (qfg-9dxb.8). With no hash property configured the
|
|
186
|
+
# pick stays random per call.
|
|
187
|
+
def resolve_weighted(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
|
|
166
188
|
payload = vget(value, :value, 'value') || {}
|
|
167
189
|
weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
|
|
168
190
|
return value unless weighted.is_a?(Array) && !weighted.empty?
|
|
@@ -182,23 +204,42 @@ module Quonfig
|
|
|
182
204
|
end
|
|
183
205
|
|
|
184
206
|
cfg_key = config_key(config)
|
|
207
|
+
if hash_value.nil? && !hash_property.to_s.empty?
|
|
208
|
+
hash_value = ''
|
|
209
|
+
hash_missing&.push(true)
|
|
210
|
+
warn_hash_property_missing(cfg_key, hash_property)
|
|
211
|
+
end
|
|
185
212
|
picker = Quonfig::WeightedValueResolver.new(weighted, cfg_key, hash_value)
|
|
186
213
|
variant, index = picker.resolve
|
|
187
214
|
on_weighted_index&.call(index)
|
|
188
215
|
variant_value = vget(variant, :value, 'value')
|
|
189
|
-
|
|
216
|
+
resolve_value_on_path(variant_value, config, context, key_path, hash_missing, &on_weighted_index)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def warn_hash_property_missing(cfg_key, hash_property)
|
|
220
|
+
return unless @hash_missing_mutex.synchronize { @hash_missing_warned.add?(cfg_key) }
|
|
221
|
+
|
|
222
|
+
LOG.warn "quonfig: weighted rollout for \"#{cfg_key}\" hashes on \"#{hash_property}\" " \
|
|
223
|
+
'which is missing from context; hashing an empty value instead'
|
|
190
224
|
end
|
|
191
225
|
|
|
192
226
|
# Recursively resolve the decryption-key config (it may itself be a
|
|
193
227
|
# provided ENV_VAR), then AES-GCM decrypt the value with that key.
|
|
194
|
-
def resolve_decryption(value, config, context, decrypt_with)
|
|
228
|
+
def resolve_decryption(value, config, context, decrypt_with, key_path)
|
|
229
|
+
key_path += [config_key(config).to_s]
|
|
230
|
+
if key_path.include?(decrypt_with.to_s)
|
|
231
|
+
raise Quonfig::Errors::DecryptionError.new(
|
|
232
|
+
config_key(config), %(decryption key config "#{decrypt_with}" is part of a decryptWith cycle)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
195
236
|
key_cfg = @store.get(decrypt_with)
|
|
196
237
|
raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
|
|
197
238
|
|
|
198
239
|
key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
|
|
199
240
|
raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
|
|
200
241
|
|
|
201
|
-
resolved_key =
|
|
242
|
+
resolved_key = resolve_value_on_path(key_match.value, key_cfg, context, key_path)
|
|
202
243
|
secret_key = vget(resolved_key, :value, 'value').to_s
|
|
203
244
|
raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
|
|
204
245
|
|
|
@@ -233,6 +274,8 @@ module Quonfig
|
|
|
233
274
|
when 'string_list'
|
|
234
275
|
env_value.split(/\s*,\s*/)
|
|
235
276
|
when 'duration'
|
|
277
|
+
raise ArgumentError, 'invalid ISO-8601 duration' unless Quonfig::Duration.valid?(env_value)
|
|
278
|
+
|
|
236
279
|
env_value
|
|
237
280
|
else
|
|
238
281
|
env_value
|
|
@@ -247,6 +290,9 @@ module Quonfig
|
|
|
247
290
|
when 'double' then 'double'
|
|
248
291
|
when 'bool' then 'bool'
|
|
249
292
|
when 'string_list' then 'string_list'
|
|
293
|
+
# Keep 'duration' so Evaluator#unwrapped_value converts to ms and
|
|
294
|
+
# get() matches the stored path (qfg-2agi.22).
|
|
295
|
+
when 'duration' then 'duration'
|
|
250
296
|
else 'string'
|
|
251
297
|
end
|
|
252
298
|
end
|
|
@@ -604,6 +604,14 @@ module Quonfig
|
|
|
604
604
|
return nil
|
|
605
605
|
end
|
|
606
606
|
|
|
607
|
+
# qfg-9dxb.3: drop a JSON event that is not a config envelope (no meta
|
|
608
|
+
# object with a non-empty version) exactly like malformed JSON — it must
|
|
609
|
+
# never reach install and wipe an established client's keys.
|
|
610
|
+
unless Quonfig::ConfigEnvelope.wire_envelope?(parsed)
|
|
611
|
+
(@logger || LOG).error 'SSE Streaming Error: non-envelope event (missing meta.version); dropped'
|
|
612
|
+
return nil
|
|
613
|
+
end
|
|
614
|
+
|
|
607
615
|
envelope = Quonfig::ConfigEnvelope.new(
|
|
608
616
|
configs: parsed['configs'] || [],
|
|
609
617
|
meta: parsed['meta'] || {}
|
data/lib/quonfig/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: quonfig
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeff Dwyer
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|