quonfig 1.7.2 → 1.9.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 +41 -0
- data/README.md +24 -0
- data/lib/quonfig/client.rb +158 -47
- data/lib/quonfig/config_loader.rb +141 -30
- data/lib/quonfig/config_shape.rb +134 -0
- data/lib/quonfig/config_store.rb +65 -19
- data/lib/quonfig/datadir.rb +5 -5
- data/lib/quonfig/errors/env_var_parse_error.rb +10 -3
- data/lib/quonfig/evaluator.rb +72 -14
- data/lib/quonfig/resolver.rb +47 -23
- data/lib/quonfig/semver.rb +11 -1
- data/lib/quonfig/sse_config_client.rb +229 -87
- data/lib/quonfig/stdlib_formatter.rb +12 -1
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +55 -1
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig/waits.rb +30 -0
- data/lib/quonfig/weighted_value_resolver.rb +3 -1
- data/lib/quonfig/worker_supervisor.rb +5 -0
- data/lib/quonfig.rb +2 -0
- metadata +4 -2
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -54,6 +54,16 @@ module Quonfig
|
|
|
54
54
|
OP_IS_PRESENT = 'IS_PRESENT'
|
|
55
55
|
OP_IS_NOT_PRESENT = 'IS_NOT_PRESENT'
|
|
56
56
|
|
|
57
|
+
MAX_SAFE_INTEGER = (2**53) - 1
|
|
58
|
+
|
|
59
|
+
# Timestamp grammar for PROP_BEFORE / PROP_AFTER; see #date_to_millis.
|
|
60
|
+
INTEGER_STRING = /\A-?[0-9]+\z/
|
|
61
|
+
RFC3339_TIMESTAMP = /
|
|
62
|
+
\A(?<y>[0-9]{4})-(?<mo>[0-9]{2})-(?<d>[0-9]{2})
|
|
63
|
+
T(?<h>[0-9]{2}):(?<mi>[0-9]{2}):(?<s>[0-9]{2})(?:\.(?<frac>[0-9]+))?
|
|
64
|
+
(?:Z|(?<sign>[+-])(?<oh>[0-9]{2}):(?<om>[0-9]{2}))\z
|
|
65
|
+
/x
|
|
66
|
+
|
|
57
67
|
MAGIC_CURRENT_TIME_PROPS = %w[quonfig.current-time prefab.current-time reforge.current-time].freeze
|
|
58
68
|
|
|
59
69
|
# PROP_MATCHES / PROP_DOES_NOT_MATCH match timeout (qfg-goi1.2.11). Ruby
|
|
@@ -282,8 +292,8 @@ module Quonfig
|
|
|
282
292
|
when OP_PROP_SEMVER_LESS_THAN, OP_PROP_SEMVER_EQUAL, OP_PROP_SEMVER_GREATER_THAN
|
|
283
293
|
mv = hget(match_value, :value)
|
|
284
294
|
if context_exists && context_value.is_a?(String) && mv.is_a?(String)
|
|
285
|
-
sv_ctx = SemanticVersion.parse_quietly(context_value)
|
|
286
|
-
sv_mv = SemanticVersion.parse_quietly(mv)
|
|
295
|
+
sv_ctx = SemanticVersion.parse_quietly(context_value, allow_abbreviated: true)
|
|
296
|
+
sv_mv = SemanticVersion.parse_quietly(mv, allow_abbreviated: true)
|
|
287
297
|
if sv_ctx && sv_mv
|
|
288
298
|
cmp = (sv_ctx <=> sv_mv)
|
|
289
299
|
case operator
|
|
@@ -356,12 +366,27 @@ module Quonfig
|
|
|
356
366
|
|
|
357
367
|
# --- Type coercion helpers ----------------------------------------
|
|
358
368
|
|
|
369
|
+
# Canonical string form (qfg-goi1.5 R1) for *_ONE_OF, the string
|
|
370
|
+
# operators and HIERARCHICAL_MATCH: strings as-is, booleans as
|
|
371
|
+
# "true"/"false", and an integral number below 2^53 as plain digits
|
|
372
|
+
# whatever its runtime type, so 1.0 is "1" and 5e9 is "5000000000"
|
|
373
|
+
# (Float#to_s would give "1.0" and "5.0e9").
|
|
359
374
|
def to_s_nil(v)
|
|
360
375
|
return '' if v.nil?
|
|
376
|
+
return v if v.is_a?(String)
|
|
377
|
+
return canonical_number(v) if v.is_a?(Numeric) && !v.is_a?(Integer)
|
|
361
378
|
|
|
362
379
|
v.to_s
|
|
363
380
|
end
|
|
364
381
|
|
|
382
|
+
def canonical_number(v)
|
|
383
|
+
return v.to_s unless v.real?
|
|
384
|
+
return v.to_s if v.respond_to?(:finite?) && !v.finite?
|
|
385
|
+
return v.to_s unless v == v.truncate && v.abs <= MAX_SAFE_INTEGER
|
|
386
|
+
|
|
387
|
+
v.to_i.to_s
|
|
388
|
+
end
|
|
389
|
+
|
|
365
390
|
def to_string_slice(v)
|
|
366
391
|
return [] if v.nil?
|
|
367
392
|
return v.map { |i| to_s_nil(i) } if v.is_a?(Array)
|
|
@@ -414,21 +439,43 @@ module Quonfig
|
|
|
414
439
|
[start_v || min, end_v || max]
|
|
415
440
|
end
|
|
416
441
|
|
|
442
|
+
# The ONE timestamp grammar for PROP_BEFORE / PROP_AFTER (qfg-goi1.5 R5),
|
|
443
|
+
# applied to both the context value and the criterion: a number (epoch
|
|
444
|
+
# ms, truncated), an all-digit string (epoch ms), or RFC3339 with an
|
|
445
|
+
# uppercase T and a Z or numeric offset (fractional seconds allowed,
|
|
446
|
+
# truncated to ms). Date-only, offset-less and free-form strings are not
|
|
447
|
+
# timestamps and never match. This replaces DateTime.parse, which read
|
|
448
|
+
# any of those (offset-less as UTC).
|
|
417
449
|
def date_to_millis(val)
|
|
418
450
|
case val
|
|
419
|
-
when Integer
|
|
420
|
-
val
|
|
451
|
+
when Integer
|
|
452
|
+
val
|
|
453
|
+
when Float
|
|
454
|
+
val.finite? ? val.to_i : nil
|
|
421
455
|
when String
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
456
|
+
return Integer(val, 10) if INTEGER_STRING.match?(val)
|
|
457
|
+
|
|
458
|
+
rfc3339_to_millis(val)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def rfc3339_to_millis(str)
|
|
463
|
+
m = RFC3339_TIMESTAMP.match(str)
|
|
464
|
+
return nil if m.nil?
|
|
465
|
+
|
|
466
|
+
y, mo, d, h, mi, s = %i[y mo d h mi s].map { |k| m[k].to_i }
|
|
467
|
+
return nil unless Date.valid_date?(y, mo, d) && h <= 23 && mi <= 59 && s <= 59
|
|
468
|
+
|
|
469
|
+
offset_seconds = 0
|
|
470
|
+
if m[:sign]
|
|
471
|
+
oh = m[:oh].to_i
|
|
472
|
+
om = m[:om].to_i
|
|
473
|
+
return nil if oh > 23 || om > 59
|
|
430
474
|
|
|
475
|
+
offset_seconds = ((oh * 3600) + (om * 60)) * (m[:sign] == '-' ? -1 : 1)
|
|
431
476
|
end
|
|
477
|
+
epoch_seconds = Time.utc(y, mo, d, h, mi, s).to_i - offset_seconds
|
|
478
|
+
(epoch_seconds * 1000) + (m[:frac] || '').ljust(3, '0')[0, 3].to_i
|
|
432
479
|
end
|
|
433
480
|
end
|
|
434
481
|
|
|
@@ -540,8 +587,11 @@ module Quonfig
|
|
|
540
587
|
when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
|
|
541
588
|
when 'duration' then duration_to_millis(raw)
|
|
542
589
|
when 'json'
|
|
543
|
-
# JSON values must be native JS/Ruby types on the wire.
|
|
544
|
-
|
|
590
|
+
# JSON values must be native JS/Ruby types on the wire. Hashes and
|
|
591
|
+
# Arrays are copied so a caller mutating the result cannot rewrite
|
|
592
|
+
# the cached config for every later read (qfg-goi1.10.3, sdk-go
|
|
593
|
+
# qfg-9dxb.6). Leaf values (strings included) are not copied.
|
|
594
|
+
copy_json_containers(raw)
|
|
545
595
|
else
|
|
546
596
|
raw
|
|
547
597
|
end
|
|
@@ -555,6 +605,14 @@ module Quonfig
|
|
|
555
605
|
|
|
556
606
|
private
|
|
557
607
|
|
|
608
|
+
def copy_json_containers(value)
|
|
609
|
+
case value
|
|
610
|
+
when Hash then value.to_h { |k, v| [k, copy_json_containers(v)] }
|
|
611
|
+
when Array then value.map { |v| copy_json_containers(v) }
|
|
612
|
+
else value
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
558
616
|
# Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
|
|
559
617
|
# to integer milliseconds. The wire format may either be a bare ISO
|
|
560
618
|
# string in `value` or the structured `{ seconds, nanos }` proto-style
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -47,26 +47,13 @@ module Quonfig
|
|
|
47
47
|
# the legacy "return nil if absent" shape can rescue and recover (see
|
|
48
48
|
# IntegrationTestHelpers.assert_resolved, which folds a missing-key
|
|
49
49
|
# raise into the test's expected default).
|
|
50
|
+
# The whole lookup reads one store snapshot (qfg-i86q): the flag, the
|
|
51
|
+
# segments it references and any decryptWith key all come from the same
|
|
52
|
+
# envelope even if an install lands mid-evaluation.
|
|
50
53
|
def get(key, context = nil)
|
|
51
|
-
|
|
52
|
-
raise Quonfig::Errors::MissingDefaultError, key if config.nil?
|
|
53
|
-
|
|
54
|
-
eval_result = @evaluator.evaluate_config(config, context, resolver: self)
|
|
55
|
-
return nil if eval_result.nil?
|
|
54
|
+
return get_unpinned(key, context) unless @store.respond_to?(:pinned)
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
hash_missing = []
|
|
59
|
-
resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing) do |idx|
|
|
60
|
-
weighted_index = idx
|
|
61
|
-
end
|
|
62
|
-
EvalResult.new(
|
|
63
|
-
value: resolved_value,
|
|
64
|
-
rule_index: eval_result.rule_index,
|
|
65
|
-
config: config,
|
|
66
|
-
weighted_value_index: weighted_index,
|
|
67
|
-
reportable_value: redacted_reportable_value(eval_result.value),
|
|
68
|
-
hash_property_missing: !hash_missing.empty?
|
|
69
|
-
)
|
|
56
|
+
@store.pinned { get_unpinned(key, context) }
|
|
70
57
|
end
|
|
71
58
|
|
|
72
59
|
# Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
|
|
@@ -86,20 +73,54 @@ module Quonfig
|
|
|
86
73
|
|
|
87
74
|
private
|
|
88
75
|
|
|
76
|
+
def get_unpinned(key, context)
|
|
77
|
+
config = raw(key)
|
|
78
|
+
raise Quonfig::Errors::MissingDefaultError, key if config.nil?
|
|
79
|
+
|
|
80
|
+
eval_result = @evaluator.evaluate_config(config, context, resolver: self)
|
|
81
|
+
return nil if eval_result.nil?
|
|
82
|
+
|
|
83
|
+
weighted_index = nil
|
|
84
|
+
hash_missing = []
|
|
85
|
+
selected_variants = []
|
|
86
|
+
resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing,
|
|
87
|
+
selected_variants) do |idx|
|
|
88
|
+
weighted_index = idx
|
|
89
|
+
end
|
|
90
|
+
# Redact from the selected rollout variant, not only the outer
|
|
91
|
+
# weighted_values wrapper (which is never flagged), so a confidential or
|
|
92
|
+
# decryptWith variant never reaches telemetry in plaintext (qfg-goi1.2.39).
|
|
93
|
+
reportable = redacted_reportable_value(eval_result.value) || redacted_reportable_value(selected_variants.last)
|
|
94
|
+
EvalResult.new(
|
|
95
|
+
value: resolved_value,
|
|
96
|
+
rule_index: eval_result.rule_index,
|
|
97
|
+
config: config,
|
|
98
|
+
weighted_value_index: weighted_index,
|
|
99
|
+
reportable_value: reportable,
|
|
100
|
+
hash_property_missing: !hash_missing.empty?
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
89
104
|
# resolve_value plus +key_path+: the config keys already being resolved
|
|
90
105
|
# above this one through decryptWith, so a decryptWith cycle raises a
|
|
91
106
|
# DecryptionError instead of recursing until SystemStackError
|
|
92
107
|
# (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
|
|
93
108
|
# gets an entry for each weighted rollout whose hash property was missing
|
|
94
|
-
# from the context (qfg-9dxb.8).
|
|
95
|
-
|
|
109
|
+
# from the context (qfg-9dxb.8). +selected_variants+, when given, gets the
|
|
110
|
+
# stored (pre-resolution) Value of each weighted variant picked, so get()
|
|
111
|
+
# can redact a confidential/decryptWith variant (qfg-goi1.2.39).
|
|
112
|
+
def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
|
|
113
|
+
&on_weighted_index)
|
|
96
114
|
return nil if value.nil?
|
|
97
115
|
|
|
98
116
|
type = vget(value, :type, 'type')
|
|
99
117
|
|
|
100
118
|
return resolve_provided(value, config) if type == 'provided'
|
|
101
119
|
|
|
102
|
-
|
|
120
|
+
if type == 'weighted_values'
|
|
121
|
+
return resolve_weighted(value, config, context, key_path, hash_missing, selected_variants,
|
|
122
|
+
&on_weighted_index)
|
|
123
|
+
end
|
|
103
124
|
|
|
104
125
|
confidential = vget(value, :confidential, 'confidential')
|
|
105
126
|
decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
|
|
@@ -184,7 +205,8 @@ module Quonfig
|
|
|
184
205
|
# nil) hashes an empty value, the same bucket as a present "", and warns
|
|
185
206
|
# once per config key (qfg-9dxb.8). With no hash property configured the
|
|
186
207
|
# pick stays random per call.
|
|
187
|
-
def resolve_weighted(value, config, context, key_path, hash_missing = nil,
|
|
208
|
+
def resolve_weighted(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
|
|
209
|
+
&on_weighted_index)
|
|
188
210
|
payload = vget(value, :value, 'value') || {}
|
|
189
211
|
weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
|
|
190
212
|
return value unless weighted.is_a?(Array) && !weighted.empty?
|
|
@@ -213,7 +235,9 @@ module Quonfig
|
|
|
213
235
|
variant, index = picker.resolve
|
|
214
236
|
on_weighted_index&.call(index)
|
|
215
237
|
variant_value = vget(variant, :value, 'value')
|
|
216
|
-
|
|
238
|
+
selected_variants&.push(variant_value)
|
|
239
|
+
resolve_value_on_path(variant_value, config, context, key_path, hash_missing, selected_variants,
|
|
240
|
+
&on_weighted_index)
|
|
217
241
|
end
|
|
218
242
|
|
|
219
243
|
def warn_hash_property_missing(cfg_key, hash_property)
|
data/lib/quonfig/semver.rb
CHANGED
|
@@ -18,6 +18,11 @@ class SemanticVersion
|
|
|
18
18
|
$
|
|
19
19
|
/x
|
|
20
20
|
|
|
21
|
+
# Abbreviated "x" and "x.y" (qfg-goi1.5 Q5), read as x.0.0 and x.y.0 by
|
|
22
|
+
# the PROP_SEMVER_* operators, matching LaunchDarkly. A prerelease or build
|
|
23
|
+
# suffix needs the full x.y.z.
|
|
24
|
+
ABBREVIATED_PATTERN = /\A(?<major>0|[1-9]\d*)(?:\.(?<minor>0|[1-9]\d*))?\z/
|
|
25
|
+
|
|
21
26
|
attr_reader :major, :minor, :patch, :prerelease, :build_metadata
|
|
22
27
|
|
|
23
28
|
def self.parse(version_string)
|
|
@@ -36,7 +41,12 @@ class SemanticVersion
|
|
|
36
41
|
)
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
def self.parse_quietly(version_string)
|
|
44
|
+
def self.parse_quietly(version_string, allow_abbreviated: false)
|
|
45
|
+
if allow_abbreviated && version_string.is_a?(String)
|
|
46
|
+
match = ABBREVIATED_PATTERN.match(version_string)
|
|
47
|
+
return new(major: match[:major].to_i, minor: match[:minor].to_i, patch: 0) if match
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
parse(version_string)
|
|
41
51
|
rescue ArgumentError
|
|
42
52
|
nil
|