disposita 0.1.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 +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +322 -0
- data/lib/disposita/configuration.rb +209 -0
- data/lib/disposita/error.rb +46 -0
- data/lib/disposita/formats/yaml.rb +67 -0
- data/lib/disposita/internal/deep_merge.rb +35 -0
- data/lib/disposita/internal/definition.rb +42 -0
- data/lib/disposita/internal/hash_tools.rb +100 -0
- data/lib/disposita/internal/schema_builder.rb +97 -0
- data/lib/disposita/internal/type_adapter.rb +70 -0
- data/lib/disposita/paths.rb +99 -0
- data/lib/disposita/schema.rb +312 -0
- data/lib/disposita/source.rb +79 -0
- data/lib/disposita/sources/environment.rb +55 -0
- data/lib/disposita/sources/file.rb +112 -0
- data/lib/disposita/sources/hash.rb +29 -0
- data/lib/disposita/types.rb +152 -0
- data/lib/disposita/version.rb +6 -0
- data/lib/disposita.rb +85 -0
- metadata +65 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Disposita
|
|
4
|
+
# Describes a consumer-owned configuration domain and resolves its values.
|
|
5
|
+
#
|
|
6
|
+
# A Schema contains structure and rules, not application semantics. It knows
|
|
7
|
+
# that a setting is an Integer, required, secret or sourced from a particular
|
|
8
|
+
# environment variable; the consumer remains responsible for deciding what
|
|
9
|
+
# that value means to the application.
|
|
10
|
+
#
|
|
11
|
+
# Schemas are immutable after construction. Resolution is explicit and
|
|
12
|
+
# ordered: later sources override earlier sources, while schema defaults form
|
|
13
|
+
# the lowest-precedence layer. Environment and runtime overrides may be added
|
|
14
|
+
# conveniently through {#load}.
|
|
15
|
+
#
|
|
16
|
+
# @example Resolve ordered layers
|
|
17
|
+
# schema = Disposita.define(:app) do
|
|
18
|
+
# setting :port, type: Integer, default: 3000
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# global = Disposita::Sources::Hash.new({ port: 4000 }, name: :global)
|
|
22
|
+
# project = Disposita::Sources::Hash.new({ port: 5000 }, name: :project)
|
|
23
|
+
#
|
|
24
|
+
# schema.resolve([global, project]).port # => 5000
|
|
25
|
+
#
|
|
26
|
+
# @see Disposita.define
|
|
27
|
+
# @see Disposita::Configuration
|
|
28
|
+
class Schema
|
|
29
|
+
# @return [Symbol] logical name of the configuration domain.
|
|
30
|
+
attr_reader :name
|
|
31
|
+
|
|
32
|
+
# @return [Integer] consumer-owned persisted schema version.
|
|
33
|
+
attr_reader :version
|
|
34
|
+
|
|
35
|
+
# @return [Array<Disposita::Internal::SettingDefinition>] declared settings.
|
|
36
|
+
attr_reader :settings
|
|
37
|
+
|
|
38
|
+
# Builds a schema from already validated definitions.
|
|
39
|
+
#
|
|
40
|
+
# Consumers normally create schemas with {Disposita.define}; this
|
|
41
|
+
# initializer is public primarily to keep Schema as a normal Ruby object.
|
|
42
|
+
#
|
|
43
|
+
# @param name [String, Symbol] logical domain name.
|
|
44
|
+
# @param version [Integer, #to_int] persisted schema version.
|
|
45
|
+
# @param settings [Array<Disposita::Internal::SettingDefinition>] settings
|
|
46
|
+
# created by the schema DSL.
|
|
47
|
+
# @return [Disposita::Schema]
|
|
48
|
+
# @raise [ArgumentError, TypeError] if +version+ cannot be converted to an
|
|
49
|
+
# Integer.
|
|
50
|
+
def initialize(name:, version:, settings:)
|
|
51
|
+
@name = name.to_sym
|
|
52
|
+
@version = Integer(version)
|
|
53
|
+
@settings = settings.freeze
|
|
54
|
+
@settings_by_path = settings.to_h { |setting| [setting.path, setting] }.freeze
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Finds a setting definition by dotted, array or scalar path.
|
|
59
|
+
#
|
|
60
|
+
# This is the lowest-level introspection method. Most user-facing tooling
|
|
61
|
+
# should prefer {#describe}, which returns a stable metadata Hash instead of
|
|
62
|
+
# exposing the internal definition object.
|
|
63
|
+
#
|
|
64
|
+
# @param path [String, Array<String, Symbol>, Symbol] setting path,
|
|
65
|
+
# for example +"git.transport"+ or +[:git, :transport]+.
|
|
66
|
+
# @return [Disposita::Internal::SettingDefinition, nil] definition when the
|
|
67
|
+
# path exists, otherwise +nil+.
|
|
68
|
+
def setting(path)
|
|
69
|
+
@settings_by_path[normalize_path(path)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns user-oriented metadata for one declared setting.
|
|
73
|
+
#
|
|
74
|
+
# The returned Hash is suitable for CLI help, documentation generators and
|
|
75
|
+
# configuration editors. Secret values are never included; only metadata
|
|
76
|
+
# such as whether a setting is secret is exposed. Secret defaults are
|
|
77
|
+
# replaced by +[REDACTED]+; absent defaults remain +nil+.
|
|
78
|
+
#
|
|
79
|
+
# @param path [String, Array<String, Symbol>, Symbol] setting path.
|
|
80
|
+
# @return [Hash, nil] frozen metadata Hash, or +nil+ for an unknown path.
|
|
81
|
+
# @example
|
|
82
|
+
# schema.describe("git.transport")
|
|
83
|
+
# # => {
|
|
84
|
+
# # path: "git.transport",
|
|
85
|
+
# # type: "enum(:ssh, :https)",
|
|
86
|
+
# # default: :ssh,
|
|
87
|
+
# # has_default: true,
|
|
88
|
+
# # required: false,
|
|
89
|
+
# # secret: false,
|
|
90
|
+
# # env: nil,
|
|
91
|
+
# # description: "Preferred Git transport"
|
|
92
|
+
# # }
|
|
93
|
+
def describe(path)
|
|
94
|
+
item = setting(path)
|
|
95
|
+
return unless item
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
path: item.key,
|
|
99
|
+
type: Internal::TypeAdapter.describe(item.type),
|
|
100
|
+
default: if item.default?
|
|
101
|
+
item.secret? ? "[REDACTED]" : item.default
|
|
102
|
+
end,
|
|
103
|
+
has_default: item.default?,
|
|
104
|
+
required: item.required?,
|
|
105
|
+
secret: item.secret?,
|
|
106
|
+
env: item.env,
|
|
107
|
+
description: item.description
|
|
108
|
+
}.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Resolves configuration using convenient optional ENV and runtime layers.
|
|
112
|
+
#
|
|
113
|
+
# Sources supplied in +sources+ are applied in order. If +env+ is provided,
|
|
114
|
+
# an Environment source is appended after them. If +overrides+ is provided,
|
|
115
|
+
# an in-memory runtime source is appended last, giving runtime values the
|
|
116
|
+
# highest precedence.
|
|
117
|
+
#
|
|
118
|
+
# No global ENV lookup happens unless +env+ is explicitly provided. Passing
|
|
119
|
+
# +ENV+ is therefore an application decision rather than a side effect of
|
|
120
|
+
# loading Disposita.
|
|
121
|
+
#
|
|
122
|
+
# @param sources [Array<Disposita::Source>] ordered low-to-high precedence
|
|
123
|
+
# configuration sources.
|
|
124
|
+
# @param env [Hash, nil] environment-like Hash. When present, declared +env+
|
|
125
|
+
# names or +env_prefix+ derived names are read from it.
|
|
126
|
+
# @param env_prefix [String, nil] prefix used to derive names such as
|
|
127
|
+
# +APP_SERVER_PORT+ from the setting path +server.port+.
|
|
128
|
+
# @param overrides [Hash, nil] highest-precedence runtime values. These are
|
|
129
|
+
# never persisted automatically.
|
|
130
|
+
# @return [Disposita::Configuration] immutable typed configuration.
|
|
131
|
+
# @raise [Disposita::ValidationError] if a value cannot be validated.
|
|
132
|
+
# @raise [Disposita::UnknownSettingError] if a source contains undeclared
|
|
133
|
+
# settings.
|
|
134
|
+
# @raise [Disposita::VersionError] if persisted data declares a newer schema
|
|
135
|
+
# version than this runtime understands.
|
|
136
|
+
# @example
|
|
137
|
+
# config = schema.load(
|
|
138
|
+
# sources: [project_source],
|
|
139
|
+
# env: ENV,
|
|
140
|
+
# env_prefix: "MY_APP",
|
|
141
|
+
# overrides: { server: { port: 9292 } }
|
|
142
|
+
# )
|
|
143
|
+
def load(sources: [], env: nil, env_prefix: nil, overrides: nil)
|
|
144
|
+
effective_sources = Array(sources).dup
|
|
145
|
+
effective_sources << Sources::Environment.new(env: env, prefix: env_prefix) if env
|
|
146
|
+
effective_sources << Sources::Hash.new(overrides, name: :runtime) if overrides
|
|
147
|
+
resolve(effective_sources)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Resolves an explicit ordered list of sources.
|
|
151
|
+
#
|
|
152
|
+
# Defaults are applied first. Each source then contributes a partial layer;
|
|
153
|
+
# hashes are deep-merged, while arrays and scalar values replace lower
|
|
154
|
+
# precedence values. The final representation is coerced and validated only
|
|
155
|
+
# after all layers have been combined.
|
|
156
|
+
#
|
|
157
|
+
# @param sources [Array<Disposita::Source>, Disposita::Source] source or
|
|
158
|
+
# ordered sources from lowest to highest precedence.
|
|
159
|
+
# @return [Disposita::Configuration] immutable resolved configuration with
|
|
160
|
+
# provenance information for each selected value.
|
|
161
|
+
# @raise [Disposita::MissingSettingError] when a required setting remains
|
|
162
|
+
# absent after all layers are resolved.
|
|
163
|
+
# @raise [Disposita::CoercionError] when a value cannot be coerced to its
|
|
164
|
+
# declared type.
|
|
165
|
+
# @raise [Disposita::UnknownSettingError] when input contains an undeclared
|
|
166
|
+
# setting.
|
|
167
|
+
# @raise [Disposita::VersionError] when a source is newer than the schema.
|
|
168
|
+
def resolve(sources)
|
|
169
|
+
data = defaults
|
|
170
|
+
provenance = default_provenance
|
|
171
|
+
|
|
172
|
+
Array(sources).each do |source|
|
|
173
|
+
raw = source.read(self)
|
|
174
|
+
validate_version!(raw)
|
|
175
|
+
normalized = normalize_layer(raw)
|
|
176
|
+
reject_unknown!(normalized)
|
|
177
|
+
data = Internal::DeepMerge.call(data, normalized)
|
|
178
|
+
mark_provenance!(provenance, normalized, source.name)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
resolved = coerce_and_validate(data)
|
|
182
|
+
Configuration.new(schema: self, data: resolved, provenance: provenance)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Validates and writes explicit partial data to one writable source.
|
|
186
|
+
#
|
|
187
|
+
# Writes deliberately do not serialize a fully resolved Configuration: ENV,
|
|
188
|
+
# runtime overrides and defaults could otherwise leak into an unrelated
|
|
189
|
+
# file. Callers must choose both the target source and the values to persist.
|
|
190
|
+
# The current schema version is added automatically.
|
|
191
|
+
#
|
|
192
|
+
# @param source [Disposita::Source] explicit writable target.
|
|
193
|
+
# @param data [Hash] partial configuration values to persist.
|
|
194
|
+
# @return [Object] whatever the source returns from +#write+; file sources
|
|
195
|
+
# return their path.
|
|
196
|
+
# @raise [Disposita::SaveError] if the source is read-only or persistence
|
|
197
|
+
# fails.
|
|
198
|
+
# @raise [Disposita::ValidationError] if supplied values are invalid.
|
|
199
|
+
# @raise [Disposita::UnsafeSecretPersistenceError] if the target source
|
|
200
|
+
# refuses secret values.
|
|
201
|
+
# @example
|
|
202
|
+
# file = Disposita::Sources::File.new(".app.yml", name: :project)
|
|
203
|
+
# schema.write(file, server: { port: 9292 })
|
|
204
|
+
def write(source, data)
|
|
205
|
+
raise SaveError, "source #{source.name.inspect} is read-only" unless source.writable?
|
|
206
|
+
|
|
207
|
+
normalized = Internal::HashTools.symbolize(data)
|
|
208
|
+
reject_unknown!(normalized)
|
|
209
|
+
validated = validate_partial(normalized)
|
|
210
|
+
payload = { version: version }
|
|
211
|
+
payload = Internal::DeepMerge.call(payload, validated)
|
|
212
|
+
source.write(self, payload)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def normalize_path(path)
|
|
218
|
+
case path
|
|
219
|
+
when String then path.split(".").map!(&:to_sym)
|
|
220
|
+
when Array then path.map(&:to_sym)
|
|
221
|
+
else Array(path).map!(&:to_sym)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def defaults
|
|
226
|
+
settings.each_with_object({}) do |setting, result|
|
|
227
|
+
next unless setting.default?
|
|
228
|
+
|
|
229
|
+
Internal::HashTools.set(result, setting.path, Internal::HashTools.deep_dup(setting.default))
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def default_provenance
|
|
234
|
+
settings.each_with_object({}) do |setting, result|
|
|
235
|
+
result[setting.path] = :default if setting.default?
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def normalize_layer(raw)
|
|
240
|
+
raw = Internal::HashTools.symbolize(raw)
|
|
241
|
+
raw.except(:version)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def validate_version!(raw)
|
|
245
|
+
raw = Internal::HashTools.symbolize(raw)
|
|
246
|
+
persisted = raw[:version]
|
|
247
|
+
return unless persisted
|
|
248
|
+
|
|
249
|
+
persisted = Integer(persisted)
|
|
250
|
+
return if persisted <= version
|
|
251
|
+
|
|
252
|
+
raise VersionError, "configuration version #{persisted} is newer than schema version #{version}"
|
|
253
|
+
rescue ArgumentError, TypeError
|
|
254
|
+
raise VersionError, "configuration version must be an integer"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def reject_unknown!(data)
|
|
258
|
+
known = settings.map(&:path)
|
|
259
|
+
Internal::HashTools.flatten_keys(data).each do |path|
|
|
260
|
+
next if known.include?(path)
|
|
261
|
+
|
|
262
|
+
raise UnknownSettingError, "unknown setting: #{path.join('.')}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def mark_provenance!(provenance, layer, source_name)
|
|
267
|
+
Internal::HashTools.flatten_keys(layer).each { |path| provenance[path] = source_name }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def coerce_and_validate(data)
|
|
271
|
+
settings.each_with_object({}) do |setting, result|
|
|
272
|
+
value = Internal::HashTools.get(data, setting.path)
|
|
273
|
+
if value.equal?(Internal::UNDEFINED)
|
|
274
|
+
raise MissingSettingError, "missing required setting: #{setting.key}" if setting.required?
|
|
275
|
+
|
|
276
|
+
next
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
Internal::HashTools.set(result, setting.path, validate_value(setting, value))
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def validate_partial(data)
|
|
284
|
+
settings.each_with_object({}) do |setting, result|
|
|
285
|
+
value = Internal::HashTools.get(data, setting.path)
|
|
286
|
+
next if value.equal?(Internal::UNDEFINED)
|
|
287
|
+
|
|
288
|
+
Internal::HashTools.set(result, setting.path, validate_value(setting, value))
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def coerce_value(setting, value)
|
|
293
|
+
return value if Internal::TypeAdapter.valid?(setting.type, value)
|
|
294
|
+
return Internal::TypeAdapter.coerce(setting.type, value) if setting.coerce?
|
|
295
|
+
|
|
296
|
+
expected = Internal::TypeAdapter.describe(setting.type)
|
|
297
|
+
raise ValidationError, "#{setting.key} expected #{expected}, got #{value.class}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def validate_value(setting, value)
|
|
301
|
+
resolved = coerce_value(setting, value)
|
|
302
|
+
|
|
303
|
+
if setting.validator && !setting.validator.call(resolved)
|
|
304
|
+
raise ValidationError, "validation failed for #{setting.key}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
resolved
|
|
308
|
+
rescue CoercionError => e
|
|
309
|
+
raise CoercionError, "#{setting.key}: #{e.message}"
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Disposita
|
|
4
|
+
# Base contract for one configuration source/layer.
|
|
5
|
+
#
|
|
6
|
+
# A source contributes partial raw configuration data to a Schema. Sources are
|
|
7
|
+
# intentionally small: they know how to read one layer and, optionally, how
|
|
8
|
+
# to persist explicit data. Ordering and precedence remain the responsibility
|
|
9
|
+
# of {Disposita::Schema#resolve}.
|
|
10
|
+
#
|
|
11
|
+
# Custom integrations can subclass Source to read from another system without
|
|
12
|
+
# teaching Disposita about that system. A remote secret manager, database or
|
|
13
|
+
# organization-specific store can therefore participate like any built-in
|
|
14
|
+
# source.
|
|
15
|
+
#
|
|
16
|
+
# @example Implement a read-only custom source
|
|
17
|
+
# class DatabaseSource < Disposita::Source
|
|
18
|
+
# def read(_schema)
|
|
19
|
+
# { feature: { enabled: true } }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# source = DatabaseSource.new(name: :database)
|
|
24
|
+
# config = schema.resolve([source])
|
|
25
|
+
#
|
|
26
|
+
# @see Disposita::Sources::Hash
|
|
27
|
+
# @see Disposita::Sources::Environment
|
|
28
|
+
# @see Disposita::Sources::File
|
|
29
|
+
class Source
|
|
30
|
+
# @return [Symbol] stable name used for provenance diagnostics.
|
|
31
|
+
attr_reader :name
|
|
32
|
+
|
|
33
|
+
# @param name [String, Symbol] source name reported by
|
|
34
|
+
# {Disposita::Configuration#source_of}.
|
|
35
|
+
def initialize(name:)
|
|
36
|
+
@name = name.to_sym
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Reads this source's partial configuration layer.
|
|
40
|
+
#
|
|
41
|
+
# Subclasses must implement this method and should return only raw scalar,
|
|
42
|
+
# Array and Hash values. Schema coercion and validation happen later.
|
|
43
|
+
#
|
|
44
|
+
# @param _schema [Disposita::Schema] schema being resolved; custom sources
|
|
45
|
+
# may use it for introspection.
|
|
46
|
+
# @return [Hash] partial configuration data.
|
|
47
|
+
# @raise [NotImplementedError] in the base implementation.
|
|
48
|
+
def read(_schema)
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Indicates whether {#write} is supported.
|
|
53
|
+
#
|
|
54
|
+
# @return [Boolean] +false+ by default.
|
|
55
|
+
def writable? = false
|
|
56
|
+
|
|
57
|
+
# Indicates whether this source permits settings marked +secret: true+.
|
|
58
|
+
#
|
|
59
|
+
# Sources should default to the safer behavior and opt in only when their
|
|
60
|
+
# storage characteristics are appropriate for secret material.
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean] +false+ by default.
|
|
63
|
+
def allows_secrets? = false
|
|
64
|
+
|
|
65
|
+
# Persists explicit validated data to this source.
|
|
66
|
+
#
|
|
67
|
+
# Subclasses that return +true+ from {#writable?} must implement this method.
|
|
68
|
+
# Resolved configuration is never written implicitly; callers choose the
|
|
69
|
+
# target source and payload through {Disposita::Schema#write}.
|
|
70
|
+
#
|
|
71
|
+
# @param _schema [Disposita::Schema] schema performing the write.
|
|
72
|
+
# @param _data [Hash] validated partial payload including schema version.
|
|
73
|
+
# @return [Object]
|
|
74
|
+
# @raise [Disposita::SaveError] in the base implementation.
|
|
75
|
+
def write(_schema, _data)
|
|
76
|
+
raise SaveError, "source #{name.inspect} is read-only"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Disposita
|
|
4
|
+
module Sources
|
|
5
|
+
# Read-only source that maps environment variables to declared settings.
|
|
6
|
+
#
|
|
7
|
+
# Per-setting +env:+ names take precedence. When a prefix is supplied,
|
|
8
|
+
# settings without an explicit name derive one from their full schema path;
|
|
9
|
+
# for example +server.port+ with prefix +MY_APP+ becomes
|
|
10
|
+
# +MY_APP_SERVER_PORT+.
|
|
11
|
+
#
|
|
12
|
+
# Environment values remain raw strings here. Type coercion belongs to the
|
|
13
|
+
# Schema so every source follows the same validation rules.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# source = Disposita::Sources::Environment.new(
|
|
17
|
+
# env: { "APP_SERVER_PORT" => "9292" },
|
|
18
|
+
# prefix: "APP"
|
|
19
|
+
# )
|
|
20
|
+
# schema.resolve([source]).server.port # => 9292
|
|
21
|
+
class Environment < Source
|
|
22
|
+
# @param env [Hash] environment-like mapping; defaults to the process ENV.
|
|
23
|
+
# Pass a Hash to isolate resolution from the process environment.
|
|
24
|
+
# @param prefix [String, nil] optional generated-variable prefix.
|
|
25
|
+
# @param name [String, Symbol] provenance name.
|
|
26
|
+
def initialize(env: ENV, prefix: nil, name: :environment)
|
|
27
|
+
super(name: name)
|
|
28
|
+
@env = env
|
|
29
|
+
@prefix = prefix
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Builds a partial configuration layer from variables present in +env+.
|
|
33
|
+
#
|
|
34
|
+
# @param schema [Disposita::Schema] schema whose setting metadata is used
|
|
35
|
+
# to determine variable names.
|
|
36
|
+
# @return [Hash] nested raw configuration values.
|
|
37
|
+
def read(schema)
|
|
38
|
+
schema.settings.each_with_object({}) do |setting, data|
|
|
39
|
+
env_name = setting.env || generated_name(setting)
|
|
40
|
+
next unless env_name && @env.key?(env_name)
|
|
41
|
+
|
|
42
|
+
Internal::HashTools.set(data, setting.path, @env.fetch(env_name))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def generated_name(setting)
|
|
49
|
+
return unless @prefix
|
|
50
|
+
|
|
51
|
+
([@prefix] + setting.path).join("_").upcase
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Disposita
|
|
7
|
+
# Built-in layers for files, environment variables and in-memory values.
|
|
8
|
+
# Pass sources to Schema#resolve in increasing order of precedence.
|
|
9
|
+
module Sources
|
|
10
|
+
# File-backed configuration source with atomic persistence.
|
|
11
|
+
#
|
|
12
|
+
# YAML is the default format, but any codec implementing +load(String)+ and
|
|
13
|
+
# +dump(Hash)+ can be supplied. Missing files behave as empty layers, which
|
|
14
|
+
# makes optional global/project configuration straightforward.
|
|
15
|
+
#
|
|
16
|
+
# Secret persistence is denied by default because project configuration is
|
|
17
|
+
# frequently committed to source control. A caller may explicitly opt in
|
|
18
|
+
# with +allow_secrets: true+ for a trusted local target; such files are
|
|
19
|
+
# written with mode +0600+ where supported. This is a persistence policy,
|
|
20
|
+
# not encryption.
|
|
21
|
+
#
|
|
22
|
+
# Writes use a temporary file in the destination directory and rename it
|
|
23
|
+
# into place after flushing and fsyncing, reducing the chance of partially
|
|
24
|
+
# written configuration.
|
|
25
|
+
#
|
|
26
|
+
# @example Read and write a project file
|
|
27
|
+
# source = Disposita::Sources::File.new(".app.yml", name: :project)
|
|
28
|
+
# config = schema.resolve([source])
|
|
29
|
+
# schema.write(source, server: { port: 9292 })
|
|
30
|
+
class File < Source
|
|
31
|
+
# @return [String] expanded backing file path.
|
|
32
|
+
attr_reader :path
|
|
33
|
+
|
|
34
|
+
# @param path [String] configuration file path.
|
|
35
|
+
# @param name [String, Symbol] provenance name.
|
|
36
|
+
# @param format [Object] codec implementing +load+ and +dump+.
|
|
37
|
+
# @param allow_secrets [Boolean] whether secret settings may be written.
|
|
38
|
+
def initialize(path, name: :file, format: Formats::YAML, allow_secrets: false)
|
|
39
|
+
super(name: name)
|
|
40
|
+
@path = ::File.expand_path(path)
|
|
41
|
+
@format = format
|
|
42
|
+
@allow_secrets = allow_secrets
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Reads and decodes the file.
|
|
46
|
+
#
|
|
47
|
+
# @param _schema [Disposita::Schema] unused by the built-in file source.
|
|
48
|
+
# @return [Hash] normalized partial configuration; empty if file is absent.
|
|
49
|
+
# @raise [Disposita::ParseError] when the configured codec rejects content.
|
|
50
|
+
# @raise [Disposita::LoadError] when the file cannot be read.
|
|
51
|
+
def read(_schema)
|
|
52
|
+
return {} unless ::File.exist?(path)
|
|
53
|
+
|
|
54
|
+
Internal::HashTools.symbolize(@format.load(::File.read(path)))
|
|
55
|
+
rescue ParseError
|
|
56
|
+
raise
|
|
57
|
+
rescue SystemCallError => e
|
|
58
|
+
raise LoadError, "cannot read #{path}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] always +true+ for a File source.
|
|
62
|
+
def writable? = true
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] whether secret settings were explicitly allowed.
|
|
65
|
+
def allows_secrets? = @allow_secrets
|
|
66
|
+
|
|
67
|
+
# Atomically persists validated configuration data.
|
|
68
|
+
#
|
|
69
|
+
# Prefer calling {Disposita::Schema#write}; it validates types, unknown
|
|
70
|
+
# keys and schema version before delegating here.
|
|
71
|
+
#
|
|
72
|
+
# @param schema [Disposita::Schema] schema used to identify secret fields.
|
|
73
|
+
# @param data [Hash] validated payload.
|
|
74
|
+
# @return [String] backing file path.
|
|
75
|
+
# @raise [Disposita::UnsafeSecretPersistenceError] when secret data is
|
|
76
|
+
# present and +allow_secrets+ is false.
|
|
77
|
+
# @raise [Disposita::SaveError] when filesystem persistence fails.
|
|
78
|
+
def write(schema, data)
|
|
79
|
+
reject_secrets!(schema, data) unless allows_secrets?
|
|
80
|
+
replace_file(@format.dump(data))
|
|
81
|
+
path
|
|
82
|
+
rescue UnsafeSecretPersistenceError
|
|
83
|
+
raise
|
|
84
|
+
rescue SystemCallError => e
|
|
85
|
+
raise SaveError, "cannot write #{path}: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def replace_file(content)
|
|
91
|
+
directory = ::File.dirname(path)
|
|
92
|
+
FileUtils.mkdir_p(directory)
|
|
93
|
+
Tempfile.create([".disposita", ".tmp"], directory) do |temporary|
|
|
94
|
+
temporary.write(content)
|
|
95
|
+
temporary.flush
|
|
96
|
+
temporary.fsync
|
|
97
|
+
::File.chmod(0o600, temporary.path) if allows_secrets?
|
|
98
|
+
::File.rename(temporary.path, path)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def reject_secrets!(schema, data)
|
|
103
|
+
schema.settings.select(&:secret?).each do |setting|
|
|
104
|
+
next if Internal::HashTools.get(data, setting.path).equal?(Internal::UNDEFINED)
|
|
105
|
+
|
|
106
|
+
raise UnsafeSecretPersistenceError,
|
|
107
|
+
"refusing to persist secret setting #{setting.key.inspect} to #{path}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Disposita
|
|
4
|
+
module Sources
|
|
5
|
+
# Simple in-memory source for runtime overrides, tests and adapters.
|
|
6
|
+
#
|
|
7
|
+
# Keys are normalized to Symbols recursively when the source is created.
|
|
8
|
+
# Because the source is read-only, it is ideal for command-line arguments or
|
|
9
|
+
# values supplied programmatically at the highest-precedence layer.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# source = Disposita::Sources::Hash.new(
|
|
13
|
+
# { server: { port: 9292 } },
|
|
14
|
+
# name: :runtime
|
|
15
|
+
# )
|
|
16
|
+
class Hash < Source
|
|
17
|
+
# @param data [Hash] partial configuration layer.
|
|
18
|
+
# @param name [String, Symbol] provenance name.
|
|
19
|
+
def initialize(data, name: :memory)
|
|
20
|
+
super(name: name)
|
|
21
|
+
@data = Internal::HashTools.symbolize(data)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param _schema [Disposita::Schema] unused; included for Source contract.
|
|
25
|
+
# @return [Hash] normalized in-memory layer.
|
|
26
|
+
def read(_schema) = @data
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|