quonfig 1.5.0 → 1.6.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 +7 -0
- data/lib/quonfig/client.rb +5 -3
- data/lib/quonfig/config_envelope.rb +16 -1
- data/lib/quonfig/config_loader.rb +61 -19
- data/lib/quonfig/evaluator.rb +25 -10
- data/lib/quonfig/resolver.rb +57 -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: 96d17a74751571b9c6099b8239b2ccaafb25a5f7e55d006cecae5cb1f8651979
|
|
4
|
+
data.tar.gz: 6bbe54bd07a5fb1e20b987ef8a8aaef417533ca8ef26000873db8a671142d4bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dcb86b3f98c5f986d61f0b0db084b992fe4c3bfefc86ef2f7d0f65487fa95dc6d66fdd4b5150dd2c0f8b6ddd819f6c0bc77980e4ae89d618d31daa3649bc48c6
|
|
7
|
+
data.tar.gz: 9faba58f93536256b216dd70dc95849adbb1c4e18b033feb9419d87d26dd8b04ec132738f4bfcbee3b0e7dca0b9051bad43971644b3312d0320ee496e8550efc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.0 - 2026-09-28
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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`.
|
|
8
|
+
- **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.
|
|
9
|
+
|
|
3
10
|
## 1.5.0 - 2026-09-25
|
|
4
11
|
|
|
5
12
|
- **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/lib/quonfig/client.rb
CHANGED
|
@@ -1636,13 +1636,15 @@ module Quonfig
|
|
|
1636
1636
|
end
|
|
1637
1637
|
|
|
1638
1638
|
reason = result.of_reason
|
|
1639
|
+
flag_metadata = build_flag_metadata(
|
|
1640
|
+
config_id, config_type, result.rule_index, result.weighted_value_index, reason
|
|
1641
|
+
)
|
|
1642
|
+
flag_metadata['hashPropertyMissing'] = true if result.hash_property_missing
|
|
1639
1643
|
Quonfig::EvaluationDetails.new(
|
|
1640
1644
|
value: coerced,
|
|
1641
1645
|
reason: reason,
|
|
1642
1646
|
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
|
-
)
|
|
1647
|
+
flag_metadata: flag_metadata
|
|
1646
1648
|
)
|
|
1647
1649
|
rescue StandardError => e
|
|
1648
1650
|
Quonfig::EvaluationDetails.new(
|
|
@@ -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/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
|
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
|
|
|
@@ -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.6.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-09-
|
|
11
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|