quonfig 0.0.10 → 0.0.12
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 +30 -0
- data/README.md +94 -0
- data/lib/quonfig/caching_http_connection.rb +3 -3
- data/lib/quonfig/client.rb +22 -27
- data/lib/quonfig/config_loader.rb +5 -1
- data/lib/quonfig/config_store.rb +10 -6
- data/lib/quonfig/context.rb +5 -4
- data/lib/quonfig/datadir.rb +4 -10
- data/lib/quonfig/dev_context.rb +4 -2
- data/lib/quonfig/duration.rb +2 -2
- data/lib/quonfig/encryption.rb +12 -16
- data/lib/quonfig/errors/invalid_environment_error.rb +1 -3
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +6 -7
- data/lib/quonfig/errors/missing_env_var_error.rb +0 -3
- data/lib/quonfig/errors/missing_environment_error.rb +1 -1
- data/lib/quonfig/errors/uninitialized_error.rb +1 -1
- data/lib/quonfig/evaluation.rb +11 -8
- data/lib/quonfig/evaluator.rb +34 -37
- data/lib/quonfig/fixed_size_hash.rb +1 -0
- data/lib/quonfig/http_connection.rb +2 -4
- data/lib/quonfig/internal_logger.rb +63 -27
- data/lib/quonfig/murmer3.rb +2 -2
- data/lib/quonfig/options.rb +62 -75
- data/lib/quonfig/periodic_sync.rb +1 -1
- data/lib/quonfig/quonfig.rb +3 -3
- data/lib/quonfig/reason.rb +2 -1
- data/lib/quonfig/resolver.rb +8 -9
- data/lib/quonfig/semantic_logger_filter.rb +4 -3
- data/lib/quonfig/semver.rb +6 -8
- data/lib/quonfig/sse_config_client.rb +14 -15
- data/lib/quonfig/stdlib_formatter.rb +3 -3
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +2 -3
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +1 -1
- data/lib/quonfig/telemetry/telemetry_reporter.rb +1 -0
- data/lib/quonfig/time_helpers.rb +2 -0
- data/lib/quonfig/version.rb +5 -0
- data/lib/quonfig.rb +2 -1
- data/quonfig.gemspec +29 -165
- metadata +24 -193
- data/.claude/rules/constitution.md +0 -81
- data/.claude/rules/git-safety.md +0 -11
- data/.claude/rules/issue-tracking.md +0 -13
- data/.claude/rules/testing-workflow.md +0 -28
- data/.envrc.sample +0 -3
- data/.github/CODEOWNERS +0 -2
- data/.github/pull_request_template.md +0 -8
- data/.github/workflows/release.yml +0 -49
- data/.github/workflows/ruby.yml +0 -60
- data/.github/workflows/test.yaml +0 -40
- data/.rubocop.yml +0 -13
- data/.tool-versions +0 -1
- data/CLAUDE.md +0 -29
- data/CODEOWNERS +0 -1
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -177
- data/Rakefile +0 -64
- data/VERSION +0 -1
- data/dev/allocation_stats +0 -60
- data/dev/benchmark +0 -40
- data/dev/console +0 -12
- data/dev/script_setup.rb +0 -18
- data/test/fixtures/datafile.json +0 -87
- data/test/integration/test_context_precedence.rb +0 -112
- data/test/integration/test_datadir_environment.rb +0 -54
- data/test/integration/test_dev_overrides.rb +0 -40
- data/test/integration/test_enabled.rb +0 -478
- data/test/integration/test_enabled_with_contexts.rb +0 -64
- data/test/integration/test_get.rb +0 -136
- data/test/integration/test_get_feature_flag.rb +0 -28
- data/test/integration/test_get_or_raise.rb +0 -60
- data/test/integration/test_get_weighted_values.rb +0 -34
- data/test/integration/test_helpers.rb +0 -667
- data/test/integration/test_helpers_test.rb +0 -73
- data/test/integration/test_post.rb +0 -44
- data/test/integration/test_telemetry.rb +0 -170
- data/test/support/common_helpers.rb +0 -106
- data/test/support/mock_base_client.rb +0 -27
- data/test/support/mock_config_loader.rb +0 -1
- data/test/test_bound_client.rb +0 -109
- data/test/test_caching_http_connection.rb +0 -218
- data/test/test_client.rb +0 -255
- data/test/test_client_network_mode.rb +0 -136
- data/test/test_client_telemetry.rb +0 -175
- data/test/test_config_loader.rb +0 -70
- data/test/test_context.rb +0 -139
- data/test/test_context_shape.rb +0 -37
- data/test/test_context_shape_aggregator.rb +0 -126
- data/test/test_datadir.rb +0 -203
- data/test/test_details_getters.rb +0 -242
- data/test/test_dev_context.rb +0 -163
- data/test/test_duration.rb +0 -37
- data/test/test_encryption.rb +0 -16
- data/test/test_evaluation_summaries_aggregator.rb +0 -180
- data/test/test_evaluator.rb +0 -285
- data/test/test_example_contexts_aggregator.rb +0 -119
- data/test/test_exponential_backoff.rb +0 -44
- data/test/test_fixed_size_hash.rb +0 -119
- data/test/test_helper.rb +0 -17
- data/test/test_http_connection.rb +0 -81
- data/test/test_internal_logger.rb +0 -34
- data/test/test_options.rb +0 -198
- data/test/test_rate_limit_cache.rb +0 -44
- data/test/test_reason.rb +0 -79
- data/test/test_rename.rb +0 -65
- data/test/test_resolver.rb +0 -291
- data/test/test_semantic_logger_filter.rb +0 -144
- data/test/test_semver.rb +0 -108
- data/test/test_should_log.rb +0 -186
- data/test/test_sse_config_client.rb +0 -297
- data/test/test_stdlib_formatter.rb +0 -195
- data/test/test_telemetry_reporter.rb +0 -209
- data/test/test_typed_getters.rb +0 -131
- data/test/test_types.rb +0 -141
- data/test/test_weighted_value_resolver.rb +0 -84
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ae93c1a78be83e339ff86bf9eaa58efa3376602c363ddd1d4c6e326fe102865
|
|
4
|
+
data.tar.gz: 38debe8196e6a9ae1f5281b810d0193a4d8a821a45bb18afeaf103732f1d92d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de28a9ffb64be4d7a5c3c4982a13713cfd44ba0abcef4bbbcf3a385de9ddd881fe814e319d6770ec572f7b9655e8cc6f466d887040fa300e4d9513dce54e045e
|
|
7
|
+
data.tar.gz: 0c1ac8d2e32d95534f0f903d0fa6df1a2a627a4d7f5f68a27e14448230e67804b3c859e0e1d551cde7c7ce6e2a7c453ebfb67aee7f30e1834753130328344663
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.12 - 2026-05-03
|
|
4
|
+
|
|
5
|
+
- **Feat: pluggable `logger:` kwarg on `Quonfig::Client.new`.** Host apps can now pass `Rails.logger` (or any stdlib `Logger`-compatible instance) and have all SDK warnings/errors flow through it instead of bare stderr / SemanticLogger. Implemented as a class-level `Quonfig::InternalLogger.user_logger` override that all `LOG` constants respect at log-call time, so existing per-class `LOG` constants pick it up automatically. Duck-typed (responds to `debug`/`info`/`warn`/`error`); missing levels degrade gracefully. SemanticLogger auto-detection is unchanged when no logger is supplied. Also routes the two outlier `dev_context.rb` warns (file read / JSON parse failures) through `InternalLogger` so they pick up the host-supplied logger too. (qfg-mol-1qw.3)
|
|
6
|
+
|
|
7
|
+
## 0.0.11 - 2026-05-02
|
|
8
|
+
|
|
9
|
+
- **Fix (telemetry): SSE clientName attribution.** The SSE client was sending
|
|
10
|
+
`X-Quonfig-SDK-Version: sdk-ruby-<version>`, which the api-telemetry parser
|
|
11
|
+
splits on the first dash, so it landed as `clientName="sdk"`,
|
|
12
|
+
`clientVersion="ruby-<version>"`. Now sends `ruby-<version>` to match
|
|
13
|
+
`http_connection.rb`, so both transports attribute consistently as
|
|
14
|
+
`clientName="ruby"`.
|
|
15
|
+
- **Release plumbing: drop juwelier, tag-triggered publish.** The gem is now
|
|
16
|
+
built from a hand-written `quonfig.gemspec` that reads the version from
|
|
17
|
+
`Quonfig::VERSION` (in `lib/quonfig/version.rb`) and lists shipped files
|
|
18
|
+
explicitly. The `release.yml` workflow now fires on `v*` tag pushes, not
|
|
19
|
+
every successful main build, and refuses to publish unless the tag matches
|
|
20
|
+
`Quonfig::VERSION`. Together these eliminate the gemspec-vs-VERSION drift
|
|
21
|
+
that prevented the original 0.0.11 publish (gem built as 0.0.10 internally
|
|
22
|
+
while filename said 0.0.11) and the manifest-drift bug from qfg-e588.
|
|
23
|
+
|
|
3
24
|
## 0.0.10 - 2026-05-01
|
|
4
25
|
|
|
5
26
|
- **BREAKING (env): `QUONFIG_TELEMETRY_URL` and `QUONFIG_API_URLS` env vars
|
|
@@ -16,6 +37,15 @@
|
|
|
16
37
|
`telemetry_url:` kwarg (was previously documented but not wired up).
|
|
17
38
|
- **Default `api_urls` now includes secondary.** Was `[primary]`, now
|
|
18
39
|
`[primary, secondary]` to match every other SDK and provide failover.
|
|
40
|
+
- **Release plumbing: pre-publish smoke check (qfg-e588).** The Rakefile
|
|
41
|
+
`:release` task and the `release.yml` workflow now run
|
|
42
|
+
`scripts/smoke_check.sh` after `gem build` and before `gem push`. The
|
|
43
|
+
script installs the freshly built `.gem` into a sandbox `GEM_HOME` and
|
|
44
|
+
shells out to `ruby -rquonfig -e 'puts Quonfig::VERSION'`. If the
|
|
45
|
+
require fails or the version mismatches, the publish aborts. This is
|
|
46
|
+
the prevention measure for qfg-e588, where 0.0.9 was published with a
|
|
47
|
+
stale gemspec manifest missing `lib/quonfig/evaluation_details.rb` and
|
|
48
|
+
every consumer hit `LoadError` at install time.
|
|
19
49
|
|
|
20
50
|
## 0.0.8 - 2026-04-26
|
|
21
51
|
|
data/README.md
CHANGED
|
@@ -147,6 +147,7 @@ Quonfig::Client.new(
|
|
|
147
147
|
| `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
|
|
148
148
|
| `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
|
|
149
149
|
| `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
|
|
150
|
+
| `logger` | Logger-like object | `nil` | Optional host-app logger (e.g. `Rails.logger`). Must respond to `debug`/`info`/`warn`/`error`. When set, all SDK warnings/errors flow through this logger instead of the default stderr / SemanticLogger backend. |
|
|
150
151
|
|
|
151
152
|
## Typed getters
|
|
152
153
|
|
|
@@ -239,6 +240,99 @@ logger.progname = 'MyApp::Services::Auth'
|
|
|
239
240
|
|
|
240
241
|
If both are supplied, the explicit `logger_name:` wins.
|
|
241
242
|
|
|
243
|
+
## Rails integration
|
|
244
|
+
|
|
245
|
+
The SDK runs a background SSE thread (and optional polling thread) that you do
|
|
246
|
+
not want to inherit across a `fork(2)`. Forked threads in the child process
|
|
247
|
+
are dead — the SSE socket is held open by a thread that no longer exists, and
|
|
248
|
+
the child silently stops receiving live updates.
|
|
249
|
+
|
|
250
|
+
Use `Quonfig::Client#fork` (or `Quonfig.fork` if you use the module-level
|
|
251
|
+
singleton) in any process that fork-spawns workers. It returns a fresh client
|
|
252
|
+
configured for the child: a new `ConfigStore`, a new SSE subscription, and
|
|
253
|
+
suppressed telemetry double-counting (`Options#is_fork` is set to `true`).
|
|
254
|
+
|
|
255
|
+
### Puma (clustered mode)
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
# config/puma.rb
|
|
259
|
+
before_fork do
|
|
260
|
+
Quonfig.instance.stop # close the master's SSE before forking
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
on_worker_boot do
|
|
264
|
+
Quonfig.fork # rebuild a fresh client per worker
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
If you initialize Quonfig lazily (in a Rails initializer) and run Puma in
|
|
269
|
+
single mode (no clustering), no fork hook is needed.
|
|
270
|
+
|
|
271
|
+
### Sidekiq
|
|
272
|
+
|
|
273
|
+
Sidekiq's parent process forks workers. Wire the same lifecycle:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
# config/initializers/quonfig.rb
|
|
277
|
+
Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))
|
|
278
|
+
|
|
279
|
+
# config/initializers/sidekiq.rb
|
|
280
|
+
Sidekiq.configure_server do |config|
|
|
281
|
+
config.on(:startup) { Quonfig.fork if Process.ppid != 1 }
|
|
282
|
+
config.on(:shutdown) { Quonfig.instance.stop rescue nil }
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
For Sidekiq web/CLI processes that don't fork (default `concurrency: 1`),
|
|
287
|
+
`Quonfig.init` in the initializer is sufficient.
|
|
288
|
+
|
|
289
|
+
### Spring / Bootsnap preloaders
|
|
290
|
+
|
|
291
|
+
Spring forks the preloader for each command. If your initializer creates a
|
|
292
|
+
Quonfig client at boot, the SSE thread will be inherited dead in every child.
|
|
293
|
+
Two options:
|
|
294
|
+
|
|
295
|
+
1. **Recommended:** initialize lazily — wrap `Quonfig.init` so it only runs
|
|
296
|
+
the first time `Quonfig.instance` is called from a non-preloader process.
|
|
297
|
+
2. **Or:** call `Quonfig.fork` from a `Spring.after_fork` hook.
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
# config/spring.rb
|
|
301
|
+
Spring.after_fork do
|
|
302
|
+
Quonfig.fork if defined?(Quonfig) && Quonfig.instance_variable_get(:@singleton)
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Code reloading (Zeitwerk, development mode)
|
|
307
|
+
|
|
308
|
+
`Quonfig::Client` is a long-lived object — keep it out of `app/` (where
|
|
309
|
+
Zeitwerk reloads classes on every request) and pin it to a constant set in a
|
|
310
|
+
Rails initializer. The client itself is reload-safe because it does not
|
|
311
|
+
reference any application classes; the failure mode to avoid is *creating a
|
|
312
|
+
new client per request*, which leaks SSE threads and quickly exhausts file
|
|
313
|
+
descriptors.
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
# config/initializers/quonfig.rb
|
|
317
|
+
# Quonfig.init is idempotent — a second call warns and returns the existing
|
|
318
|
+
# singleton — so it's safe to wrap in to_prepare for reload-friendliness.
|
|
319
|
+
Rails.application.config.to_prepare do
|
|
320
|
+
Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Thread safety
|
|
325
|
+
|
|
326
|
+
`Quonfig::Client` is safe to share across threads. Reads (`get`, `enabled?`,
|
|
327
|
+
`get_*`) and SSE-driven writes to the underlying `ConfigStore` use
|
|
328
|
+
`Concurrent::Map` for per-key atomicity. Eventual consistency across an
|
|
329
|
+
envelope is intentional: a reader concurrent with envelope application may
|
|
330
|
+
observe the new value for some keys and the old value for others, then
|
|
331
|
+
converge once the envelope finishes applying.
|
|
332
|
+
|
|
333
|
+
`Quonfig.fork` is the only safe way to "carry" a client across `Process.fork`
|
|
334
|
+
— do not reuse the parent's client in a child process.
|
|
335
|
+
|
|
242
336
|
## Documentation
|
|
243
337
|
|
|
244
338
|
Full documentation, including SPEC, SDK reference, and operational guides, is
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
class CachingHttpConnection
|
|
5
|
-
CACHE_SIZE = 2
|
|
5
|
+
CACHE_SIZE = 2
|
|
6
6
|
CacheEntry = Struct.new(:data, :etag, :expires_at)
|
|
7
7
|
|
|
8
8
|
class << self
|
|
@@ -68,7 +68,7 @@ module Quonfig
|
|
|
68
68
|
return response if cache_control.include?('no-store')
|
|
69
69
|
|
|
70
70
|
# Calculate expiration
|
|
71
|
-
max_age = cache_control
|
|
71
|
+
max_age = cache_control[/max-age=(\d+)/, 1]&.to_i
|
|
72
72
|
expires_at = max_age ? now + max_age : nil
|
|
73
73
|
|
|
74
74
|
# Cache the response if we have caching headers
|
|
@@ -92,4 +92,4 @@ module Quonfig
|
|
|
92
92
|
@connection.uri
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
|
-
end
|
|
95
|
+
end
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Quonfig
|
|
|
32
32
|
else
|
|
33
33
|
Quonfig::Options.new(option_kwargs)
|
|
34
34
|
end
|
|
35
|
+
Quonfig::InternalLogger.user_logger = @options.logger if @options.logger
|
|
35
36
|
@global_context = build_initial_global_context(@options)
|
|
36
37
|
@instance_hash = SecureRandom.uuid
|
|
37
38
|
@store = store || Quonfig::ConfigStore.new
|
|
@@ -137,7 +138,7 @@ module Quonfig
|
|
|
137
138
|
|
|
138
139
|
def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
|
|
139
140
|
value = get(feature_name, false, jit_context)
|
|
140
|
-
|
|
141
|
+
[true, 'true'].include?(value)
|
|
141
142
|
end
|
|
142
143
|
|
|
143
144
|
def defined?(key)
|
|
@@ -295,19 +296,19 @@ module Quonfig
|
|
|
295
296
|
example_aggregator = nil
|
|
296
297
|
summaries_aggregator = nil
|
|
297
298
|
|
|
298
|
-
if @options.collect_max_shapes.to_i
|
|
299
|
+
if @options.collect_max_shapes.to_i.positive?
|
|
299
300
|
shape_aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(
|
|
300
301
|
max_shapes: @options.collect_max_shapes
|
|
301
302
|
)
|
|
302
303
|
end
|
|
303
304
|
|
|
304
|
-
if @options.collect_max_example_contexts.to_i
|
|
305
|
+
if @options.collect_max_example_contexts.to_i.positive?
|
|
305
306
|
example_aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(
|
|
306
307
|
max_contexts: @options.collect_max_example_contexts
|
|
307
308
|
)
|
|
308
309
|
end
|
|
309
310
|
|
|
310
|
-
if @options.collect_max_evaluation_summaries.to_i
|
|
311
|
+
if @options.collect_max_evaluation_summaries.to_i.positive?
|
|
311
312
|
summaries_aggregator = Quonfig::Telemetry::EvaluationSummariesAggregator.new(
|
|
312
313
|
max_keys: @options.collect_max_evaluation_summaries
|
|
313
314
|
)
|
|
@@ -382,9 +383,7 @@ module Quonfig
|
|
|
382
383
|
# Initialize network mode: sync HTTP fetch (bounded by
|
|
383
384
|
# initialization_timeout_sec) then start SSE + polling as requested.
|
|
384
385
|
def initialize_network_mode
|
|
385
|
-
if @options.sdk_key.nil? || @options.sdk_key.to_s.strip.empty?
|
|
386
|
-
raise Quonfig::Errors::InvalidSdkKeyError, @options.sdk_key
|
|
387
|
-
end
|
|
386
|
+
raise Quonfig::Errors::InvalidSdkKeyError, @options.sdk_key if @options.sdk_key.nil? || @options.sdk_key.to_s.strip.empty?
|
|
388
387
|
|
|
389
388
|
@config_loader = Quonfig::ConfigLoader.new(@store, @options)
|
|
390
389
|
|
|
@@ -433,6 +432,7 @@ module Quonfig
|
|
|
433
432
|
@sse_client = Quonfig::SSEConfigClient.new(@options, @config_loader)
|
|
434
433
|
@sse_client.start do |envelope, _event, _source|
|
|
435
434
|
next if @stopped
|
|
435
|
+
|
|
436
436
|
begin
|
|
437
437
|
@config_loader.apply_envelope(envelope)
|
|
438
438
|
@on_update&.call
|
|
@@ -455,6 +455,7 @@ module Quonfig
|
|
|
455
455
|
Thread.current.name = 'quonfig-poller'
|
|
456
456
|
loop do
|
|
457
457
|
break if @stopped
|
|
458
|
+
|
|
458
459
|
sleep poll_interval
|
|
459
460
|
break if @stopped
|
|
460
461
|
|
|
@@ -505,11 +506,11 @@ module Quonfig
|
|
|
505
506
|
merged = {}
|
|
506
507
|
left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
|
|
507
508
|
right.each do |name, ctx|
|
|
508
|
-
if merged[name].is_a?(Hash) && ctx.is_a?(Hash)
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
509
|
+
merged[name] = if merged[name].is_a?(Hash) && ctx.is_a?(Hash)
|
|
510
|
+
merged[name].merge(ctx)
|
|
511
|
+
else
|
|
512
|
+
ctx.is_a?(Hash) ? ctx.dup : ctx
|
|
513
|
+
end
|
|
513
514
|
end
|
|
514
515
|
merged
|
|
515
516
|
end
|
|
@@ -525,9 +526,7 @@ module Quonfig
|
|
|
525
526
|
def handle_missing(key, default)
|
|
526
527
|
return default if default != NO_DEFAULT_PROVIDED
|
|
527
528
|
|
|
528
|
-
if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
|
|
529
|
-
raise Quonfig::Errors::MissingDefaultError, key
|
|
530
|
-
end
|
|
529
|
+
raise Quonfig::Errors::MissingDefaultError, key if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
|
|
531
530
|
|
|
532
531
|
nil
|
|
533
532
|
end
|
|
@@ -602,29 +601,25 @@ module Quonfig
|
|
|
602
601
|
def coerce_and_check(key, value, expected_type)
|
|
603
602
|
case expected_type
|
|
604
603
|
when :bool
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
end
|
|
604
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', value) unless [true, false].include?(value)
|
|
605
|
+
|
|
608
606
|
value
|
|
609
607
|
when :string_list
|
|
610
608
|
arr = value.is_a?(Array) ? value : nil
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
end
|
|
609
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, 'Array<String>', value) unless arr&.all?(String)
|
|
610
|
+
|
|
614
611
|
arr
|
|
615
612
|
when :duration
|
|
616
613
|
return value.to_i if value.is_a?(Numeric)
|
|
617
|
-
if value.is_a?(String)
|
|
618
|
-
|
|
619
|
-
end
|
|
614
|
+
return (Quonfig::Duration.parse(value) * 1000).to_i if value.is_a?(String)
|
|
615
|
+
|
|
620
616
|
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value)
|
|
621
617
|
when :json
|
|
622
618
|
# JSON values are returned as-is (Hash, Array, or scalar from the wire).
|
|
623
619
|
value
|
|
624
620
|
when Class
|
|
625
|
-
unless value.is_a?(expected_type)
|
|
626
|
-
|
|
627
|
-
end
|
|
621
|
+
raise Quonfig::Errors::TypeMismatchError.new(key, "expected #{expected_type}", value) unless value.is_a?(expected_type)
|
|
622
|
+
|
|
628
623
|
value
|
|
629
624
|
else
|
|
630
625
|
value
|
|
@@ -124,8 +124,9 @@ module Quonfig
|
|
|
124
124
|
|
|
125
125
|
def short_body(response)
|
|
126
126
|
return '' if response.body.nil?
|
|
127
|
+
|
|
127
128
|
str = response.body.to_s
|
|
128
|
-
str.length > 200 ? str[0, 200]
|
|
129
|
+
str.length > 200 ? "#{str[0, 200]}..." : str
|
|
129
130
|
end
|
|
130
131
|
|
|
131
132
|
def install_envelope(envelope, source:)
|
|
@@ -134,6 +135,7 @@ module Quonfig
|
|
|
134
135
|
envelope.configs.each do |cfg|
|
|
135
136
|
key = config_key(cfg)
|
|
136
137
|
next if key.nil?
|
|
138
|
+
|
|
137
139
|
next_map[key] = { source: source, config: cfg }
|
|
138
140
|
end
|
|
139
141
|
@api_config = next_map
|
|
@@ -153,12 +155,14 @@ module Quonfig
|
|
|
153
155
|
envelope.configs.each do |cfg|
|
|
154
156
|
key = config_key(cfg)
|
|
155
157
|
next if key.nil?
|
|
158
|
+
|
|
156
159
|
@store.set(key, cfg)
|
|
157
160
|
end
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
def config_key(cfg)
|
|
161
164
|
return cfg['key'] || cfg[:key] if cfg.is_a?(Hash)
|
|
165
|
+
|
|
162
166
|
cfg.respond_to?(:key) ? cfg.key : nil
|
|
163
167
|
end
|
|
164
168
|
end
|
data/lib/quonfig/config_store.rb
CHANGED
|
@@ -6,9 +6,15 @@ module Quonfig
|
|
|
6
6
|
# Mirrors sdk-node's ConfigStore (src/store.ts). Integration tests and the
|
|
7
7
|
# new Resolver/Evaluator trio construct this directly, independent of any
|
|
8
8
|
# Client/ConfigLoader plumbing.
|
|
9
|
+
#
|
|
10
|
+
# Thread-safety: backed by Concurrent::Map, whose per-key reads, writes, and
|
|
11
|
+
# deletes are atomic. There is no compound multi-key operation here that
|
|
12
|
+
# needs an outer lock — envelope application in ConfigLoader is a sequence
|
|
13
|
+
# of independent set/delete calls, and readers tolerate seeing the
|
|
14
|
+
# in-progress mix. Eventual consistency across an envelope is acceptable
|
|
15
|
+
# and matches sdk-node behavior.
|
|
9
16
|
class ConfigStore
|
|
10
17
|
def initialize(initial_configs = nil)
|
|
11
|
-
@lock = Concurrent::ReadWriteLock.new
|
|
12
18
|
@configs = Concurrent::Map.new
|
|
13
19
|
return unless initial_configs
|
|
14
20
|
|
|
@@ -20,17 +26,15 @@ module Quonfig
|
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def set(key, config)
|
|
23
|
-
@
|
|
29
|
+
@configs[key] = config
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def delete(key)
|
|
27
|
-
@
|
|
33
|
+
@configs.delete(key)
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def clear
|
|
31
|
-
@
|
|
32
|
-
@configs.keys.each { |k| @configs.delete(k) }
|
|
33
|
-
end
|
|
37
|
+
@configs.each_key { |k| @configs.delete(k) }
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def keys
|
data/lib/quonfig/context.rb
CHANGED
|
@@ -47,7 +47,7 @@ module Quonfig
|
|
|
47
47
|
|
|
48
48
|
@contexts[name.to_s] = NamedContext.new(name, values)
|
|
49
49
|
values.each do |key, value|
|
|
50
|
-
@flattened[name
|
|
50
|
+
@flattened["#{name}.#{key}"] = value
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
end
|
|
@@ -59,12 +59,12 @@ module Quonfig
|
|
|
59
59
|
def set(name, hash)
|
|
60
60
|
@contexts[name.to_s] = NamedContext.new(name, hash)
|
|
61
61
|
hash.each do |key, value|
|
|
62
|
-
@flattened[name
|
|
62
|
+
@flattened["#{name}.#{key}"] = value
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def get(property_key, scope: nil)
|
|
67
|
-
property_key = BLANK_CONTEXT_NAME
|
|
67
|
+
property_key = "#{BLANK_CONTEXT_NAME}.#{property_key}" unless property_key.include?('.')
|
|
68
68
|
@flattened[property_key]
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -94,11 +94,12 @@ module Quonfig
|
|
|
94
94
|
@contexts.values.map do |ctx|
|
|
95
95
|
h = ctx.to_h
|
|
96
96
|
v = h['key'] || h[:key] || h['trackingId'] || h[:trackingId]
|
|
97
|
-
v
|
|
97
|
+
v&.to_s
|
|
98
98
|
end.compact.reject(&:empty?).sort.join('|')
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
include Comparable
|
|
102
|
+
|
|
102
103
|
def <=>(other)
|
|
103
104
|
if other.is_a?(Quonfig::Context)
|
|
104
105
|
to_h <=> other.to_h
|
data/lib/quonfig/datadir.rb
CHANGED
|
@@ -56,21 +56,15 @@ module Quonfig
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def resolve_environment(quonfig_path, environment)
|
|
59
|
-
environment ||= ENV
|
|
59
|
+
environment ||= ENV.fetch('QUONFIG_ENVIRONMENT', nil)
|
|
60
60
|
|
|
61
|
-
if environment.nil? || environment.empty?
|
|
62
|
-
raise Quonfig::Errors::MissingEnvironmentError
|
|
63
|
-
end
|
|
61
|
+
raise Quonfig::Errors::MissingEnvironmentError if environment.nil? || environment.empty?
|
|
64
62
|
|
|
65
|
-
unless File.exist?(quonfig_path)
|
|
66
|
-
raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}"
|
|
67
|
-
end
|
|
63
|
+
raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}" unless File.exist?(quonfig_path)
|
|
68
64
|
|
|
69
65
|
environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
|
|
70
66
|
|
|
71
|
-
if !environments.empty? && !environments.include?(environment)
|
|
72
|
-
raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments)
|
|
73
|
-
end
|
|
67
|
+
raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments) if !environments.empty? && !environments.include?(environment)
|
|
74
68
|
|
|
75
69
|
environment
|
|
76
70
|
end
|
data/lib/quonfig/dev_context.rb
CHANGED
|
@@ -12,6 +12,8 @@ module Quonfig
|
|
|
12
12
|
# run `qfg login` and therefore have no tokens file. Rules keyed on
|
|
13
13
|
# `quonfig-user.email` are dead code in prod.
|
|
14
14
|
module DevContext
|
|
15
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
16
|
+
|
|
15
17
|
TOKENS_BASENAME = File.join('.quonfig', 'tokens.json')
|
|
16
18
|
|
|
17
19
|
def self.load_quonfig_user_context
|
|
@@ -21,14 +23,14 @@ module Quonfig
|
|
|
21
23
|
raw = begin
|
|
22
24
|
File.read(path)
|
|
23
25
|
rescue StandardError => e
|
|
24
|
-
warn "
|
|
26
|
+
LOG.warn "dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
|
|
25
27
|
return nil
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
parsed = begin
|
|
29
31
|
JSON.parse(raw)
|
|
30
32
|
rescue JSON::ParserError => e
|
|
31
|
-
warn "
|
|
33
|
+
LOG.warn "dev-context: could not parse #{path} (#{e.message}); skipping injection"
|
|
32
34
|
return nil
|
|
33
35
|
end
|
|
34
36
|
|
data/lib/quonfig/duration.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Quonfig
|
|
|
20
20
|
minutes = match[:minutes]&.to_f || 0
|
|
21
21
|
seconds = match[:seconds]&.to_f || 0
|
|
22
22
|
|
|
23
|
-
(days * DAYS_IN_SECONDS + hours * HOURS_IN_SECONDS + minutes * MINUTES_IN_SECONDS + seconds)
|
|
23
|
+
((days * DAYS_IN_SECONDS) + (hours * HOURS_IN_SECONDS) + (minutes * MINUTES_IN_SECONDS) + seconds)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def in_seconds
|
|
@@ -52,7 +52,7 @@ module Quonfig
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def as_json
|
|
55
|
-
{ ms:
|
|
55
|
+
{ ms: in_seconds * 1000, seconds: in_seconds }
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
end
|
data/lib/quonfig/encryption.rb
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Quonfig
|
|
4
4
|
class Encryption
|
|
5
|
-
CIPHER_TYPE =
|
|
6
|
-
SEPARATOR =
|
|
5
|
+
CIPHER_TYPE = 'aes-256-gcm' # 32/12
|
|
6
|
+
SEPARATOR = '--'
|
|
7
7
|
AUTH_TAG_LENGTH = 16
|
|
8
8
|
|
|
9
9
|
# Hexadecimal format ensures that generated keys are representable with
|
|
@@ -12,11 +12,11 @@ module Quonfig
|
|
|
12
12
|
# To convert back to the original string with the desired length:
|
|
13
13
|
# [ value ].pack("H*")
|
|
14
14
|
def self.generate_new_hex_key
|
|
15
|
-
generate_random_key.
|
|
15
|
+
generate_random_key.unpack1('H*')
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def initialize(key_string_hex)
|
|
19
|
-
@key = [key_string_hex].pack(
|
|
19
|
+
@key = [key_string_hex].pack('H*')
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def encrypt(clear_text)
|
|
@@ -27,42 +27,38 @@ module Quonfig
|
|
|
27
27
|
# load them into the cipher
|
|
28
28
|
cipher.key = @key
|
|
29
29
|
cipher.iv = iv
|
|
30
|
-
cipher.auth_data =
|
|
30
|
+
cipher.auth_data = ''
|
|
31
31
|
|
|
32
32
|
# encrypt the message
|
|
33
33
|
encrypted = cipher.update(clear_text)
|
|
34
34
|
encrypted << cipher.final
|
|
35
35
|
tag = cipher.auth_tag
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
# pack and join
|
|
38
|
-
[encrypted, iv, tag].map { |p| p.
|
|
38
|
+
[encrypted, iv, tag].map { |p| p.unpack1('H*') }.join(SEPARATOR)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def decrypt(encrypted_string)
|
|
42
|
-
encrypted_data, iv, auth_tag = encrypted_string.split(SEPARATOR).map { |p| [p].pack(
|
|
43
|
-
|
|
42
|
+
encrypted_data, iv, auth_tag = encrypted_string.split(SEPARATOR).map { |p| [p].pack('H*') }
|
|
43
|
+
|
|
44
44
|
# Currently the OpenSSL bindings do not raise an error if auth_tag is
|
|
45
45
|
# truncated, which would allow an attacker to easily forge it. See
|
|
46
46
|
# https://github.com/ruby/openssl/issues/63
|
|
47
|
-
if auth_tag.bytesize != AUTH_TAG_LENGTH
|
|
48
|
-
raise "truncated auth_tag"
|
|
49
|
-
end
|
|
47
|
+
raise 'truncated auth_tag' if auth_tag.bytesize != AUTH_TAG_LENGTH
|
|
50
48
|
|
|
51
49
|
cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
|
|
52
50
|
cipher.decrypt
|
|
53
51
|
cipher.key = @key
|
|
54
52
|
cipher.iv = iv
|
|
55
|
-
|
|
53
|
+
|
|
56
54
|
cipher.auth_tag = auth_tag
|
|
57
|
-
|
|
55
|
+
|
|
58
56
|
# and decrypt it
|
|
59
57
|
decrypted = cipher.update(encrypted_data)
|
|
60
58
|
decrypted << cipher.final
|
|
61
59
|
decrypted
|
|
62
60
|
end
|
|
63
61
|
|
|
64
|
-
private
|
|
65
|
-
|
|
66
62
|
def self.generate_random_key
|
|
67
63
|
SecureRandom.random_bytes(key_length)
|
|
68
64
|
end
|
|
@@ -9,9 +9,7 @@ module Quonfig
|
|
|
9
9
|
class InvalidEnvironmentError < Quonfig::Error
|
|
10
10
|
def initialize(environment, available = nil)
|
|
11
11
|
message = "[quonfig] Environment \"#{environment}\" not found in workspace"
|
|
12
|
-
if available && !Array(available).empty?
|
|
13
|
-
message += "; available environments: #{Array(available).join(', ')}"
|
|
14
|
-
end
|
|
12
|
+
message += "; available environments: #{Array(available).join(', ')}" if available && !Array(available).empty?
|
|
15
13
|
super(message)
|
|
16
14
|
end
|
|
17
15
|
end
|
|
@@ -4,15 +4,14 @@ module Quonfig
|
|
|
4
4
|
module Errors
|
|
5
5
|
class InvalidSdkKeyError < Quonfig::Error
|
|
6
6
|
def initialize(key)
|
|
7
|
-
if key.nil? || key.empty?
|
|
8
|
-
|
|
7
|
+
message = if key.nil? || key.empty?
|
|
8
|
+
'No SDK key. Set QUONFIG_BACKEND_SDK_KEY env var or use QUONFIG_DATAFILE'
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
message = "Your SDK key format is invalid. Expecting something like 123-development-yourapikey-SDK. You provided `#{key}`"
|
|
10
|
+
else
|
|
11
|
+
"Your SDK key format is invalid. Expecting something like 123-development-yourapikey-SDK. You provided `#{key}`"
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
end
|
|
14
|
+
super(message)
|
|
16
15
|
end
|
|
17
16
|
end
|
|
18
17
|
end
|