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
|
@@ -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,29 @@ 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")
|
|
450
|
+
:failed
|
|
451
|
+
rescue Quonfig::ConfigShape::AllInvalidError => e
|
|
452
|
+
# qfg-krjt: a payload with no loadable config is a leg error, so it can
|
|
453
|
+
# never install as an empty workspace.
|
|
454
|
+
cycle_log.log(:warn, "Config fetch from #{source}: #{e.message}; treating as a failed leg")
|
|
351
455
|
:failed
|
|
352
456
|
rescue Faraday::ConnectionFailed => e
|
|
353
457
|
@logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
|
|
354
458
|
:failed
|
|
355
459
|
rescue StandardError => e
|
|
356
|
-
|
|
460
|
+
cycle_log.log(:warn, "Unexpected error fetching configs from #{source}: #{e.message}")
|
|
357
461
|
:failed
|
|
358
462
|
end
|
|
359
463
|
|
|
@@ -373,10 +477,11 @@ module Quonfig
|
|
|
373
477
|
data = body.is_a?(String) ? JSON.parse(body) : body
|
|
374
478
|
raise NonEnvelopeError, 'missing meta.version' unless Quonfig::ConfigEnvelope.wire_envelope?(data)
|
|
375
479
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
)
|
|
480
|
+
meta = data['meta'] || {}
|
|
481
|
+
# Skip one malformed config, keep the rest (qfg-krjt). Raises
|
|
482
|
+
# ConfigShape::AllInvalidError when none is loadable.
|
|
483
|
+
configs = Quonfig::ConfigShape.filter(data['configs'] || [], logger: @logger, version: meta['version'])
|
|
484
|
+
Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
|
|
380
485
|
end
|
|
381
486
|
|
|
382
487
|
def short_body(response)
|
|
@@ -461,6 +566,27 @@ module Quonfig
|
|
|
461
566
|
@version = meta['version'] || meta[:version] || @version
|
|
462
567
|
@environment_id = meta['environment'] || meta[:environment] || @environment_id
|
|
463
568
|
|
|
569
|
+
# Replace the live store first, then advance the generation
|
|
570
|
+
# (qfg-goi1.10.2). An interrupt part-way through the store write
|
|
571
|
+
# (Thread#raise, Timeout) then leaves the held generation where it
|
|
572
|
+
# was, so the same-generation redelivery that follows installs again
|
|
573
|
+
# and repairs the store instead of being ignored as a duplicate.
|
|
574
|
+
#
|
|
575
|
+
# The new config set is built off to the side and swapped in with one
|
|
576
|
+
# reference write (qfg-i86q), so a concurrent lookup never sees a flag
|
|
577
|
+
# from this envelope with a segment from the last one, or a key that
|
|
578
|
+
# is momentarily absent.
|
|
579
|
+
unless @store.nil?
|
|
580
|
+
next_configs = {}
|
|
581
|
+
envelope.configs.each do |cfg|
|
|
582
|
+
key = config_key(cfg)
|
|
583
|
+
next if key.nil?
|
|
584
|
+
|
|
585
|
+
next_configs[key] = cfg
|
|
586
|
+
end
|
|
587
|
+
@store.replace_all(next_configs)
|
|
588
|
+
end
|
|
589
|
+
|
|
464
590
|
# qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
|
|
465
591
|
# ordering info and must never LOWER a positive held generation. Under
|
|
466
592
|
# the qfg-9dxb.9 rule it only installs when held is nil/0 anyway, so
|
|
@@ -473,21 +599,6 @@ module Quonfig
|
|
|
473
599
|
# SSE / datadir installs pass source_index nil and are ignored by the
|
|
474
600
|
# aggregator, so they are never counted.
|
|
475
601
|
@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
602
|
end
|
|
492
603
|
end
|
|
493
604
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Decode-time shape check for config entries (qfg-krjt; the WS8
|
|
5
|
+
# skip-one-bad-config rule, as sdk-go 2f6b98b, sdk-node qfg-goi1.10.1 and
|
|
6
|
+
# sdk-python qfg-goi1.10.5).
|
|
7
|
+
#
|
|
8
|
+
# The store keeps raw JSON and the evaluator walks it as Hashes and Arrays,
|
|
9
|
+
# so a config whose rule, criterion or default has the wrong shape, or whose
|
|
10
|
+
# weighted value has a non-integer weight, would install and then raise
|
|
11
|
+
# NoMethodError/TypeError out of every read of that key. Each delivery path
|
|
12
|
+
# (HTTP, SSE, datadir) runs its configs through .filter instead: a bad entry
|
|
13
|
+
# is skipped with a WARN naming its key, so the key reads as not found, and
|
|
14
|
+
# the rest installs. If every entry fails, .filter raises AllInvalidError so
|
|
15
|
+
# the payload is rejected and can never install as an empty workspace.
|
|
16
|
+
module ConfigShape
|
|
17
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
18
|
+
|
|
19
|
+
# Raised by .filter when a non-empty payload has no loadable config.
|
|
20
|
+
class AllInvalidError < StandardError; end
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Returns the configs that pass #problem, in order. +version+ is only used
|
|
25
|
+
# in the log line.
|
|
26
|
+
def filter(configs, logger: nil, version: nil)
|
|
27
|
+
raise AllInvalidError, 'every config failed to load: configs is not a list' unless configs.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
log = logger || LOG
|
|
30
|
+
first_problem = nil
|
|
31
|
+
kept = configs.select do |cfg|
|
|
32
|
+
reason = problem(cfg)
|
|
33
|
+
next true if reason.nil?
|
|
34
|
+
|
|
35
|
+
first_problem ||= reason
|
|
36
|
+
log.warn "[quonfig] skipping config \"#{key_of(cfg)}\" that failed to load (version #{version}): " \
|
|
37
|
+
"#{reason}; other configs are unaffected"
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
raise AllInvalidError, "every config failed to load (first: #{first_problem})" if kept.empty? && first_problem
|
|
41
|
+
|
|
42
|
+
kept
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# nil when +cfg+ has a shape the evaluator can walk, else a short reason.
|
|
46
|
+
def problem(cfg)
|
|
47
|
+
return 'config entry is not an object' unless cfg.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
key = field(cfg, 'key')
|
|
50
|
+
return 'config entry has no key' unless key.is_a?(String) && !key.empty?
|
|
51
|
+
|
|
52
|
+
default = field(cfg, 'default')
|
|
53
|
+
unless default.nil?
|
|
54
|
+
return 'default is not an object' unless default.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
reason = rules_problem(field(default, 'rules'), 'default')
|
|
57
|
+
return reason if reason
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
env = field(cfg, 'environment')
|
|
61
|
+
return nil if env.nil?
|
|
62
|
+
return 'environment is not an object' unless env.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
rules_problem(field(env, 'rules'), 'environment')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rules_problem(rules, where)
|
|
68
|
+
return nil if rules.nil?
|
|
69
|
+
return "#{where}.rules is not a list" unless rules.is_a?(Array)
|
|
70
|
+
|
|
71
|
+
rules.each_with_index do |rule, i|
|
|
72
|
+
reason = rule_problem(rule)
|
|
73
|
+
return "#{where}.rules[#{i}]: #{reason}" if reason
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def rule_problem(rule)
|
|
79
|
+
return 'rule is not an object' unless rule.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
criteria = field(rule, 'criteria')
|
|
82
|
+
unless criteria.nil?
|
|
83
|
+
return 'criteria is not a list' unless criteria.is_a?(Array)
|
|
84
|
+
|
|
85
|
+
criteria.each_with_index do |criterion, i|
|
|
86
|
+
return "criteria[#{i}] is not an object" unless criterion.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
match = field(criterion, 'valueToMatch')
|
|
89
|
+
return "criteria[#{i}].valueToMatch is not an object" unless match.nil? || match.is_a?(Hash)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
value_problem(field(rule, 'value'))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def value_problem(value)
|
|
97
|
+
return nil if value.nil?
|
|
98
|
+
return 'value is not an object' unless value.is_a?(Hash)
|
|
99
|
+
return nil unless field(value, 'type') == 'weighted_values'
|
|
100
|
+
|
|
101
|
+
weighted = field(value, 'value')
|
|
102
|
+
return 'weighted_values value is not an object' unless weighted.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
entries = field(weighted, 'weightedValues')
|
|
105
|
+
return nil if entries.nil?
|
|
106
|
+
return 'weightedValues is not a list' unless entries.is_a?(Array)
|
|
107
|
+
|
|
108
|
+
entries.each_with_index do |entry, i|
|
|
109
|
+
return "weightedValues[#{i}] is not an object" unless entry.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
# A missing weight counts as 0; a present one must be an Integer
|
|
112
|
+
# (sdk-go's int weight fails json.Unmarshal the same way).
|
|
113
|
+
next unless entry.key?('weight') || entry.key?(:weight)
|
|
114
|
+
next if field(entry, 'weight').is_a?(Integer)
|
|
115
|
+
|
|
116
|
+
return "weightedValues[#{i}].weight must be an integer (got #{field(entry, 'weight').class})"
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def field(hash, name)
|
|
122
|
+
return hash[name] if hash.key?(name)
|
|
123
|
+
|
|
124
|
+
hash[name.to_sym]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def key_of(cfg)
|
|
128
|
+
return '' unless cfg.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
key = field(cfg, 'key')
|
|
131
|
+
key.is_a?(String) ? key : ''
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/lib/quonfig/config_store.rb
CHANGED
|
@@ -3,48 +3,94 @@
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
# In-memory store of configs keyed by config key.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# Client/ConfigLoader plumbing.
|
|
6
|
+
# Integration tests and the Resolver/Evaluator trio construct this
|
|
7
|
+
# directly, independent of any Client/ConfigLoader plumbing.
|
|
9
8
|
#
|
|
10
|
-
# Thread-safety:
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# and
|
|
9
|
+
# Thread-safety (qfg-i86q): the store holds one frozen Hash snapshot behind
|
|
10
|
+
# an AtomicReference. An install builds the next Hash off to the side and
|
|
11
|
+
# swaps the reference once (#replace_all), so a reader sees the old
|
|
12
|
+
# envelope or the new one, never a mix. Matches sdk-go runtime_store.go.
|
|
13
|
+
# #set / #delete / #clear are copy-on-write under a writer mutex, for the
|
|
14
|
+
# test harness and single-key callers.
|
|
15
|
+
#
|
|
16
|
+
# One lookup can read several keys (a flag, then the segments it
|
|
17
|
+
# references, then a decryptWith key). #pinned holds one snapshot for the
|
|
18
|
+
# current thread for the duration of a block, so every read in that
|
|
19
|
+
# lookup comes from the same envelope even if an install swaps the store
|
|
20
|
+
# mid-evaluation.
|
|
16
21
|
class ConfigStore
|
|
22
|
+
EMPTY = {}.freeze
|
|
23
|
+
private_constant :EMPTY
|
|
24
|
+
|
|
17
25
|
def initialize(initial_configs = nil)
|
|
18
|
-
@
|
|
26
|
+
@write_mutex = Mutex.new
|
|
27
|
+
@pin_key = :"__quonfig_store_pin_#{object_id}"
|
|
28
|
+
@snapshot = Concurrent::AtomicReference.new(EMPTY)
|
|
19
29
|
return unless initial_configs
|
|
20
30
|
|
|
21
|
-
initial_configs.
|
|
31
|
+
replace_all(initial_configs.to_h)
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
def get(key)
|
|
25
|
-
|
|
35
|
+
snapshot[key]
|
|
26
36
|
end
|
|
27
37
|
|
|
28
38
|
def set(key, config)
|
|
29
|
-
@
|
|
39
|
+
@write_mutex.synchronize do
|
|
40
|
+
@snapshot.set(@snapshot.get.merge(key => config).freeze)
|
|
41
|
+
end
|
|
42
|
+
config
|
|
30
43
|
end
|
|
31
44
|
|
|
32
45
|
def delete(key)
|
|
33
|
-
@
|
|
46
|
+
@write_mutex.synchronize do
|
|
47
|
+
current = @snapshot.get
|
|
48
|
+
return nil unless current.key?(key)
|
|
49
|
+
|
|
50
|
+
next_map = current.dup
|
|
51
|
+
value = next_map.delete(key)
|
|
52
|
+
@snapshot.set(next_map.freeze)
|
|
53
|
+
value
|
|
54
|
+
end
|
|
34
55
|
end
|
|
35
56
|
|
|
36
57
|
def clear
|
|
37
|
-
|
|
58
|
+
replace_all(EMPTY)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Atomically replace every config with +configs+ (a Hash of key =>
|
|
62
|
+
# config). Readers see either the previous set or this one.
|
|
63
|
+
def replace_all(configs)
|
|
64
|
+
next_map = configs.frozen? ? configs : configs.dup.freeze
|
|
65
|
+
@write_mutex.synchronize { @snapshot.set(next_map) }
|
|
66
|
+
nil
|
|
38
67
|
end
|
|
39
68
|
|
|
40
69
|
def keys
|
|
41
|
-
|
|
70
|
+
snapshot.keys
|
|
42
71
|
end
|
|
43
72
|
|
|
44
73
|
def all_configs
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
74
|
+
snapshot.dup
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Run the block with this thread's reads pinned to the current snapshot.
|
|
78
|
+
# Re-entrant: a nested call keeps the outer pin.
|
|
79
|
+
def pinned
|
|
80
|
+
return yield if Thread.current[@pin_key]
|
|
81
|
+
|
|
82
|
+
Thread.current[@pin_key] = @snapshot.get
|
|
83
|
+
begin
|
|
84
|
+
yield
|
|
85
|
+
ensure
|
|
86
|
+
Thread.current[@pin_key] = nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def snapshot
|
|
93
|
+
Thread.current[@pin_key] || @snapshot.get
|
|
48
94
|
end
|
|
49
95
|
end
|
|
50
96
|
end
|
data/lib/quonfig/datadir.rb
CHANGED
|
@@ -50,18 +50,18 @@ module Quonfig
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
version = "datadir:#{datadir}"
|
|
54
|
+
# Skip one malformed config file, keep the rest (qfg-krjt).
|
|
53
55
|
Quonfig::ConfigEnvelope.new(
|
|
54
|
-
configs: configs,
|
|
55
|
-
meta: { 'version' =>
|
|
56
|
+
configs: Quonfig::ConfigShape.filter(configs, version: version),
|
|
57
|
+
meta: { 'version' => version, 'environment' => env_id }
|
|
56
58
|
)
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
# Convenience: load the envelope and populate a fresh ConfigStore.
|
|
60
62
|
def load_store(datadir, environment = nil)
|
|
61
63
|
envelope = load_envelope(datadir, environment)
|
|
62
|
-
|
|
63
|
-
envelope.configs.each { |cfg| store.set(cfg['key'], cfg) }
|
|
64
|
-
store
|
|
64
|
+
Quonfig::ConfigStore.new(envelope.configs.to_h { |cfg| [cfg['key'], cfg] })
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def resolve_environment(quonfig_path, environment)
|
|
@@ -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'],
|
|
@@ -12,8 +14,13 @@ module Quonfig
|
|
|
12
14
|
[config.key, config.value_type]
|
|
13
15
|
end
|
|
14
16
|
# +_env_var+ (the raw value) is deliberately left out of the message:
|
|
15
|
-
# an env var or stored value can be a secret (qfg-goi1.2.11).
|
|
16
|
-
|
|
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}"))
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
end
|