quonfig 1.7.1 → 1.8.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 +39 -0
- data/README.md +59 -23
- data/lib/quonfig/client.rb +147 -43
- data/lib/quonfig/config_loader.rb +129 -26
- data/lib/quonfig/datadir.rb +5 -2
- data/lib/quonfig/dev_context.rb +2 -1
- data/lib/quonfig/errors/env_var_parse_error.rb +11 -2
- data/lib/quonfig/errors/type_mismatch_error.rb +5 -1
- data/lib/quonfig/evaluator.rb +81 -15
- data/lib/quonfig/quonfig.rb +8 -0
- data/lib/quonfig/resolver.rb +22 -7
- data/lib/quonfig/semantic_logger_filter.rb +6 -0
- data/lib/quonfig/semver.rb +11 -1
- data/lib/quonfig/sse_config_client.rb +101 -54
- 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/worker_supervisor.rb +5 -0
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'uri'
|
|
4
5
|
|
|
5
6
|
module Quonfig
|
|
6
7
|
# Fetches config envelopes from the Quonfig delivery API and installs them
|
|
@@ -18,6 +19,79 @@ module Quonfig
|
|
|
18
19
|
|
|
19
20
|
CONFIGS_PATH = '/api/v2/configs'
|
|
20
21
|
|
|
22
|
+
# Rule R1 (qfg-goi1.10.26): the auth class. A fetch cycle with no 200/304
|
|
23
|
+
# on any leg and one of these on the primary leg is rejected; the client's
|
|
24
|
+
# poller then enters auth-failed (Client#start_polling).
|
|
25
|
+
AUTH_STATUSES = [401, 403, 404, 410].freeze
|
|
26
|
+
|
|
27
|
+
# Result of one fetch cycle across the configured legs. +statuses+ maps a
|
|
28
|
+
# config_api_urls index to the HTTP status that leg returned (0 = no
|
|
29
|
+
# response: timeout, connection failure). +primary_host+ is host:port of
|
|
30
|
+
# the primary leg, for the entry ERROR (never the key).
|
|
31
|
+
class FetchCycle
|
|
32
|
+
attr_reader :result, :statuses, :primary_host
|
|
33
|
+
|
|
34
|
+
def initialize(result, statuses, primary_host)
|
|
35
|
+
@result = result
|
|
36
|
+
@statuses = statuses
|
|
37
|
+
@primary_host = primary_host
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def primary_status
|
|
41
|
+
@statuses[0] || 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Rule R2: no leg returned 200/304 and the primary returned an
|
|
45
|
+
# auth-class status. A 200 the guard dropped, or a 304, never qualifies.
|
|
46
|
+
def auth_rejected?
|
|
47
|
+
return false unless @result == :failed
|
|
48
|
+
return false if @statuses.values.any? { |s| [200, 304].include?(s) }
|
|
49
|
+
|
|
50
|
+
AUTH_STATUSES.include?(primary_status)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Collects one cycle's per-leg log lines. +mode+:
|
|
55
|
+
# :normal - log at once, as always (init fetch, refresh, legacy callers)
|
|
56
|
+
# :poll - a poller cycle: hold the auth-class lines until the cycle is
|
|
57
|
+
# classified, so an entering cycle logs its one ERROR in their
|
|
58
|
+
# place (rule R5); otherwise they are logged unchanged
|
|
59
|
+
# :probe - an auth-failed probe: every line at DEBUG (rule R5)
|
|
60
|
+
class CycleLog
|
|
61
|
+
def initialize(logger, mode)
|
|
62
|
+
@logger = logger
|
|
63
|
+
@mode = mode
|
|
64
|
+
@held = []
|
|
65
|
+
@released = false
|
|
66
|
+
@mutex = Mutex.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def log(level, msg, auth: false)
|
|
70
|
+
return @logger.debug(msg) if @mode == :probe
|
|
71
|
+
|
|
72
|
+
if @mode == :poll && auth
|
|
73
|
+
hold = @mutex.synchronize do
|
|
74
|
+
@held << [level, msg] unless @released
|
|
75
|
+
!@released
|
|
76
|
+
end
|
|
77
|
+
return if hold
|
|
78
|
+
end
|
|
79
|
+
@logger.public_send(level, msg)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Log (or with +drop+, discard) the held lines. Lines from a leg that
|
|
83
|
+
# settles later are logged as they come.
|
|
84
|
+
def release(drop: false)
|
|
85
|
+
held = @mutex.synchronize do
|
|
86
|
+
@released = true
|
|
87
|
+
h = @held
|
|
88
|
+
@held = []
|
|
89
|
+
h
|
|
90
|
+
end
|
|
91
|
+
held.each { |level, msg| @logger.public_send(level, msg) } unless drop
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
21
95
|
attr_reader :version, :environment_id
|
|
22
96
|
|
|
23
97
|
# qfg-7h5d.1.9 (canonical ordering). Diagnostic surface read by the failover/
|
|
@@ -127,17 +201,32 @@ module Quonfig
|
|
|
127
201
|
# :not_modified -- a leg answered 304 (no change) and nothing installed
|
|
128
202
|
# :failed -- every fired leg failed
|
|
129
203
|
def fetch!
|
|
204
|
+
fetch_cycle!.result
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# One fetch cycle, as #fetch!, returning a FetchCycle that carries each
|
|
208
|
+
# leg's status (rule R2, qfg-goi1.10.26). +log_mode+ is one of CycleLog's
|
|
209
|
+
# modes; the poller passes :poll for an ordinary cycle and :probe while
|
|
210
|
+
# auth-failed.
|
|
211
|
+
def fetch_cycle!(log_mode: :normal)
|
|
130
212
|
urls = Array(@options.config_api_urls)
|
|
131
|
-
|
|
213
|
+
statuses = Concurrent::Map.new
|
|
214
|
+
return FetchCycle.new(:failed, statuses, '') if urls.empty?
|
|
132
215
|
|
|
216
|
+
cycle_log = CycleLog.new(@logger, log_mode)
|
|
133
217
|
# Single leg (or no secondary configured): no hedge, just fetch on the
|
|
134
218
|
# calling thread under the SEQUENTIAL per-URL timeout (config_fetch_timeout_ms
|
|
135
219
|
# is unchanged and still governs any non-hedged path). Preserves the
|
|
136
220
|
# synchronous, single-request-per-call shape the legacy/mock callers depend
|
|
137
221
|
# on.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
222
|
+
result = if urls.length < 2
|
|
223
|
+
fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses, cycle_log: cycle_log)
|
|
224
|
+
else
|
|
225
|
+
fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log)
|
|
226
|
+
end
|
|
227
|
+
cycle = FetchCycle.new(result, statuses, host_of(urls[0]))
|
|
228
|
+
cycle_log.release(drop: cycle.auth_rejected?)
|
|
229
|
+
cycle
|
|
141
230
|
end
|
|
142
231
|
|
|
143
232
|
# Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
|
|
@@ -176,7 +265,7 @@ module Quonfig
|
|
|
176
265
|
# the hedge abort inside fetch_from, and heals forward through the
|
|
177
266
|
# reject-older guard. We never join the slow leg — a hung primary must not
|
|
178
267
|
# block a successful secondary install.
|
|
179
|
-
def fetch_hedged(urls)
|
|
268
|
+
def fetch_hedged(urls, statuses: nil, cycle_log: nil)
|
|
180
269
|
hedge_delay_s = hedge_delay_ms / 1000.0
|
|
181
270
|
abort_ms = hedge_abort_ms
|
|
182
271
|
|
|
@@ -204,7 +293,7 @@ module Quonfig
|
|
|
204
293
|
Thread.new do
|
|
205
294
|
result = :failed
|
|
206
295
|
begin
|
|
207
|
-
result = fetch_from(urls[index], index, timeout_ms: abort_ms)
|
|
296
|
+
result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log)
|
|
208
297
|
rescue StandardError => e
|
|
209
298
|
@logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
|
|
210
299
|
ensure
|
|
@@ -306,7 +395,15 @@ module Quonfig
|
|
|
306
395
|
@options.respond_to?(:config_fetch_timeout_ms) ? @options.config_fetch_timeout_ms : nil
|
|
307
396
|
end
|
|
308
397
|
|
|
309
|
-
def
|
|
398
|
+
def host_of(url)
|
|
399
|
+
uri = URI(url.to_s)
|
|
400
|
+
uri.host ? "#{uri.host}:#{uri.port}" : url.to_s
|
|
401
|
+
rescue URI::InvalidURIError
|
|
402
|
+
''
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil)
|
|
406
|
+
cycle_log ||= CycleLog.new(@logger, :normal)
|
|
310
407
|
# qfg-7h5d.1.9 / .1.14: bound this single per-leg attempt so a hung upstream
|
|
311
408
|
# aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
|
|
312
409
|
# path the caller passes config_fetch_hedge_abort_ms; on the sequential /
|
|
@@ -318,6 +415,7 @@ module Quonfig
|
|
|
318
415
|
etag = etag_for(index)
|
|
319
416
|
headers['If-None-Match'] = etag if etag
|
|
320
417
|
response = conn.get(CONFIGS_PATH, headers)
|
|
418
|
+
statuses[index || 0] = response.status.to_i if statuses
|
|
321
419
|
|
|
322
420
|
case response.status
|
|
323
421
|
when 200
|
|
@@ -337,23 +435,24 @@ module Quonfig
|
|
|
337
435
|
@logger.debug "Configs not modified (304) from #{source}"
|
|
338
436
|
:not_modified
|
|
339
437
|
when 401, 403
|
|
340
|
-
|
|
438
|
+
cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}", auth: true)
|
|
341
439
|
:failed
|
|
342
440
|
else
|
|
343
|
-
|
|
441
|
+
cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{source}",
|
|
442
|
+
auth: AUTH_STATUSES.include?(response.status.to_i))
|
|
344
443
|
:failed
|
|
345
444
|
end
|
|
346
445
|
rescue NonEnvelopeError => e
|
|
347
446
|
# qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
|
|
348
447
|
# hedge/failover proceeds and this leg's ETag is never stored (so a junk
|
|
349
448
|
# 200 cannot pin itself through later 304s).
|
|
350
|
-
|
|
449
|
+
cycle_log.log(:warn, "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
|
|
351
450
|
:failed
|
|
352
451
|
rescue Faraday::ConnectionFailed => e
|
|
353
452
|
@logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
|
|
354
453
|
:failed
|
|
355
454
|
rescue StandardError => e
|
|
356
|
-
|
|
455
|
+
cycle_log.log(:warn, "Unexpected error fetching configs from #{source}: #{e.message}")
|
|
357
456
|
:failed
|
|
358
457
|
end
|
|
359
458
|
|
|
@@ -461,6 +560,25 @@ module Quonfig
|
|
|
461
560
|
@version = meta['version'] || meta[:version] || @version
|
|
462
561
|
@environment_id = meta['environment'] || meta[:environment] || @environment_id
|
|
463
562
|
|
|
563
|
+
# Replace the live store first, then advance the generation
|
|
564
|
+
# (qfg-goi1.10.2). An interrupt part-way through the store write
|
|
565
|
+
# (Thread#raise, Timeout) then leaves the held generation where it
|
|
566
|
+
# was, so the same-generation redelivery that follows installs again
|
|
567
|
+
# and repairs the store instead of being ignored as a duplicate.
|
|
568
|
+
unless @store.nil?
|
|
569
|
+
new_keys = next_map.keys.to_set
|
|
570
|
+
old_keys = @store.keys.to_set
|
|
571
|
+
# Drop keys that disappeared server-side.
|
|
572
|
+
(old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
|
|
573
|
+
|
|
574
|
+
envelope.configs.each do |cfg|
|
|
575
|
+
key = config_key(cfg)
|
|
576
|
+
next if key.nil?
|
|
577
|
+
|
|
578
|
+
@store.set(key, cfg)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
464
582
|
# qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
|
|
465
583
|
# ordering info and must never LOWER a positive held generation. Under
|
|
466
584
|
# the qfg-9dxb.9 rule it only installs when held is nil/0 anyway, so
|
|
@@ -473,21 +591,6 @@ module Quonfig
|
|
|
473
591
|
# SSE / datadir installs pass source_index nil and are ignored by the
|
|
474
592
|
# aggregator, so they are never counted.
|
|
475
593
|
@failover_aggregator&.record_resolved_from(source_index)
|
|
476
|
-
|
|
477
|
-
# Replace the live store atomically.
|
|
478
|
-
return if @store.nil?
|
|
479
|
-
|
|
480
|
-
new_keys = next_map.keys.to_set
|
|
481
|
-
old_keys = @store.keys.to_set
|
|
482
|
-
# Drop keys that disappeared server-side.
|
|
483
|
-
(old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
|
|
484
|
-
|
|
485
|
-
envelope.configs.each do |cfg|
|
|
486
|
-
key = config_key(cfg)
|
|
487
|
-
next if key.nil?
|
|
488
|
-
|
|
489
|
-
@store.set(key, cfg)
|
|
490
|
-
end
|
|
491
594
|
end
|
|
492
595
|
end
|
|
493
596
|
|
data/lib/quonfig/datadir.rb
CHANGED
|
@@ -39,7 +39,10 @@ module Quonfig
|
|
|
39
39
|
.sort
|
|
40
40
|
.each do |filename|
|
|
41
41
|
path = File.join(dir, filename)
|
|
42
|
-
|
|
42
|
+
# Always read as UTF-8: under LANG unset / LANG=C the process
|
|
43
|
+
# default is US-ASCII and JSON.parse would raise on any non-ASCII
|
|
44
|
+
# byte (qfg-goi1.2.11 / qfg-xy92).
|
|
45
|
+
raw = JSON.parse(File.read(path, encoding: 'UTF-8'))
|
|
43
46
|
raise ArgumentError, "[quonfig] config has empty key — file is not a Quonfig Config: #{path}" if raw['key'].nil? || raw['key'].to_s.empty?
|
|
44
47
|
|
|
45
48
|
coerce_numeric_values(raw)
|
|
@@ -68,7 +71,7 @@ module Quonfig
|
|
|
68
71
|
|
|
69
72
|
raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}" unless File.exist?(quonfig_path)
|
|
70
73
|
|
|
71
|
-
environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
|
|
74
|
+
environments = JSON.parse(File.read(quonfig_path, encoding: 'UTF-8')).fetch('environments', [])
|
|
72
75
|
|
|
73
76
|
raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments) if !environments.empty? && !environments.include?(environment)
|
|
74
77
|
|
data/lib/quonfig/dev_context.rb
CHANGED
|
@@ -21,7 +21,8 @@ module Quonfig
|
|
|
21
21
|
return nil unless File.exist?(path)
|
|
22
22
|
|
|
23
23
|
raw = begin
|
|
24
|
-
|
|
24
|
+
# UTF-8 regardless of the process locale (qfg-goi1.2.11 / qfg-xy92).
|
|
25
|
+
File.read(path, encoding: 'UTF-8')
|
|
25
26
|
rescue StandardError => e
|
|
26
27
|
LOG.warn "dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
|
|
27
28
|
return nil
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
module Errors
|
|
5
5
|
class EnvVarParseError < Quonfig::Error
|
|
6
|
-
|
|
6
|
+
# +stored: true+ marks a malformed stored value (no env var involved);
|
|
7
|
+
# the message then names the stored value rather than a variable.
|
|
8
|
+
def initialize(_env_var, config, env_var_name, stored: false)
|
|
7
9
|
key, value_type =
|
|
8
10
|
if config.is_a?(Hash)
|
|
9
11
|
[config[:key] || config['key'],
|
|
@@ -11,7 +13,14 @@ module Quonfig
|
|
|
11
13
|
else
|
|
12
14
|
[config.key, config.value_type]
|
|
13
15
|
end
|
|
14
|
-
|
|
16
|
+
# +_env_var+ (the raw value) is deliberately left out of the message:
|
|
17
|
+
# an env var or stored value can be a secret (qfg-goi1.2.11). The
|
|
18
|
+
# wording is the cross-SDK template pinned by integration-test-data
|
|
19
|
+
# (v2026.10.12): it names the variable, the lowercase type and the
|
|
20
|
+
# config key, never the value or a parser message quoting it.
|
|
21
|
+
type_name = value_type.to_s.downcase
|
|
22
|
+
source = stored ? 'stored value' : %(environment variable "#{env_var_name}")
|
|
23
|
+
super(%(#{source} cannot be converted to #{type_name} for config "#{key}"))
|
|
15
24
|
end
|
|
16
25
|
end
|
|
17
26
|
end
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
module Errors
|
|
5
5
|
class TypeMismatchError < Quonfig::Error
|
|
6
|
+
# The message names the key, the expected type and the actual value's
|
|
7
|
+
# class only. The value itself is never interpolated: it can be a
|
|
8
|
+
# decrypted secret, and exception messages end up in logs and error
|
|
9
|
+
# trackers (qfg-goi1.2.11).
|
|
6
10
|
def initialize(key, expected, actual_value)
|
|
7
|
-
super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}
|
|
11
|
+
super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}")
|
|
8
12
|
end
|
|
9
13
|
end
|
|
10
14
|
end
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -54,8 +54,26 @@ 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
|
|
|
69
|
+
# PROP_MATCHES / PROP_DOES_NOT_MATCH match timeout (qfg-goi1.2.11). Ruby
|
|
70
|
+
# 3.2+ supports Regexp.new(src, timeout:); Regexp::TimeoutError is a
|
|
71
|
+
# RegexpError, so a pattern that runs away (backreferences and lookarounds
|
|
72
|
+
# defeat Onigmo's memoization) does not match instead of pinning a
|
|
73
|
+
# thread. Ruby 3.0/3.1 have no timeout and are unchanged.
|
|
74
|
+
REGEX_MATCH_TIMEOUT_SECONDS = 0.1
|
|
75
|
+
REGEX_TIMEOUT_SUPPORTED = Regexp.respond_to?(:timeout)
|
|
76
|
+
|
|
59
77
|
attr_reader :store
|
|
60
78
|
attr_accessor :project_env_id, :env_id
|
|
61
79
|
|
|
@@ -218,7 +236,7 @@ module Quonfig
|
|
|
218
236
|
mv = hget(match_value, :value)
|
|
219
237
|
if context_exists && context_value.is_a?(String) && mv.is_a?(String)
|
|
220
238
|
begin
|
|
221
|
-
re = Regexp.new(mv)
|
|
239
|
+
re = REGEX_TIMEOUT_SUPPORTED ? Regexp.new(mv, timeout: REGEX_MATCH_TIMEOUT_SECONDS) : Regexp.new(mv)
|
|
222
240
|
matched = re.match?(context_value)
|
|
223
241
|
return matched == (operator == OP_PROP_MATCHES)
|
|
224
242
|
rescue RegexpError
|
|
@@ -274,8 +292,8 @@ module Quonfig
|
|
|
274
292
|
when OP_PROP_SEMVER_LESS_THAN, OP_PROP_SEMVER_EQUAL, OP_PROP_SEMVER_GREATER_THAN
|
|
275
293
|
mv = hget(match_value, :value)
|
|
276
294
|
if context_exists && context_value.is_a?(String) && mv.is_a?(String)
|
|
277
|
-
sv_ctx = SemanticVersion.parse_quietly(context_value)
|
|
278
|
-
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)
|
|
279
297
|
if sv_ctx && sv_mv
|
|
280
298
|
cmp = (sv_ctx <=> sv_mv)
|
|
281
299
|
case operator
|
|
@@ -348,12 +366,27 @@ module Quonfig
|
|
|
348
366
|
|
|
349
367
|
# --- Type coercion helpers ----------------------------------------
|
|
350
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").
|
|
351
374
|
def to_s_nil(v)
|
|
352
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)
|
|
353
378
|
|
|
354
379
|
v.to_s
|
|
355
380
|
end
|
|
356
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
|
+
|
|
357
390
|
def to_string_slice(v)
|
|
358
391
|
return [] if v.nil?
|
|
359
392
|
return v.map { |i| to_s_nil(i) } if v.is_a?(Array)
|
|
@@ -406,22 +439,44 @@ module Quonfig
|
|
|
406
439
|
[start_v || min, end_v || max]
|
|
407
440
|
end
|
|
408
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).
|
|
409
449
|
def date_to_millis(val)
|
|
410
450
|
case val
|
|
411
|
-
when Integer
|
|
412
|
-
val
|
|
451
|
+
when Integer
|
|
452
|
+
val
|
|
453
|
+
when Float
|
|
454
|
+
val.finite? ? val.to_i : nil
|
|
413
455
|
when String
|
|
414
|
-
|
|
415
|
-
begin
|
|
416
|
-
t = DateTime.parse(val)
|
|
417
|
-
return (t.to_time.to_f * 1000).to_i
|
|
418
|
-
rescue ArgumentError, TypeError
|
|
419
|
-
# not a date; try integer
|
|
420
|
-
end
|
|
421
|
-
Integer(val, exception: false)
|
|
456
|
+
return Integer(val, 10) if INTEGER_STRING.match?(val)
|
|
422
457
|
|
|
458
|
+
rfc3339_to_millis(val)
|
|
423
459
|
end
|
|
424
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
|
|
474
|
+
|
|
475
|
+
offset_seconds = ((oh * 3600) + (om * 60)) * (m[:sign] == '-' ? -1 : 1)
|
|
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
|
|
479
|
+
end
|
|
425
480
|
end
|
|
426
481
|
|
|
427
482
|
# Result of a matched config evaluation. Provides the caller with both the
|
|
@@ -532,8 +587,11 @@ module Quonfig
|
|
|
532
587
|
when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
|
|
533
588
|
when 'duration' then duration_to_millis(raw)
|
|
534
589
|
when 'json'
|
|
535
|
-
# JSON values must be native JS/Ruby types on the wire.
|
|
536
|
-
|
|
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)
|
|
537
595
|
else
|
|
538
596
|
raw
|
|
539
597
|
end
|
|
@@ -547,6 +605,14 @@ module Quonfig
|
|
|
547
605
|
|
|
548
606
|
private
|
|
549
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
|
+
|
|
550
616
|
# Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
|
|
551
617
|
# to integer milliseconds. The wire format may either be a bare ISO
|
|
552
618
|
# string in `value` or the structured `{ seconds, nanos }` proto-style
|
data/lib/quonfig/quonfig.rb
CHANGED
|
@@ -11,6 +11,14 @@ module Quonfig
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
@@lock.with_write_lock do
|
|
14
|
+
# Re-check under the lock: two threads can both pass the check above,
|
|
15
|
+
# and the loser must not build (and orphan) a second Client with its
|
|
16
|
+
# own SSE stream and telemetry thread (qfg-goi1.2.11).
|
|
17
|
+
unless @singleton.nil?
|
|
18
|
+
LOG.warn 'Quonfig already initialized.'
|
|
19
|
+
next @singleton
|
|
20
|
+
end
|
|
21
|
+
|
|
14
22
|
@singleton = Quonfig::Client.new(options)
|
|
15
23
|
end
|
|
16
24
|
end
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -56,15 +56,21 @@ module Quonfig
|
|
|
56
56
|
|
|
57
57
|
weighted_index = nil
|
|
58
58
|
hash_missing = []
|
|
59
|
-
|
|
59
|
+
selected_variants = []
|
|
60
|
+
resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing,
|
|
61
|
+
selected_variants) do |idx|
|
|
60
62
|
weighted_index = idx
|
|
61
63
|
end
|
|
64
|
+
# Redact from the selected rollout variant, not only the outer
|
|
65
|
+
# weighted_values wrapper (which is never flagged), so a confidential or
|
|
66
|
+
# decryptWith variant never reaches telemetry in plaintext (qfg-goi1.2.39).
|
|
67
|
+
reportable = redacted_reportable_value(eval_result.value) || redacted_reportable_value(selected_variants.last)
|
|
62
68
|
EvalResult.new(
|
|
63
69
|
value: resolved_value,
|
|
64
70
|
rule_index: eval_result.rule_index,
|
|
65
71
|
config: config,
|
|
66
72
|
weighted_value_index: weighted_index,
|
|
67
|
-
reportable_value:
|
|
73
|
+
reportable_value: reportable,
|
|
68
74
|
hash_property_missing: !hash_missing.empty?
|
|
69
75
|
)
|
|
70
76
|
end
|
|
@@ -91,15 +97,21 @@ module Quonfig
|
|
|
91
97
|
# DecryptionError instead of recursing until SystemStackError
|
|
92
98
|
# (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
|
|
93
99
|
# gets an entry for each weighted rollout whose hash property was missing
|
|
94
|
-
# from the context (qfg-9dxb.8).
|
|
95
|
-
|
|
100
|
+
# from the context (qfg-9dxb.8). +selected_variants+, when given, gets the
|
|
101
|
+
# stored (pre-resolution) Value of each weighted variant picked, so get()
|
|
102
|
+
# can redact a confidential/decryptWith variant (qfg-goi1.2.39).
|
|
103
|
+
def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
|
|
104
|
+
&on_weighted_index)
|
|
96
105
|
return nil if value.nil?
|
|
97
106
|
|
|
98
107
|
type = vget(value, :type, 'type')
|
|
99
108
|
|
|
100
109
|
return resolve_provided(value, config) if type == 'provided'
|
|
101
110
|
|
|
102
|
-
|
|
111
|
+
if type == 'weighted_values'
|
|
112
|
+
return resolve_weighted(value, config, context, key_path, hash_missing, selected_variants,
|
|
113
|
+
&on_weighted_index)
|
|
114
|
+
end
|
|
103
115
|
|
|
104
116
|
confidential = vget(value, :confidential, 'confidential')
|
|
105
117
|
decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
|
|
@@ -184,7 +196,8 @@ module Quonfig
|
|
|
184
196
|
# nil) hashes an empty value, the same bucket as a present "", and warns
|
|
185
197
|
# once per config key (qfg-9dxb.8). With no hash property configured the
|
|
186
198
|
# pick stays random per call.
|
|
187
|
-
def resolve_weighted(value, config, context, key_path, hash_missing = nil,
|
|
199
|
+
def resolve_weighted(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
|
|
200
|
+
&on_weighted_index)
|
|
188
201
|
payload = vget(value, :value, 'value') || {}
|
|
189
202
|
weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
|
|
190
203
|
return value unless weighted.is_a?(Array) && !weighted.empty?
|
|
@@ -213,7 +226,9 @@ module Quonfig
|
|
|
213
226
|
variant, index = picker.resolve
|
|
214
227
|
on_weighted_index&.call(index)
|
|
215
228
|
variant_value = vget(variant, :value, 'value')
|
|
216
|
-
|
|
229
|
+
selected_variants&.push(variant_value)
|
|
230
|
+
resolve_value_on_path(variant_value, config, context, key_path, hash_missing, selected_variants,
|
|
231
|
+
&on_weighted_index)
|
|
217
232
|
end
|
|
218
233
|
|
|
219
234
|
def warn_hash_property_missing(cfg_key, hash_property)
|
|
@@ -52,6 +52,10 @@ module Quonfig
|
|
|
52
52
|
|
|
53
53
|
# SemanticLogger filter contract: return true to emit, false to suppress.
|
|
54
54
|
# Missing config key → return true so SemanticLogger's static level decides.
|
|
55
|
+
# A resolution error (unset ENV_VAR, decryption failure, ...) is treated
|
|
56
|
+
# the same way: the filter runs inside the host app's log calls and must
|
|
57
|
+
# never raise out of them (qfg-goi1.2.11). Nothing is logged here, since
|
|
58
|
+
# logging from inside a log filter can recurse.
|
|
55
59
|
def call(log)
|
|
56
60
|
configured = @client.get(@config_key, nil, context_for(log))
|
|
57
61
|
return true if configured.nil?
|
|
@@ -59,6 +63,8 @@ module Quonfig
|
|
|
59
63
|
log_severity = LEVELS[log.level] || LEVELS[:debug]
|
|
60
64
|
min_severity = LEVELS[normalize_level(configured)] || LEVELS[:debug]
|
|
61
65
|
log_severity >= min_severity
|
|
66
|
+
rescue StandardError
|
|
67
|
+
true
|
|
62
68
|
end
|
|
63
69
|
|
|
64
70
|
private
|
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
|