quonfig 0.0.6 → 0.0.8
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 +29 -0
- data/VERSION +1 -1
- data/lib/quonfig/client.rb +109 -2
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/errors/decryption_error.rb +20 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
- data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
- data/lib/quonfig/errors/missing_environment_error.rb +18 -0
- data/lib/quonfig/evaluator.rb +64 -2
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/resolver.rb +187 -2
- data/lib/quonfig/stdlib_formatter.rb +95 -0
- data/lib/quonfig/telemetry/context_shape.rb +33 -0
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
- data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
- data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
- data/lib/quonfig.rb +8 -0
- data/quonfig.gemspec +20 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_enabled.rb +157 -463
- data/test/integration/test_enabled_with_contexts.rb +19 -49
- data/test/integration/test_get.rb +43 -131
- data/test/integration/test_get_feature_flag.rb +7 -13
- data/test/integration/test_get_or_raise.rb +19 -45
- data/test/integration/test_get_weighted_values.rb +9 -4
- data/test/integration/test_helpers.rb +499 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +63 -21
- data/test/test_client_telemetry.rb +132 -0
- data/test/test_context.rb +4 -1
- data/test/test_context_shape.rb +37 -0
- data/test/test_context_shape_aggregator.rb +126 -0
- data/test/test_datadir.rb +6 -2
- data/test/test_evaluation_summaries_aggregator.rb +180 -0
- data/test/test_example_contexts_aggregator.rb +119 -0
- data/test/test_http_connection.rb +1 -1
- data/test/test_resolver.rb +149 -2
- data/test/test_should_log.rb +186 -0
- data/test/test_stdlib_formatter.rb +195 -0
- data/test/test_telemetry_reporter.rb +209 -0
- metadata +19 -3
- data/scripts/generate_integration_tests.rb +0 -362
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18707780a8ad33c299973f4de01bc0b76d10161902f977b9771c10c2d4a23fde
|
|
4
|
+
data.tar.gz: 6980b904632659b97f16636e9a317bc8b3e8cbf02cc0b8f75ecf51325b8ba472
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74c6a59b81fd222ddd522e52301ea74a1f9bc76b18ecbfe50c01a46e21539bdc5cea6a38a927938fb497f6003a64a2e796d177f2ed3ee527d5891f6cd9ca63a4
|
|
7
|
+
data.tar.gz: 94b1c109e6db3df2ea115e62f80f099430752779faa9655ee6f53c20c614fa42ccdc8e350342985cb09861e286bd91b857381508849a81fada865927c98f7f22
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.8 - 2026-04-26
|
|
4
|
+
|
|
5
|
+
- **Fix (gemspec): drop deleted `scripts/` entry from manifest** — regenerated
|
|
6
|
+
juwelier gemspec so `gem build` no longer fails on the missing
|
|
7
|
+
`scripts/generate_integration_tests.rb` file. Also untracked stray
|
|
8
|
+
`.DS_Store`. v0.0.7 was tagged but never published due to this bug.
|
|
9
|
+
|
|
10
|
+
## 0.0.7 - 2026-04-26
|
|
11
|
+
|
|
12
|
+
- **New: `client.enabled?` / `client.default` / `client.client_construction` integration helpers** —
|
|
13
|
+
Adds aggregator helpers used by the cross-SDK post + telemetry integration suites.
|
|
14
|
+
- **New: telemetry eval-summaries aggregator + `at_exit` drain (qfg-9x7)** —
|
|
15
|
+
Periodically batches evaluation summaries and drains them on process exit so
|
|
16
|
+
short-lived scripts still report telemetry.
|
|
17
|
+
- **New: context telemetry aggregators ported from sdk-node** — context shapes
|
|
18
|
+
and example-contexts ship through the same aggregator path as sdk-node and
|
|
19
|
+
sdk-go.
|
|
20
|
+
- **New errors: `DecryptionError`, `MissingEnvironmentError`,
|
|
21
|
+
`InvalidEnvironmentError`** — explicit error classes raised from the resolver
|
|
22
|
+
and datadir loaders.
|
|
23
|
+
- **Resolver: provided ENV_VAR resolution + coercion (qfg-08q)** — config values
|
|
24
|
+
marked `provided` now resolve from the environment at evaluation time and are
|
|
25
|
+
coerced to the declared value type.
|
|
26
|
+
- **Fix (resolver): raise on missing key, decode weighted/duration/decryption** —
|
|
27
|
+
`get_or_raise` now raises `MissingDefaultError` for unknown keys, and weighted /
|
|
28
|
+
duration / decryption value types decode correctly through the JSON resolver.
|
|
29
|
+
- **Fix (context): `grouped_key` drops anonymous contexts** — anonymous contexts
|
|
30
|
+
are no longer mixed into the grouped-context key, matching sdk-node and sdk-go.
|
|
31
|
+
|
|
3
32
|
## 0.0.6 - 2026-04-22
|
|
4
33
|
|
|
5
34
|
- **New: `Quonfig::StdlibFormatter` + `client.stdlib_formatter(logger_name:)`** —
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.8
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Quonfig
|
|
|
21
21
|
LOG = Quonfig::InternalLogger.new(self)
|
|
22
22
|
|
|
23
23
|
attr_reader :options, :resolver, :store, :evaluator, :instance_hash,
|
|
24
|
-
:config_loader
|
|
24
|
+
:config_loader, :telemetry_reporter
|
|
25
25
|
|
|
26
26
|
def initialize(options = nil, store: nil, **option_kwargs)
|
|
27
27
|
@options =
|
|
@@ -41,6 +41,7 @@ module Quonfig
|
|
|
41
41
|
@sse_client = nil
|
|
42
42
|
@poll_thread = nil
|
|
43
43
|
@stopped = false
|
|
44
|
+
@telemetry_reporter = nil
|
|
44
45
|
|
|
45
46
|
# If the caller injected a store, we're in test/bootstrap mode; skip I/O.
|
|
46
47
|
return if store
|
|
@@ -50,15 +51,27 @@ module Quonfig
|
|
|
50
51
|
else
|
|
51
52
|
initialize_network_mode
|
|
52
53
|
end
|
|
54
|
+
|
|
55
|
+
initialize_telemetry
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
# ---- Lookup --------------------------------------------------------
|
|
56
59
|
|
|
57
60
|
def get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
|
|
58
61
|
ctx = build_context(jit_context)
|
|
59
|
-
|
|
62
|
+
record_context_for_telemetry(ctx)
|
|
63
|
+
result =
|
|
64
|
+
begin
|
|
65
|
+
@resolver.get(key, ctx)
|
|
66
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
67
|
+
# The Resolver raises (matching Quonfig.get_or_raise semantics).
|
|
68
|
+
# The Client's get applies the caller-provided default *or* the
|
|
69
|
+
# configured on_no_default policy via handle_missing.
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
60
72
|
return handle_missing(key, default) if result.nil?
|
|
61
73
|
|
|
74
|
+
record_evaluation_for_telemetry(result)
|
|
62
75
|
result.unwrapped_value
|
|
63
76
|
end
|
|
64
77
|
|
|
@@ -223,6 +236,13 @@ module Quonfig
|
|
|
223
236
|
thread = @poll_thread
|
|
224
237
|
@poll_thread = nil
|
|
225
238
|
thread&.kill
|
|
239
|
+
|
|
240
|
+
begin
|
|
241
|
+
@telemetry_reporter&.stop
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
LOG.debug "Error stopping telemetry reporter: #{e.message}"
|
|
244
|
+
end
|
|
245
|
+
@telemetry_reporter = nil
|
|
226
246
|
end
|
|
227
247
|
|
|
228
248
|
def fork
|
|
@@ -235,6 +255,93 @@ module Quonfig
|
|
|
235
255
|
|
|
236
256
|
private
|
|
237
257
|
|
|
258
|
+
# Construct and start the telemetry reporter if the options permit it.
|
|
259
|
+
# The reporter runs on a background thread and periodically POSTs
|
|
260
|
+
# context-shape and example-context batches to +telemetry_destination+.
|
|
261
|
+
def initialize_telemetry
|
|
262
|
+
shape_aggregator = nil
|
|
263
|
+
example_aggregator = nil
|
|
264
|
+
summaries_aggregator = nil
|
|
265
|
+
|
|
266
|
+
if @options.collect_max_shapes.to_i > 0
|
|
267
|
+
shape_aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(
|
|
268
|
+
max_shapes: @options.collect_max_shapes
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
if @options.collect_max_example_contexts.to_i > 0
|
|
273
|
+
example_aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(
|
|
274
|
+
max_contexts: @options.collect_max_example_contexts
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if @options.collect_max_evaluation_summaries.to_i > 0
|
|
279
|
+
summaries_aggregator = Quonfig::Telemetry::EvaluationSummariesAggregator.new(
|
|
280
|
+
max_keys: @options.collect_max_evaluation_summaries
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
return if shape_aggregator.nil? && example_aggregator.nil? && summaries_aggregator.nil?
|
|
285
|
+
|
|
286
|
+
@telemetry_reporter = Quonfig::Telemetry::TelemetryReporter.new(
|
|
287
|
+
options: @options,
|
|
288
|
+
instance_hash: @instance_hash,
|
|
289
|
+
context_shape_aggregator: shape_aggregator,
|
|
290
|
+
example_contexts_aggregator: example_aggregator,
|
|
291
|
+
evaluation_summaries_aggregator: summaries_aggregator,
|
|
292
|
+
sync_interval: @options.collect_sync_interval
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return unless @telemetry_reporter.enabled?
|
|
296
|
+
|
|
297
|
+
@telemetry_reporter.start
|
|
298
|
+
rescue StandardError => e
|
|
299
|
+
LOG.warn "[quonfig] Telemetry init failed: #{e.class}: #{e.message}"
|
|
300
|
+
@telemetry_reporter = nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Feed a matched EvalResult into the evaluation_summaries aggregator.
|
|
304
|
+
# A no-op when telemetry is disabled or eval-summaries collection is off.
|
|
305
|
+
def record_evaluation_for_telemetry(result)
|
|
306
|
+
return if @telemetry_reporter.nil?
|
|
307
|
+
return if result.nil?
|
|
308
|
+
|
|
309
|
+
config = result.config
|
|
310
|
+
return if config.nil?
|
|
311
|
+
|
|
312
|
+
@telemetry_reporter.record_evaluation(
|
|
313
|
+
config_id: config_field(config, :id),
|
|
314
|
+
config_key: config_field(config, :key),
|
|
315
|
+
config_type: config_field(config, :type),
|
|
316
|
+
conditional_value_index: result.rule_index,
|
|
317
|
+
weighted_value_index: nil,
|
|
318
|
+
selected_value: result.unwrapped_value,
|
|
319
|
+
reason: result.wire_reason
|
|
320
|
+
)
|
|
321
|
+
rescue StandardError => e
|
|
322
|
+
LOG.debug "[quonfig] Telemetry record_evaluation error: #{e.class}: #{e.message}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def config_field(config, key)
|
|
326
|
+
return nil if config.nil?
|
|
327
|
+
|
|
328
|
+
config[key.to_s] || config[key.to_sym]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Feed every evaluated context into the telemetry aggregators. A no-op
|
|
332
|
+
# when telemetry is disabled or no aggregators are active.
|
|
333
|
+
def record_context_for_telemetry(context)
|
|
334
|
+
return if @telemetry_reporter.nil?
|
|
335
|
+
return if context.nil?
|
|
336
|
+
|
|
337
|
+
context_obj = context.is_a?(Quonfig::Context) ? context : Quonfig::Context.new(context)
|
|
338
|
+
return if context_obj.blank?
|
|
339
|
+
|
|
340
|
+
@telemetry_reporter.record(context_obj)
|
|
341
|
+
rescue StandardError => e
|
|
342
|
+
LOG.debug "[quonfig] Telemetry record error: #{e.class}: #{e.message}"
|
|
343
|
+
end
|
|
344
|
+
|
|
238
345
|
def load_datadir_into_store
|
|
239
346
|
envelope = Quonfig::Datadir.load_envelope(@options.datadir, @options.environment)
|
|
240
347
|
envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
|
data/lib/quonfig/context.rb
CHANGED
|
@@ -85,8 +85,17 @@ module Quonfig
|
|
|
85
85
|
@contexts[name.to_s] || NamedContext.new(name, {})
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Concatenate each named context's `key` (or `trackingId`) value into
|
|
89
|
+
# a stable identifier used for example-contexts dedupe. Mirrors
|
|
90
|
+
# sdk-node's groupedKey: contexts that don't have a `key` property
|
|
91
|
+
# contribute nothing — the resulting string is empty for "anonymous"
|
|
92
|
+
# contexts so the example aggregator can drop them entirely.
|
|
88
93
|
def grouped_key
|
|
89
|
-
@contexts.map
|
|
94
|
+
@contexts.values.map do |ctx|
|
|
95
|
+
h = ctx.to_h
|
|
96
|
+
v = h['key'] || h[:key] || h['trackingId'] || h[:trackingId]
|
|
97
|
+
v.nil? ? nil : v.to_s
|
|
98
|
+
end.compact.reject(&:empty?).sort.join('|')
|
|
90
99
|
end
|
|
91
100
|
|
|
92
101
|
include Comparable
|
data/lib/quonfig/datadir.rb
CHANGED
|
@@ -59,8 +59,7 @@ module Quonfig
|
|
|
59
59
|
environment ||= ENV['QUONFIG_ENVIRONMENT']
|
|
60
60
|
|
|
61
61
|
if environment.nil? || environment.empty?
|
|
62
|
-
raise
|
|
63
|
-
'[quonfig] Environment required for datadir mode; set the `environment` option or QUONFIG_ENVIRONMENT env var'
|
|
62
|
+
raise Quonfig::Errors::MissingEnvironmentError
|
|
64
63
|
end
|
|
65
64
|
|
|
66
65
|
unless File.exist?(quonfig_path)
|
|
@@ -70,8 +69,7 @@ module Quonfig
|
|
|
70
69
|
environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
|
|
71
70
|
|
|
72
71
|
if !environments.empty? && !environments.include?(environment)
|
|
73
|
-
raise
|
|
74
|
-
"[quonfig] Environment \"#{environment}\" not found in workspace; available environments: #{environments.join(', ')}"
|
|
72
|
+
raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments)
|
|
75
73
|
end
|
|
76
74
|
|
|
77
75
|
environment
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
# Raised when a confidential config's ciphertext cannot be decrypted —
|
|
6
|
+
# either the configured `decryptWith` key is missing/empty, or the
|
|
7
|
+
# AES-GCM payload itself is malformed/tampered.
|
|
8
|
+
#
|
|
9
|
+
# Mirrors sdk-python's QuonfigDecryptionError. Sdk-node currently
|
|
10
|
+
# raises plain `Error` for the same path; this class is the Ruby
|
|
11
|
+
# equivalent of the dedicated exception type.
|
|
12
|
+
class DecryptionError < Quonfig::Error
|
|
13
|
+
def initialize(key, cause = nil)
|
|
14
|
+
message = "Decryption failed for config '#{key}'"
|
|
15
|
+
message += ": #{cause}" if cause && !cause.to_s.empty?
|
|
16
|
+
super(message)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -4,7 +4,14 @@ module Quonfig
|
|
|
4
4
|
module Errors
|
|
5
5
|
class EnvVarParseError < Quonfig::Error
|
|
6
6
|
def initialize(env_var, config, env_var_name)
|
|
7
|
-
|
|
7
|
+
key, value_type =
|
|
8
|
+
if config.is_a?(Hash)
|
|
9
|
+
[config[:key] || config['key'],
|
|
10
|
+
config[:value_type] || config['value_type'] || config['valueType']]
|
|
11
|
+
else
|
|
12
|
+
[config.key, config.value_type]
|
|
13
|
+
end
|
|
14
|
+
super("Evaluating #{key} couldn't coerce #{env_var_name} of #{env_var} to #{value_type}")
|
|
8
15
|
end
|
|
9
16
|
end
|
|
10
17
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
# Raised when the requested environment (via `environment:` option or
|
|
6
|
+
# QUONFIG_ENVIRONMENT) isn't listed in the workspace's `quonfig.json`.
|
|
7
|
+
# Catches typos like `"prdoduction"` early instead of silently
|
|
8
|
+
# evaluating against default rules.
|
|
9
|
+
class InvalidEnvironmentError < Quonfig::Error
|
|
10
|
+
def initialize(environment, available = nil)
|
|
11
|
+
message = "[quonfig] Environment \"#{environment}\" not found in workspace"
|
|
12
|
+
if available && !Array(available).empty?
|
|
13
|
+
message += "; available environments: #{Array(available).join(', ')}"
|
|
14
|
+
end
|
|
15
|
+
super(message)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
# Raised when datadir mode is engaged but no environment was supplied
|
|
6
|
+
# (neither the `environment:` option nor the QUONFIG_ENVIRONMENT env
|
|
7
|
+
# var is set). Datadir mode requires an explicit environment; without
|
|
8
|
+
# one the loader cannot pick the right environment row from each
|
|
9
|
+
# config's `environments` array.
|
|
10
|
+
class MissingEnvironmentError < Quonfig::Error
|
|
11
|
+
def initialize(message = nil)
|
|
12
|
+
message ||= '[quonfig] Environment required for datadir mode; ' \
|
|
13
|
+
'set the `environment` option or QUONFIG_ENVIRONMENT env var'
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -409,14 +409,54 @@ module Quonfig
|
|
|
409
409
|
# raw JSON Value hash (#value) and a coerced Ruby value (#unwrapped_value).
|
|
410
410
|
# The test suite and integration helpers consume both shapes.
|
|
411
411
|
class EvalResult
|
|
412
|
+
# Integer reason codes for the api-telemetry EvalSummaries wire
|
|
413
|
+
# format. Match sdk-node/src/reason.ts.
|
|
414
|
+
REASON_UNKNOWN = 0
|
|
415
|
+
REASON_STATIC = 1
|
|
416
|
+
REASON_TARGETING_MATCH = 2
|
|
417
|
+
REASON_SPLIT = 3
|
|
418
|
+
|
|
412
419
|
attr_reader :value, :rule_index, :config
|
|
420
|
+
attr_accessor :weighted_value_index
|
|
413
421
|
|
|
414
|
-
def initialize(value:, rule_index:, config:)
|
|
422
|
+
def initialize(value:, rule_index:, config:, weighted_value_index: nil)
|
|
415
423
|
@value = value
|
|
416
424
|
@rule_index = rule_index
|
|
417
425
|
@config = config
|
|
426
|
+
@weighted_value_index = weighted_value_index
|
|
418
427
|
end
|
|
419
428
|
|
|
429
|
+
# Integer reason code for telemetry. Mirrors sdk-node's computeReason:
|
|
430
|
+
# SPLIT when a weighted variant is picked, STATIC when the first rule
|
|
431
|
+
# of a config with no targeting rules matched, otherwise TARGETING_MATCH.
|
|
432
|
+
def wire_reason
|
|
433
|
+
return REASON_SPLIT unless @weighted_value_index.nil?
|
|
434
|
+
return REASON_STATIC if @rule_index == 0 && !EvalResult.send(:targeting_rules?, @config)
|
|
435
|
+
|
|
436
|
+
REASON_TARGETING_MATCH
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# True if any rule on the config (default or environment) has a
|
|
440
|
+
# non-ALWAYS_TRUE criterion. Used to decide STATIC vs TARGETING_MATCH.
|
|
441
|
+
def self.targeting_rules?(config)
|
|
442
|
+
return false if config.nil?
|
|
443
|
+
|
|
444
|
+
rules = []
|
|
445
|
+
%i[default environment].each do |section_key|
|
|
446
|
+
section = config[section_key.to_s] || config[section_key]
|
|
447
|
+
next if section.nil?
|
|
448
|
+
|
|
449
|
+
section_rules = section['rules'] || section[:rules] || []
|
|
450
|
+
rules.concat(section_rules)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
rules.any? do |rule|
|
|
454
|
+
criteria = rule['criteria'] || rule[:criteria] || []
|
|
455
|
+
criteria.any? { |c| (c['operator'] || c[:operator]) != 'ALWAYS_TRUE' }
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
private_class_method :targeting_rules?
|
|
459
|
+
|
|
420
460
|
# Raw underlying value without type coercion.
|
|
421
461
|
def raw_value
|
|
422
462
|
return nil if @value.nil?
|
|
@@ -446,7 +486,7 @@ module Quonfig
|
|
|
446
486
|
when 'string' then raw.to_s
|
|
447
487
|
when 'string_list' then raw.is_a?(Array) ? raw.map(&:to_s) : []
|
|
448
488
|
when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
|
|
449
|
-
when 'duration' then raw
|
|
489
|
+
when 'duration' then duration_to_millis(raw)
|
|
450
490
|
when 'json'
|
|
451
491
|
# JSON values must be native JS/Ruby types on the wire.
|
|
452
492
|
raw
|
|
@@ -460,5 +500,27 @@ module Quonfig
|
|
|
460
500
|
def value_type
|
|
461
501
|
type
|
|
462
502
|
end
|
|
503
|
+
|
|
504
|
+
private
|
|
505
|
+
|
|
506
|
+
# Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
|
|
507
|
+
# to integer milliseconds. The wire format may either be a bare ISO
|
|
508
|
+
# string in `value` or the structured `{ seconds, nanos }` proto-style
|
|
509
|
+
# shape. Mirrors sdk-node Resolver#unwrapValue duration branch.
|
|
510
|
+
def duration_to_millis(raw)
|
|
511
|
+
case raw
|
|
512
|
+
when Numeric
|
|
513
|
+
raw.to_i
|
|
514
|
+
when String
|
|
515
|
+
seconds = Quonfig::Duration.parse(raw)
|
|
516
|
+
(seconds * 1000).round
|
|
517
|
+
when Hash
|
|
518
|
+
secs = (raw['seconds'] || raw[:seconds] || 0).to_f
|
|
519
|
+
nanos = (raw['nanos'] || raw[:nanos] || 0).to_f
|
|
520
|
+
(secs * 1000 + nanos / 1_000_000.0).round
|
|
521
|
+
else
|
|
522
|
+
raw
|
|
523
|
+
end
|
|
524
|
+
end
|
|
463
525
|
end
|
|
464
526
|
end
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -14,6 +14,8 @@ module Quonfig
|
|
|
14
14
|
# production read path (with config_loader, SSE updates, telemetry), see
|
|
15
15
|
# Quonfig::ConfigResolver — the two coexist during the JSON migration.
|
|
16
16
|
class Resolver
|
|
17
|
+
TRUE_VALUES = %w[true 1 t yes].freeze
|
|
18
|
+
|
|
17
19
|
attr_reader :store, :evaluator
|
|
18
20
|
attr_accessor :project_env_id
|
|
19
21
|
|
|
@@ -26,11 +28,55 @@ module Quonfig
|
|
|
26
28
|
@store.get(key)
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Look up +key+ and evaluate against +context+. Mirrors Quonfig.get_or_raise
|
|
32
|
+
# semantics: if the key is unknown to the store, raise
|
|
33
|
+
# Quonfig::Errors::MissingDefaultError so callers can distinguish "no
|
|
34
|
+
# such config" from "config matched a nil/false value". Tests that want
|
|
35
|
+
# the legacy "return nil if absent" shape can rescue and recover (see
|
|
36
|
+
# IntegrationTestHelpers.assert_resolved, which folds a missing-key
|
|
37
|
+
# raise into the test's expected default).
|
|
29
38
|
def get(key, context = nil)
|
|
30
39
|
config = raw(key)
|
|
31
|
-
|
|
40
|
+
raise Quonfig::Errors::MissingDefaultError.new(key) if config.nil?
|
|
41
|
+
|
|
42
|
+
eval_result = @evaluator.evaluate_config(config, context, resolver: self)
|
|
43
|
+
return nil if eval_result.nil?
|
|
44
|
+
|
|
45
|
+
weighted_index = nil
|
|
46
|
+
resolved_value = resolve_value(eval_result.value, config, context) do |idx|
|
|
47
|
+
weighted_index = idx
|
|
48
|
+
end
|
|
49
|
+
EvalResult.new(
|
|
50
|
+
value: resolved_value,
|
|
51
|
+
rule_index: eval_result.rule_index,
|
|
52
|
+
config: config,
|
|
53
|
+
weighted_value_index: weighted_index
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
|
|
58
|
+
# and sdk-go resolver.Resolve:
|
|
59
|
+
# - "provided" + ENV_VAR → read ENV[lookup], coerce to config's valueType
|
|
60
|
+
# - confidential + decryptWith → look up the key config, decrypt
|
|
61
|
+
# - everything else passes through unchanged
|
|
62
|
+
def resolve_value(value, config, context = nil, &on_weighted_index)
|
|
63
|
+
return nil if value.nil?
|
|
32
64
|
|
|
33
|
-
|
|
65
|
+
type = vget(value, :type, 'type')
|
|
66
|
+
|
|
67
|
+
if type == 'provided'
|
|
68
|
+
return resolve_provided(value, config)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if type == 'weighted_values'
|
|
72
|
+
return resolve_weighted(value, config, context, &on_weighted_index)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
confidential = vget(value, :confidential, 'confidential')
|
|
76
|
+
decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
|
|
77
|
+
return resolve_decryption(value, config, context, decrypt_with) if confidential && decrypt_with && !decrypt_with.to_s.empty?
|
|
78
|
+
|
|
79
|
+
value
|
|
34
80
|
end
|
|
35
81
|
|
|
36
82
|
# Integration shims for code that expects a ConfigResolver. Keep these
|
|
@@ -38,5 +84,144 @@ module Quonfig
|
|
|
38
84
|
def symbolize_json_names?
|
|
39
85
|
false
|
|
40
86
|
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def vget(hash, *keys)
|
|
91
|
+
return nil if hash.nil?
|
|
92
|
+
|
|
93
|
+
keys.each do |k|
|
|
94
|
+
return hash[k] if hash.is_a?(Hash) && hash.key?(k)
|
|
95
|
+
end
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def config_key(config)
|
|
100
|
+
return nil if config.nil?
|
|
101
|
+
|
|
102
|
+
vget(config, :key, 'key')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def config_value_type(config)
|
|
106
|
+
return nil if config.nil?
|
|
107
|
+
|
|
108
|
+
vget(config, :value_type, 'value_type', 'valueType', :valueType)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_provided(value, config)
|
|
112
|
+
provided = vget(value, :value, 'value')
|
|
113
|
+
return value if provided.nil?
|
|
114
|
+
|
|
115
|
+
source = vget(provided, :source, 'source')
|
|
116
|
+
lookup = vget(provided, :lookup, 'lookup')
|
|
117
|
+
return value if source != 'ENV_VAR' || lookup.nil? || lookup.to_s.empty?
|
|
118
|
+
|
|
119
|
+
env_value = ENV[lookup.to_s]
|
|
120
|
+
if env_value.nil?
|
|
121
|
+
raise Quonfig::Errors::MissingEnvVarError,
|
|
122
|
+
%(Environment variable "#{lookup}" not set for config "#{config_key(config)}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
value_type = config_value_type(config)
|
|
126
|
+
coerced = coerce_env_value(env_value, value_type, config, lookup)
|
|
127
|
+
{
|
|
128
|
+
'type' => coerced_value_type(value_type),
|
|
129
|
+
'value' => coerced
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Pick a weighted variant. Mirrors sdk-node Resolver#resolveWeightedValues
|
|
134
|
+
# and sdk-go resolveWeightedValues: hash the configured context property
|
|
135
|
+
# (or fall back to a per-call random) into [0,1), then walk the variant
|
|
136
|
+
# weights until cumulative weight >= bucket. Recurses through
|
|
137
|
+
# resolve_value so nested provided/encrypted variants work too.
|
|
138
|
+
def resolve_weighted(value, config, context, &on_weighted_index)
|
|
139
|
+
payload = vget(value, :value, 'value') || {}
|
|
140
|
+
weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
|
|
141
|
+
return value unless weighted.is_a?(Array) && !weighted.empty?
|
|
142
|
+
|
|
143
|
+
hash_property = vget(payload, :hashByPropertyName, 'hashByPropertyName',
|
|
144
|
+
:hash_by_property_name, 'hash_by_property_name')
|
|
145
|
+
hash_value = nil
|
|
146
|
+
if hash_property && context
|
|
147
|
+
ctx_value =
|
|
148
|
+
if context.respond_to?(:get)
|
|
149
|
+
context.get(hash_property.to_s)
|
|
150
|
+
elsif context.is_a?(Hash)
|
|
151
|
+
ctx_obj = Quonfig::Context.new(context)
|
|
152
|
+
ctx_obj.get(hash_property.to_s)
|
|
153
|
+
end
|
|
154
|
+
hash_value = ctx_value.to_s unless ctx_value.nil?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
cfg_key = config_key(config)
|
|
158
|
+
picker = Quonfig::WeightedValueResolver.new(weighted, cfg_key, hash_value)
|
|
159
|
+
variant, index = picker.resolve
|
|
160
|
+
on_weighted_index&.call(index)
|
|
161
|
+
variant_value = vget(variant, :value, 'value')
|
|
162
|
+
resolve_value(variant_value, config, context, &on_weighted_index)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Recursively resolve the decryption-key config (it may itself be a
|
|
166
|
+
# provided ENV_VAR), then AES-GCM decrypt the value with that key.
|
|
167
|
+
def resolve_decryption(value, config, context, decrypt_with)
|
|
168
|
+
key_cfg = @store.get(decrypt_with)
|
|
169
|
+
raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
|
|
170
|
+
|
|
171
|
+
key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
|
|
172
|
+
raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
|
|
173
|
+
|
|
174
|
+
resolved_key = resolve_value(key_match.value, key_cfg, context)
|
|
175
|
+
secret_key = vget(resolved_key, :value, 'value').to_s
|
|
176
|
+
raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
|
|
177
|
+
|
|
178
|
+
ciphertext = vget(value, :value, 'value').to_s
|
|
179
|
+
begin
|
|
180
|
+
plaintext = Quonfig::Encryption.new(secret_key).decrypt(ciphertext)
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
{
|
|
186
|
+
'type' => 'string',
|
|
187
|
+
'value' => plaintext,
|
|
188
|
+
'confidential' => true
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Coerce a raw env var string to the SDK type declared by the config.
|
|
193
|
+
# Matches sdk-node coerceValue (string/int/double/bool/string_list)
|
|
194
|
+
# and sdk-go coerceValue (string/int/double/bool). Anything else falls
|
|
195
|
+
# through as a string.
|
|
196
|
+
def coerce_env_value(env_value, value_type, config, lookup)
|
|
197
|
+
case value_type
|
|
198
|
+
when 'string', nil, ''
|
|
199
|
+
env_value
|
|
200
|
+
when 'int'
|
|
201
|
+
Integer(env_value, 10)
|
|
202
|
+
when 'double'
|
|
203
|
+
Float(env_value)
|
|
204
|
+
when 'bool'
|
|
205
|
+
TRUE_VALUES.include?(env_value.downcase)
|
|
206
|
+
when 'string_list'
|
|
207
|
+
env_value.split(/\s*,\s*/)
|
|
208
|
+
when 'duration'
|
|
209
|
+
env_value
|
|
210
|
+
else
|
|
211
|
+
env_value
|
|
212
|
+
end
|
|
213
|
+
rescue ArgumentError, TypeError
|
|
214
|
+
raise Quonfig::Errors::EnvVarParseError.new(env_value, config, lookup)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def coerced_value_type(value_type)
|
|
218
|
+
case value_type
|
|
219
|
+
when 'int' then 'int'
|
|
220
|
+
when 'double' then 'double'
|
|
221
|
+
when 'bool' then 'bool'
|
|
222
|
+
when 'string_list' then 'string_list'
|
|
223
|
+
else 'string'
|
|
224
|
+
end
|
|
225
|
+
end
|
|
41
226
|
end
|
|
42
227
|
end
|