quonfig 0.0.2
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 +7 -0
- data/.claude/rules/constitution.md +81 -0
- data/.claude/rules/git-safety.md +11 -0
- data/.claude/rules/issue-tracking.md +13 -0
- data/.claude/rules/testing-workflow.md +28 -0
- data/.envrc.sample +3 -0
- data/.github/CODEOWNERS +2 -0
- data/.github/pull_request_template.md +8 -0
- data/.github/workflows/push_gem.yml +49 -0
- data/.github/workflows/ruby.yml +60 -0
- data/.github/workflows/test.yaml +40 -0
- data/.rubocop.yml +13 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +301 -0
- data/CLAUDE.md +29 -0
- data/CODEOWNERS +1 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +177 -0
- data/LICENSE.txt +20 -0
- data/README.md +213 -0
- data/Rakefile +64 -0
- data/VERSION +1 -0
- data/dev/allocation_stats +60 -0
- data/dev/benchmark +40 -0
- data/dev/console +12 -0
- data/dev/script_setup.rb +18 -0
- data/lib/quonfig/bound_client.rb +71 -0
- data/lib/quonfig/caching_http_connection.rb +95 -0
- data/lib/quonfig/client.rb +221 -0
- data/lib/quonfig/config_envelope.rb +5 -0
- data/lib/quonfig/config_loader.rb +103 -0
- data/lib/quonfig/config_store.rb +42 -0
- data/lib/quonfig/context.rb +101 -0
- data/lib/quonfig/datadir.rb +101 -0
- data/lib/quonfig/duration.rb +58 -0
- data/lib/quonfig/encryption.rb +74 -0
- data/lib/quonfig/error.rb +6 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
- data/lib/quonfig/errors/missing_default_error.rb +13 -0
- data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
- data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
- data/lib/quonfig/errors/uninitialized_error.rb +13 -0
- data/lib/quonfig/evaluation.rb +64 -0
- data/lib/quonfig/evaluator.rb +464 -0
- data/lib/quonfig/exponential_backoff.rb +21 -0
- data/lib/quonfig/fixed_size_hash.rb +14 -0
- data/lib/quonfig/http_connection.rb +46 -0
- data/lib/quonfig/internal_logger.rb +173 -0
- data/lib/quonfig/murmer3.rb +50 -0
- data/lib/quonfig/options.rb +194 -0
- data/lib/quonfig/periodic_sync.rb +74 -0
- data/lib/quonfig/quonfig.rb +58 -0
- data/lib/quonfig/rate_limit_cache.rb +41 -0
- data/lib/quonfig/reason.rb +39 -0
- data/lib/quonfig/resolver.rb +42 -0
- data/lib/quonfig/semantic_logger_filter.rb +90 -0
- data/lib/quonfig/semver.rb +132 -0
- data/lib/quonfig/sse_config_client.rb +135 -0
- data/lib/quonfig/time_helpers.rb +7 -0
- data/lib/quonfig/types.rb +56 -0
- data/lib/quonfig/weighted_value_resolver.rb +49 -0
- data/lib/quonfig.rb +57 -0
- data/quonfig.gemspec +149 -0
- data/scripts/generate_integration_tests.rb +362 -0
- data/test/fixtures/datafile.json +87 -0
- data/test/integration/test_context_precedence.rb +194 -0
- data/test/integration/test_datadir_environment.rb +76 -0
- data/test/integration/test_enabled.rb +784 -0
- data/test/integration/test_enabled_with_contexts.rb +94 -0
- data/test/integration/test_get.rb +224 -0
- data/test/integration/test_get_feature_flag.rb +34 -0
- data/test/integration/test_get_or_raise.rb +86 -0
- data/test/integration/test_get_weighted_values.rb +29 -0
- data/test/integration/test_helpers.rb +139 -0
- data/test/integration/test_helpers_test.rb +73 -0
- data/test/integration/test_post.rb +34 -0
- data/test/integration/test_telemetry.rb +114 -0
- data/test/support/common_helpers.rb +106 -0
- data/test/support/mock_base_client.rb +27 -0
- data/test/support/mock_config_loader.rb +1 -0
- data/test/test_bound_client.rb +109 -0
- data/test/test_caching_http_connection.rb +218 -0
- data/test/test_client.rb +255 -0
- data/test/test_config_loader.rb +70 -0
- data/test/test_context.rb +136 -0
- data/test/test_datadir.rb +199 -0
- data/test/test_duration.rb +37 -0
- data/test/test_encryption.rb +16 -0
- data/test/test_evaluator.rb +285 -0
- data/test/test_exponential_backoff.rb +44 -0
- data/test/test_fixed_size_hash.rb +119 -0
- data/test/test_helper.rb +17 -0
- data/test/test_http_connection.rb +79 -0
- data/test/test_internal_logger.rb +34 -0
- data/test/test_options.rb +167 -0
- data/test/test_rate_limit_cache.rb +44 -0
- data/test/test_reason.rb +79 -0
- data/test/test_rename.rb +65 -0
- data/test/test_resolver.rb +144 -0
- data/test/test_semantic_logger_filter.rb +123 -0
- data/test/test_semver.rb +108 -0
- data/test/test_sse_config_client.rb +297 -0
- data/test/test_typed_getters.rb +131 -0
- data/test/test_types.rb +141 -0
- data/test/test_weighted_value_resolver.rb +84 -0
- metadata +311 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
# Loads a Quonfig workspace from the local filesystem (offline / datadir
|
|
7
|
+
# mode). Mirrors sdk-node/src/datadir.ts.
|
|
8
|
+
#
|
|
9
|
+
# The workspace directory layout matches integration-test-data:
|
|
10
|
+
# <datadir>/quonfig.json
|
|
11
|
+
# <datadir>/configs/*.json
|
|
12
|
+
# <datadir>/feature-flags/*.json
|
|
13
|
+
# <datadir>/segments/*.json
|
|
14
|
+
# <datadir>/schemas/*.json
|
|
15
|
+
# <datadir>/log-levels/*.json
|
|
16
|
+
#
|
|
17
|
+
# Each <type>/*.json file is a WorkspaceConfigDocument. The loader projects
|
|
18
|
+
# it down to the ConfigResponse shape that the SSE/HTTP delivery path emits,
|
|
19
|
+
# so ConfigStore consumes both transports uniformly.
|
|
20
|
+
module Datadir
|
|
21
|
+
CONFIG_SUBDIRS = %w[configs feature-flags segments schemas log-levels].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Read every config JSON in `datadir`, project to ConfigResponse hashes,
|
|
26
|
+
# and wrap in a ConfigEnvelope. Does no network I/O.
|
|
27
|
+
def load_envelope(datadir, environment = nil)
|
|
28
|
+
env_id = resolve_environment(File.join(datadir, 'quonfig.json'), environment)
|
|
29
|
+
configs = []
|
|
30
|
+
|
|
31
|
+
CONFIG_SUBDIRS.each do |subdir|
|
|
32
|
+
dir = File.join(datadir, subdir)
|
|
33
|
+
next unless Dir.exist?(dir)
|
|
34
|
+
|
|
35
|
+
Dir.children(dir)
|
|
36
|
+
.select { |name| name.end_with?('.json') }
|
|
37
|
+
.sort
|
|
38
|
+
.each do |filename|
|
|
39
|
+
raw = JSON.parse(File.read(File.join(dir, filename)))
|
|
40
|
+
configs << to_config_response(raw, env_id)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Quonfig::ConfigEnvelope.new(
|
|
45
|
+
configs: configs,
|
|
46
|
+
meta: { 'version' => "datadir:#{datadir}", 'environment' => env_id }
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Convenience: load the envelope and populate a fresh ConfigStore.
|
|
51
|
+
def load_store(datadir, environment = nil)
|
|
52
|
+
envelope = load_envelope(datadir, environment)
|
|
53
|
+
store = Quonfig::ConfigStore.new
|
|
54
|
+
envelope.configs.each { |cfg| store.set(cfg['key'], cfg) }
|
|
55
|
+
store
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_environment(quonfig_path, environment)
|
|
59
|
+
environment ||= ENV['QUONFIG_ENVIRONMENT']
|
|
60
|
+
|
|
61
|
+
if environment.nil? || environment.empty?
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
'[quonfig] Environment required for datadir mode; set the `environment` option or QUONFIG_ENVIRONMENT env var'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless File.exist?(quonfig_path)
|
|
67
|
+
raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
|
|
71
|
+
|
|
72
|
+
if !environments.empty? && !environments.include?(environment)
|
|
73
|
+
raise ArgumentError,
|
|
74
|
+
"[quonfig] Environment \"#{environment}\" not found in workspace; available environments: #{environments.join(', ')}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
environment
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_config_response(raw, env_id)
|
|
81
|
+
environment = Array(raw['environments']).find { |e| e['id'] == env_id }
|
|
82
|
+
type = raw['type']
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
'id' => raw['id'] || '',
|
|
86
|
+
'key' => raw['key'],
|
|
87
|
+
'type' => type,
|
|
88
|
+
'valueType' => raw['valueType'],
|
|
89
|
+
'sendToClientSdk' => effective_send_to_client_sdk(type, raw['sendToClientSdk']),
|
|
90
|
+
'default' => raw['default'] || { 'rules' => [] },
|
|
91
|
+
'environment' => environment
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def effective_send_to_client_sdk(type, raw)
|
|
96
|
+
return true if type == 'feature_flag'
|
|
97
|
+
|
|
98
|
+
raw || false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
class Duration
|
|
5
|
+
PATTERN = /P(?:(?<days>\d+(?:\.\d+)?)D)?(?:T(?:(?<hours>\d+(?:\.\d+)?)H)?(?:(?<minutes>\d+(?:\.\d+)?)M)?(?:(?<seconds>\d+(?:\.\d+)?)S)?)?/
|
|
6
|
+
MINUTES_IN_SECONDS = 60
|
|
7
|
+
HOURS_IN_SECONDS = 60 * MINUTES_IN_SECONDS
|
|
8
|
+
DAYS_IN_SECONDS = 24 * HOURS_IN_SECONDS
|
|
9
|
+
|
|
10
|
+
def initialize(definition)
|
|
11
|
+
@seconds = self.class.parse(definition)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.parse(definition)
|
|
15
|
+
match = PATTERN.match(definition)
|
|
16
|
+
return 0 unless match
|
|
17
|
+
|
|
18
|
+
days = match[:days]&.to_f || 0
|
|
19
|
+
hours = match[:hours]&.to_f || 0
|
|
20
|
+
minutes = match[:minutes]&.to_f || 0
|
|
21
|
+
seconds = match[:seconds]&.to_f || 0
|
|
22
|
+
|
|
23
|
+
(days * DAYS_IN_SECONDS + hours * HOURS_IN_SECONDS + minutes * MINUTES_IN_SECONDS + seconds)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def in_seconds
|
|
27
|
+
@seconds
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def in_minutes
|
|
31
|
+
in_seconds / 60.0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def in_hours
|
|
35
|
+
in_minutes / 60.0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def in_days
|
|
39
|
+
in_hours / 24.0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def in_weeks
|
|
43
|
+
in_days / 7.0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_i
|
|
47
|
+
in_seconds.to_i
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_f
|
|
51
|
+
in_seconds.to_f
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def as_json
|
|
55
|
+
{ ms: in_seconds * 1000, seconds: in_seconds }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
class Encryption
|
|
5
|
+
CIPHER_TYPE = "aes-256-gcm" # 32/12
|
|
6
|
+
SEPARATOR = "--"
|
|
7
|
+
AUTH_TAG_LENGTH = 16
|
|
8
|
+
|
|
9
|
+
# Hexadecimal format ensures that generated keys are representable with
|
|
10
|
+
# plain text
|
|
11
|
+
#
|
|
12
|
+
# To convert back to the original string with the desired length:
|
|
13
|
+
# [ value ].pack("H*")
|
|
14
|
+
def self.generate_new_hex_key
|
|
15
|
+
generate_random_key.unpack("H*")[0]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(key_string_hex)
|
|
19
|
+
@key = [key_string_hex].pack("H*")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def encrypt(clear_text)
|
|
23
|
+
cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
|
|
24
|
+
cipher.encrypt
|
|
25
|
+
iv = cipher.random_iv
|
|
26
|
+
|
|
27
|
+
# load them into the cipher
|
|
28
|
+
cipher.key = @key
|
|
29
|
+
cipher.iv = iv
|
|
30
|
+
cipher.auth_data = ""
|
|
31
|
+
|
|
32
|
+
# encrypt the message
|
|
33
|
+
encrypted = cipher.update(clear_text)
|
|
34
|
+
encrypted << cipher.final
|
|
35
|
+
tag = cipher.auth_tag
|
|
36
|
+
|
|
37
|
+
# pack and join
|
|
38
|
+
[encrypted, iv, tag].map { |p| p.unpack("H*")[0] }.join(SEPARATOR)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def decrypt(encrypted_string)
|
|
42
|
+
encrypted_data, iv, auth_tag = encrypted_string.split(SEPARATOR).map { |p| [p].pack("H*") }
|
|
43
|
+
|
|
44
|
+
# Currently the OpenSSL bindings do not raise an error if auth_tag is
|
|
45
|
+
# truncated, which would allow an attacker to easily forge it. See
|
|
46
|
+
# https://github.com/ruby/openssl/issues/63
|
|
47
|
+
if auth_tag.bytesize != AUTH_TAG_LENGTH
|
|
48
|
+
raise "truncated auth_tag"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
|
|
52
|
+
cipher.decrypt
|
|
53
|
+
cipher.key = @key
|
|
54
|
+
cipher.iv = iv
|
|
55
|
+
|
|
56
|
+
cipher.auth_tag = auth_tag
|
|
57
|
+
|
|
58
|
+
# and decrypt it
|
|
59
|
+
decrypted = cipher.update(encrypted_data)
|
|
60
|
+
decrypted << cipher.final
|
|
61
|
+
decrypted
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def self.generate_random_key
|
|
67
|
+
SecureRandom.random_bytes(key_length)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.key_length
|
|
71
|
+
OpenSSL::Cipher.new(CIPHER_TYPE).key_len
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
class EnvVarParseError < Quonfig::Error
|
|
6
|
+
def initialize(env_var, config, env_var_name)
|
|
7
|
+
super("Evaluating #{config.key} couldn't coerce #{env_var_name} of #{env_var} to #{config.value_type}")
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
class InitializationTimeoutError < Quonfig::Error
|
|
6
|
+
def initialize(timeout_sec, key)
|
|
7
|
+
message = "Quonfig couldn't initialize in #{timeout_sec} second timeout. Trying to fetch key `#{key}`."
|
|
8
|
+
super(message)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
class InvalidSdkKeyError < Quonfig::Error
|
|
6
|
+
def initialize(key)
|
|
7
|
+
if key.nil? || key.empty?
|
|
8
|
+
message = 'No SDK key. Set QUONFIG_BACKEND_SDK_KEY env var or use QUONFIG_DATAFILE'
|
|
9
|
+
|
|
10
|
+
super(message)
|
|
11
|
+
else
|
|
12
|
+
message = "Your SDK key format is invalid. Expecting something like 123-development-yourapikey-SDK. You provided `#{key}`"
|
|
13
|
+
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
class MissingDefaultError < Quonfig::Error
|
|
6
|
+
def initialize(key)
|
|
7
|
+
message = "No value found for key '#{key}' and no default was provided.\n\nIf you'd prefer returning `nil` rather than raising when this occurs, modify the `on_no_default` value you provide in your Quonfig::Options."
|
|
8
|
+
|
|
9
|
+
super(message)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module Errors
|
|
5
|
+
class TypeMismatchError < Quonfig::Error
|
|
6
|
+
def initialize(key, expected, actual_value)
|
|
7
|
+
super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}: #{actual_value.inspect}")
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Records the result of evaluating a config's criteria and forensics for reporting
|
|
5
|
+
class Evaluation
|
|
6
|
+
attr_reader :value, :context
|
|
7
|
+
|
|
8
|
+
def initialize(config:, value:, value_index:, config_row_index:, context:, resolver:, conditional_value: nil)
|
|
9
|
+
@config = config
|
|
10
|
+
@value = value
|
|
11
|
+
@value_index = value_index
|
|
12
|
+
@config_row_index = config_row_index
|
|
13
|
+
@context = context
|
|
14
|
+
@resolver = resolver
|
|
15
|
+
@conditional_value = conditional_value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reason
|
|
19
|
+
@reason ||= @conditional_value ?
|
|
20
|
+
Quonfig::Reason.compute(
|
|
21
|
+
config: @config,
|
|
22
|
+
conditional_value: @conditional_value,
|
|
23
|
+
weighted_value_index: deepest_value.weighted_value_index
|
|
24
|
+
) :
|
|
25
|
+
Quonfig::Reason::UNKNOWN
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unwrapped_value
|
|
29
|
+
deepest_value.unwrap
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reportable_value
|
|
33
|
+
deepest_value.reportable_value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def report_and_return(evaluation_summary_aggregator)
|
|
37
|
+
report(evaluation_summary_aggregator)
|
|
38
|
+
|
|
39
|
+
unwrapped_value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def report(evaluation_summary_aggregator)
|
|
45
|
+
return if @config.config_type == :LOG_LEVEL
|
|
46
|
+
|
|
47
|
+
evaluation_summary_aggregator&.record(
|
|
48
|
+
config_key: @config.key,
|
|
49
|
+
config_type: @config.config_type,
|
|
50
|
+
counter: {
|
|
51
|
+
config_id: @config.id,
|
|
52
|
+
config_row_index: @config_row_index,
|
|
53
|
+
conditional_value_index: @value_index,
|
|
54
|
+
selected_value: deepest_value.reportable_wrapped_value,
|
|
55
|
+
weighted_value_index: deepest_value.weighted_value_index,
|
|
56
|
+
selected_index: nil # TODO
|
|
57
|
+
})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def deepest_value
|
|
61
|
+
@deepest_value ||= Quonfig::ConfigValueUnwrapper.deepest_value(@value, @config, @context, @resolver)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|