invar 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58a64d36feebf42e3d2e44e3cee20197683055dbb276f38701344db45a050e27
4
- data.tar.gz: f39265a13a4ae8a49cb2b7944599c75bab6723422d3b92f5838b777d7fa8dfab
3
+ metadata.gz: 6e3d07cd5d6d704c0bec06fe0803e0a5fd553f69e54f74501ddd7e9639353172
4
+ data.tar.gz: 3ecb948cb3b856d9f15d0d1d985f5e59db62532a724e8c4c748287532d1efac2
5
5
  SHA512:
6
- metadata.gz: c8d134ac530b0e9a2046922cc1055621d4120e92840eedee5fd5d527b1cd5a6880577a78cc4cbfa065dc751d48d9883a0edd36e661dcd83151b6967f1501cf6d
7
- data.tar.gz: e27ee5476e9efcf5871f78e50dd428398306a2dc48092b56c55a04be3e98c30fd2fad2d899ed7d35fff70ef297d2ede51941dd22197eb56222155f8b5d93be48
6
+ metadata.gz: 66ebee132d5bc1ba58e5876594435514d19b177fda6820d1ebe05ad6908b590f48b4466c30af950127cf3178a43eeb74573535fcfd32aa9f2165e378b657f4be
7
+ data.tar.gz: 3972595f9f126f76707b1bf6c2d76ef6803d5056808cf06977106256524be109ed9b0975a99931cb9acf694d9e1fb934916f5de0939e6dc65fc920e61c412195
data/README.md CHANGED
@@ -181,7 +181,9 @@ puts invar / :config / :database
181
181
  In your `Rakefile`, add:
182
182
 
183
183
  ```ruby
184
- require 'invar/rake'
184
+ require 'invar/rake/tasks'
185
+
186
+ Invar::Rake::Tasks.define namespace: 'app-name-here'
185
187
  ```
186
188
 
187
189
  Then you can use the rake tasks as reported by `rake -T`
@@ -272,6 +274,54 @@ puts invar[:config][:database][:host]
272
274
  >
273
275
  > **A**: Because key names could collide with method names, like `inspect`, `dup`, or `tap`.
274
276
 
277
+ ### Validation
278
+
279
+ You may provide `:configs_schema` and `:secrets_schema` keyword arguments to `Invar.new` and it will use those schema to
280
+ validate your `Invar::Reality`.
281
+
282
+ > **Note** Invar uses dry-schema to validate its internal `configs` and `secrets` trees, not the raw file contents.
283
+
284
+ `dry-schema` has a lot of shorthand which can add a lot of complexity, but here's a crash course:
285
+
286
+ * Keys are declared with the `required` method while the value's validator is declared to be a general `schema` type
287
+ with an accompanying block.
288
+ * In that block are the units of validation logic (*"predicates"*)
289
+ * They get unioned together with a **single** `&`
290
+ operator, **not** double `&&` like a boolean expression.
291
+ * There are predefined predicates for a bunch of simple properties
292
+
293
+ Generally when used with Invar it will look like:
294
+
295
+ ```ruby
296
+ require 'invar'
297
+
298
+ cnf_schema = Dry::Schema.define do
299
+ required(:upload).schema do
300
+ required(:mysql) { str? & filled? }
301
+ end
302
+
303
+ required(:upload).schema do
304
+ required(:max_bytes) { int? & filled? & gt?(0) }
305
+ required(:timeout) { int? & filled? & gt?(0) }
306
+ end
307
+ # ...
308
+ end
309
+
310
+ sec_schema = Dry::Schema.define do
311
+ required(:email).schema do
312
+ required(:username) { str? & filled? }
313
+ required(:password) { str? & filled? }
314
+ end
315
+ # ...
316
+ end
317
+
318
+ invar = Invar.new 'my-app', configs_schema: cnf_schema, secrets_schema: sec_schema
319
+ # ...
320
+ ```
321
+
322
+ If there are any unexpected or invalid keys in the *configs* or *secrets* files, Invar will complain about it with
323
+ a `SchemaValidationError`.
324
+
275
325
  ### Custom Locations
276
326
 
277
327
  You can customize the search paths by setting the environment variables `XDG_CONFIG_HOME` and/or `XDG_CONFIG_DIRS` any
@@ -280,6 +330,16 @@ time you run a Rake task or your application.
280
330
  # Looks in /tmp instead of ~/.config/
281
331
  XDG_CONFIG_HOME=/tmp bundle exec rake invar:paths
282
332
 
333
+ You can also specify the Lockbox decryption keyfile (eg. for automated production environments) by using the
334
+ `:decryption_keyfile` keyword argument to `Invar.new`. Be aware that your secrets are only as secure as the file that
335
+ keeps the master key, so double check that its file permissions are as restricted as possible.
336
+
337
+ ```ruby
338
+ require 'invar'
339
+
340
+ invar = Invar.new 'my-app', decryption_keyfile: '/etc/my-app/master_key'
341
+ ```
342
+
283
343
  ## Alternatives
284
344
 
285
345
  Some other gems with different approaches:
data/RELEASE_NOTES.md CHANGED
@@ -1,14 +1,20 @@
1
1
  # Release Notes
2
2
 
3
+ All notable changes to this project will be documented below.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project loosely follows
6
+ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
3
8
  ## [Unreleased]
4
9
 
5
10
  ### Major Changes
6
11
 
7
- * none
12
+ * Renamed `Invar::Invar` to `Invar::Reality`
13
+ * Maintenance Rake task inclusion now requires explicit define call
8
14
 
9
15
  ### Minor Changes
10
16
 
11
- * none
17
+ * Expanded docs
12
18
 
13
19
  ### Bugfixes
14
20
 
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'invar'
4
+
5
+ require 'rake'
6
+ require 'io/console'
7
+ require 'tempfile'
8
+
9
+ module Invar
10
+ # Rake task implementation.
11
+ #
12
+ # The actual rake tasks themselves are thinly defined in invar/rake.rb (so that the external include
13
+ # path is nice and short)
14
+ module Rake
15
+ # RakeTask builder class. Use Tasks.define to generate the needed tasks.
16
+ class Tasks
17
+ include ::Rake::Cloneable
18
+ include ::Rake::DSL
19
+
20
+ # Template config YAML file
21
+ CONFIG_TEMPLATE = SECRETS_TEMPLATE = <<~YML
22
+ ---
23
+ YML
24
+
25
+ # Shorthand for Invar::Rake::Tasks.new.define
26
+ #
27
+ # @param (see #define)
28
+ # @see Tasks#define
29
+ def self.define(**args, &block)
30
+ new.define(**args, &block)
31
+ end
32
+
33
+ # Defines helpful Rake tasks for the given namespace.
34
+ #
35
+ # @param namespace [String] The namespace to search for files within
36
+ def define(namespace: nil)
37
+ raise ArgumentError, ':namespace keyword argument cannot be nil' if namespace.nil?
38
+ raise ArgumentError, ':namespace keyword argument cannot be empty string' if namespace.empty?
39
+
40
+ define_all_tasks(namespace)
41
+ end
42
+
43
+ private
44
+
45
+ def define_all_tasks(app_namespace)
46
+ namespace :invar do
47
+ define_config_tasks(app_namespace)
48
+ define_secrets_tasks(app_namespace)
49
+
50
+ define_info_tasks(app_namespace)
51
+ end
52
+ end
53
+
54
+ def define_config_tasks(app_namespace)
55
+ namespace :configs do
56
+ desc 'Create a new configuration file'
57
+ task :create do
58
+ ::Invar::Rake::Tasks::ConfigTask.new(app_namespace).create
59
+ end
60
+
61
+ desc 'Edit the config in your default editor'
62
+ task :edit do
63
+ ::Invar::Rake::Tasks::ConfigTask.new(app_namespace).edit
64
+ end
65
+ end
66
+
67
+ # alias
68
+ namespace :config do
69
+ task create: ['configs:create']
70
+ task edit: ['configs:edit']
71
+ end
72
+ end
73
+
74
+ def define_secrets_tasks(app_namespace)
75
+ namespace :secrets do
76
+ desc 'Create a new encrypted secrets file'
77
+ task :create do
78
+ ::Invar::Rake::Tasks::SecretTask.new(app_namespace).create
79
+ end
80
+
81
+ desc 'Edit the encrypted secrets file in your default editor'
82
+ task :edit do
83
+ ::Invar::Rake::Tasks::SecretTask.new(app_namespace).edit
84
+ end
85
+ end
86
+
87
+ # alias
88
+ namespace :secret do
89
+ task create: ['secrets:create']
90
+ task edit: ['secrets:edit']
91
+ end
92
+ end
93
+
94
+ def define_info_tasks(app_namespace)
95
+ desc 'Show directories to be searched for the given namespace'
96
+ task :paths do
97
+ ::Invar::Rake::Tasks::StateTask.new(app_namespace).show_paths
98
+ end
99
+ end
100
+
101
+ # Tasks that use a namespace for file searching
102
+ class NamespacedTask
103
+ def initialize(namespace)
104
+ @locator = FileLocator.new(namespace)
105
+ end
106
+ end
107
+
108
+ # Configuration file actions.
109
+ class ConfigTask < NamespacedTask
110
+ # Creates a config file in the appropriate location
111
+ def create
112
+ config_dir = @locator.search_paths.first
113
+ config_dir.mkpath
114
+
115
+ file = config_dir / 'config.yml'
116
+ if file.exist?
117
+ warn <<~MSG
118
+ Abort: File exists. (#{ file })
119
+ Maybe you meant to edit the file with bundle exec rake invar:secrets:edit?
120
+ MSG
121
+ exit 1
122
+ end
123
+
124
+ file.write CONFIG_TEMPLATE
125
+
126
+ warn "Created file: #{ file }"
127
+ end
128
+
129
+ # Edits the existing config file in the appropriate location
130
+ def edit
131
+ configs_file = begin
132
+ @locator.find('config.yml')
133
+ rescue ::Invar::FileLocator::FileNotFoundError => e
134
+ warn <<~ERR
135
+ Abort: #{ e.message }. Searched in: #{ @locator.search_paths.join(', ') }
136
+ Maybe you used the wrong namespace or need to create the file with bundle exec rake invar:configs:create?
137
+ ERR
138
+ exit 1
139
+ end
140
+
141
+ system(ENV.fetch('EDITOR', 'editor'), configs_file.to_s, exception: true)
142
+
143
+ warn "File saved to: #{ configs_file }"
144
+ end
145
+ end
146
+
147
+ # Secrets file actions.
148
+ class SecretTask < NamespacedTask
149
+ # Instructions hint for how to handle secret keys.
150
+ SECRETS_INSTRUCTIONS = <<~INST
151
+ Save this key to a secure password manager. You will need it to edit the secrets.yml file.
152
+ INST
153
+
154
+ # Creates a new encrypted secrets file and prints the generated encryption key to STDOUT
155
+ def create
156
+ config_dir = @locator.search_paths.first
157
+ config_dir.mkpath
158
+
159
+ file = config_dir / 'secrets.yml'
160
+
161
+ if file.exist?
162
+ warn <<~ERR
163
+ Abort: File exists. (#{ file })
164
+ Maybe you meant to edit the file with bundle exec rake invar:secrets:edit?
165
+ ERR
166
+ exit 1
167
+ end
168
+
169
+ encryption_key = Lockbox.generate_key
170
+
171
+ write_encrypted_file(file, encryption_key, SECRETS_TEMPLATE)
172
+
173
+ warn "Created file #{ file }"
174
+
175
+ warn SECRETS_INSTRUCTIONS
176
+ warn 'Generated key is:'
177
+ puts encryption_key
178
+ end
179
+
180
+ # Opens an editor for the decrypted contents of the secrets file. After closing the editor, the file will be
181
+ # updated with the new encrypted contents.
182
+ def edit
183
+ secrets_file = begin
184
+ @locator.find('secrets.yml')
185
+ rescue ::Invar::FileLocator::FileNotFoundError => e
186
+ warn <<~ERR
187
+ Abort: #{ e.message }. Searched in: #{ @locator.search_paths.join(', ') }
188
+ Maybe you used the wrong namespace or need to create the file with bundle exec rake invar:secrets:create?
189
+ ERR
190
+ exit 1
191
+ end
192
+
193
+ edit_encrypted_file(secrets_file)
194
+
195
+ warn "File saved to #{ secrets_file }"
196
+ end
197
+
198
+ private
199
+
200
+ def write_encrypted_file(file_path, encryption_key, content)
201
+ lockbox = Lockbox.new(key: encryption_key)
202
+
203
+ encrypted_data = lockbox.encrypt(content)
204
+
205
+ # TODO: replace File.opens with photo_path.binwrite(uri.data) once FakeFS can handle it
206
+ File.open(file_path.to_s, 'wb') { |f| f.write encrypted_data }
207
+ end
208
+
209
+ def edit_encrypted_file(file_path)
210
+ encryption_key = determine_key(file_path)
211
+
212
+ lockbox = build_lockbox(encryption_key)
213
+
214
+ file_str = Tempfile.create(file_path.basename.to_s) do |tmp_file|
215
+ decrypted = lockbox.decrypt(file_path.binread)
216
+
217
+ tmp_file.write(decrypted)
218
+ tmp_file.rewind # rewind needed because file does not get closed after write
219
+ system(ENV.fetch('EDITOR', 'editor'), tmp_file.path, exception: true)
220
+ tmp_file.read
221
+ end
222
+
223
+ write_encrypted_file(file_path, encryption_key, file_str)
224
+ end
225
+
226
+ def determine_key(file_path)
227
+ encryption_key = Lockbox.master_key
228
+
229
+ if encryption_key.nil? && $stdin.respond_to?(:noecho)
230
+ warn "Enter master key to decrypt #{ file_path }:"
231
+ encryption_key = $stdin.noecho(&:gets).strip
232
+ end
233
+
234
+ encryption_key
235
+ end
236
+
237
+ def build_lockbox(encryption_key)
238
+ Lockbox.new(key: encryption_key)
239
+ rescue ArgumentError => e
240
+ raise SecretsFileEncryptionError, e
241
+ end
242
+ end
243
+
244
+ # General status tasks
245
+ class StateTask < NamespacedTask
246
+ # Prints the current paths to be searched in
247
+ def show_paths
248
+ warn @locator.search_paths.join("\n")
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'lockbox'
5
+ require 'pathname'
6
+ require 'dry/schema'
7
+
8
+ require 'invar/file_locator'
9
+ require 'invar/scope'
10
+
11
+ # :nodoc:
12
+ module Invar
13
+ # Stores application config, secrets, and environmental variables. Environment variables come from ENV at the time of
14
+ # instantiation, while configs and secrets are loaded from respective files in the appropriate location. The secrets
15
+ # file is kept encrypted at rest.
16
+ #
17
+ # Fetch information from a Reality by using the slash operator or square brackets:
18
+ #
19
+ # invar = Invar::Reality.new(namespace: 'test-app')
20
+ #
21
+ # puts invar / :config / :database / :host
22
+ # puts invar[:config][:database][:host] # same as above
23
+ #
24
+ # puts invar / :secrets / :database / :user
25
+ #
26
+ # Note: `Invar.new` is a shorthand for Invar::Reality.new
27
+ #
28
+ # Information known by a Reality is immutable. You may use the `#pretend` method to simulate different values during
29
+ # testing.
30
+ #
31
+ # # In your tests, define an after_load hook
32
+ # require 'invar/test'
33
+ # Invar.after_load do |reality|
34
+ # reality[:config][:database][:host].pretend 'example.com'
35
+ # end
36
+ #
37
+ # # then later, in your app, it will use the pretend value
38
+ # invar = Invar.new namespace: 'my-app'
39
+ # puts invar / :config / :database / :host # prints example.com
40
+ #
41
+ # @see Invar.new
42
+ # @see Reality#after_load
43
+ # @see Reality#pretend
44
+ class Reality
45
+ # Allowed permissions modes for lockfile. Readable or read-writable by the current user only
46
+ ALLOWED_LOCKFILE_MODES = [0o600, 0o400].freeze
47
+
48
+ # Name of the default key file to be searched for within config directories
49
+ DEFAULT_KEY_FILE_NAME = 'master_key'
50
+
51
+ # Constructs a new Invar.
52
+ #
53
+ # It will search for config, secrets, and decryption key files using the XDG specification.
54
+ #
55
+ # The secrets file is decrypted using Lockbox. The key will be requested from these locations in order:
56
+ #
57
+ # 1. the Lockbox.master_key variable
58
+ # 2. the LOCKBOX_MASTER_KEY environment variable.
59
+ # 3. saved in a secure key file (recommended)
60
+ # 4. manual terminal prompt entry (recommended)
61
+ #
62
+ # The :decryption_keyfile argument specifies the filename to read for option 3. It will be searched for in
63
+ # the same XDG locations as the secrets file. The decryption keyfile will be checked for safe permission modes.
64
+ #
65
+ # NEVER hardcode your encryption key. This class intentionally does not accept a raw string of your decryption
66
+ # key to discourage hardcoding your encryption key and committing it to version control.
67
+ #
68
+ # @param [String] namespace name of the subdirectory within XDG locations
69
+ # @param [#read] decryption_keyfile Any #read capable object referring to the decryption key file
70
+ def initialize(namespace:, decryption_keyfile: nil, configs_schema: nil, secrets_schema: nil)
71
+ locator = FileLocator.new(namespace)
72
+ search_paths = locator.search_paths.join(', ')
73
+
74
+ begin
75
+ @configs = Scope.new(load_configs(locator))
76
+ rescue FileLocator::FileNotFoundError
77
+ raise MissingConfigFileError,
78
+ "No config file found. Create config.yml in one of these locations: #{ search_paths }"
79
+ end
80
+
81
+ begin
82
+ @secrets = Scope.new(load_secrets(locator, decryption_keyfile))
83
+ rescue FileLocator::FileNotFoundError
84
+ raise MissingSecretsFileError,
85
+ "No secrets file found. Create encrypted secrets.yml in one of these locations: #{ search_paths }"
86
+ end
87
+
88
+ freeze
89
+ # instance_eval(&self.class.__override_block__)
90
+ self.class.__override_block__&.call(self)
91
+
92
+ RealityValidator.new(configs_schema, secrets_schema).validate(@configs, @secrets)
93
+ end
94
+
95
+ class << self
96
+ attr_accessor :__override_block__
97
+ end
98
+
99
+ # Fetch from one of the two base scopes: :config or :secret.
100
+ # Plural names are also accepted (ie. :configs and :secrets).
101
+ #
102
+ # @param base_scope [Symbol, String]
103
+ def fetch(base_scope)
104
+ case base_scope
105
+ when /configs?/i
106
+ @configs
107
+ when /secrets?/i
108
+ @secrets
109
+ else
110
+ raise ArgumentError, 'The root scope name must be either :config or :secret.'
111
+ end
112
+ end
113
+
114
+ alias / fetch
115
+ alias [] fetch
116
+
117
+ private
118
+
119
+ def load_configs(locator)
120
+ file = locator.find('config', EXT::YAML)
121
+
122
+ configs = parse(file.read)
123
+ env_hash = ENV.to_hash.transform_keys(&:downcase).transform_keys(&:to_sym)
124
+
125
+ collision_key = configs.keys.collect(&:downcase).find { |key| env_hash.key? key }
126
+ if collision_key
127
+ hint = EnvConfigCollisionError::HINT
128
+ raise EnvConfigCollisionError,
129
+ "Both the environment and your config file have key #{ collision_key }. #{ hint }"
130
+ end
131
+
132
+ configs.merge(env_hash)
133
+ end
134
+
135
+ def load_secrets(locator, decryption_keyfile)
136
+ file = locator.find('secrets', EXT::YAML)
137
+
138
+ lockbox = begin
139
+ decryption_key = Lockbox.master_key || resolve_key(decryption_keyfile, locator,
140
+ "Enter master key to decrypt #{ file }:")
141
+
142
+ Lockbox.new(key: decryption_key)
143
+ rescue Lockbox::Error => e
144
+ raise SecretsFileDecryptionError, e
145
+ end
146
+
147
+ bytes = file.binread
148
+ file_data = begin
149
+ lockbox.decrypt bytes
150
+ rescue Lockbox::DecryptionError => e
151
+ hint = 'Perhaps you used the wrong file decryption key?'
152
+ raise SecretsFileDecryptionError, "Failed to open #{ file } (#{ e }). #{ hint }"
153
+ end
154
+
155
+ parse(file_data)
156
+ end
157
+
158
+ def parse(file_data)
159
+ YAML.safe_load(file_data, symbolize_names: true) || {}
160
+ end
161
+
162
+ def resolve_key(pathname, locator, prompt)
163
+ key_file = locator.find(pathname || DEFAULT_KEY_FILE_NAME)
164
+
165
+ read_keyfile(key_file)
166
+ rescue FileLocator::FileNotFoundError
167
+ if $stdin.respond_to?(:noecho)
168
+ warn prompt
169
+ $stdin.noecho(&:gets).strip
170
+ else
171
+ raise SecretsFileDecryptionError,
172
+ "Could not find file '#{ pathname }'. Searched in: #{ locator.search_paths }"
173
+ end
174
+ end
175
+
176
+ def read_keyfile(key_file)
177
+ permissions_mask = 0o777 # only the lowest three digits are perms, so masking
178
+ stat = key_file.stat
179
+ file_mode = stat.mode & permissions_mask
180
+ # TODO: use stat.world_readable? etc instead
181
+ unless ALLOWED_LOCKFILE_MODES.include? file_mode
182
+ hint = "Try: chmod 600 #{ key_file }"
183
+ raise SecretsFileDecryptionError,
184
+ format("File '%<path>s' has improper permissions (%<mode>04o). %<hint>s",
185
+ path: key_file,
186
+ mode: file_mode,
187
+ hint: hint)
188
+ end
189
+
190
+ key_file.read.strip
191
+ end
192
+
193
+ # Validates a Reality object
194
+ class RealityValidator
195
+ def initialize(configs_schema, secrets_schema)
196
+ configs_schema ||= Dry::Schema.define
197
+ env_schema = build_env_schema
198
+
199
+ @schema = Dry::Schema.define do
200
+ config.validate_keys = true
201
+
202
+ required(:configs).hash(configs_schema & env_schema)
203
+
204
+ if secrets_schema
205
+ required(:secrets).hash(secrets_schema)
206
+ else
207
+ required(:secrets)
208
+ end
209
+ end
210
+ end
211
+
212
+ def validate(configs, secrets)
213
+ validation = @schema.call(configs: configs.to_h,
214
+ secrets: secrets.to_h)
215
+
216
+ return true if validation.success?
217
+
218
+ errs = validation.errors.messages.collect do |message|
219
+ [message.path.collect do |p|
220
+ ":#{ p }"
221
+ end.join(' / '), message.text].join(' ')
222
+ end
223
+
224
+ raise SchemaValidationError, <<~ERR
225
+ Validation errors:
226
+ #{ errs.join("\n ") }
227
+ ERR
228
+ end
229
+
230
+ private
231
+
232
+ # Special schema for just the env variables, listing them explicitly allows for using validate_keys
233
+ def build_env_schema
234
+ env_keys = ENV.to_hash.transform_keys(&:downcase).transform_keys(&:to_sym).keys
235
+ Dry::Schema.define do
236
+ env_keys.each do |key|
237
+ optional(key)
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ # Raised when no config file can be found within the search paths.
245
+ class MissingConfigFileError < RuntimeError
246
+ end
247
+
248
+ # Raised when no secrets file can be found within the search paths.
249
+ class MissingSecretsFileError < RuntimeError
250
+ end
251
+
252
+ # Raised when an error is encountered during secrets file encryption
253
+ class SecretsFileEncryptionError < RuntimeError
254
+ end
255
+
256
+ # Raised when an error is encountered during secrets file decryption
257
+ class SecretsFileDecryptionError < RuntimeError
258
+ end
259
+
260
+ # Raised when a key is defined in both the environment and the configuration file.
261
+ class EnvConfigCollisionError < RuntimeError
262
+ # Message hinting at possible solution
263
+ HINT = 'Either rename your config entry or remove the environment variable.'
264
+ end
265
+
266
+ # Raised when there are config or secrets files found at multiple locations. You can resolve this by deciding on
267
+ # one correct location and removing the alternate file(s).
268
+ class AmbiguousSourceError < RuntimeError
269
+ # Message hinting at possible solution
270
+ HINT = 'Choose 1 correct one and delete the others.'
271
+ end
272
+
273
+ # Raised when #pretend is called but the testing extension has not been loaded.
274
+ #
275
+ # When raised during normal operation, it may mean the application is calling #pretend directly, which is strongly
276
+ # discouraged. The feature is meant for testing.
277
+ #
278
+ # @see Invar#pretend
279
+ class ImmutableRealityError < NoMethodError
280
+ # Message and hint for a possible solution
281
+ MSG = <<~MSG
282
+ Method 'pretend' is defined in the testing extension. Try adding this to your test suite config file:
283
+ require 'invar/test'
284
+ MSG
285
+ end
286
+
287
+ # Raised when schema validation fails
288
+ class SchemaValidationError < RuntimeError
289
+ end
290
+ end
data/lib/invar/scope.rb CHANGED
@@ -30,6 +30,9 @@ module Invar
30
30
  raise ::Invar::ImmutableRealityError, ::Invar::ImmutableRealityError::MSG
31
31
  end
32
32
 
33
+ # Returns a hash representation of this scope and subscopes.
34
+ #
35
+ # @return [Hash] a hash representation of this scope
33
36
  def to_h
34
37
  @data.merge(@data_override).to_h.transform_values do |value|
35
38
  case value
data/lib/invar/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Invar
4
4
  # Current version of the gem
5
- VERSION = '0.4.0'
5
+ VERSION = '0.5.0'
6
6
  end
data/lib/invar.rb CHANGED
@@ -1,216 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'invar/version'
4
- require 'invar/file_locator'
5
- require 'invar/scope'
4
+ require 'invar/reality'
6
5
 
7
- require 'yaml'
8
- require 'lockbox'
9
- require 'pathname'
10
- require 'dry/schema'
11
-
12
- # Invar is a Ruby Gem that provides a single source of truth for application configuration and environment.
6
+ # Invar is a Ruby Gem that provides a single source of truth for application configuration, secrets, and environment
7
+ # variables.
13
8
  module Invar
14
- # Alias for Invar::Invar.new
9
+ # Alias for Invar::Reality.new
15
10
  #
16
- # @see Invar.new
11
+ # @see Invar::Reality.new
17
12
  def self.new(**args)
18
- Invar.new(**args)
19
- end
20
-
21
- # A wrapper for config and ENV variable data. It endeavours to limit your situation to a single source of truth.
22
- class Invar
23
- # Allowed permissions modes for lockfile. Readable or read-writable by the current user only
24
- ALLOWED_LOCKFILE_MODES = [0o600, 0o400].freeze
25
-
26
- # Name of the default key file to be searched for within config directories
27
- DEFAULT_KEY_FILE_NAME = 'master_key'
28
-
29
- # Constructs a new Invar.
30
- #
31
- # It will search for config, secrets, and decryption key files using the XDG specification.
32
- #
33
- # The secrets file is decrypted using Lockbox. The key will be requested from these locations in order:
34
- #
35
- # 1. the Lockbox.master_key variable
36
- # 2. the LOCKBOX_MASTER_KEY environment variable.
37
- # 3. saved in a secure key file (recommended)
38
- # 4. manual terminal prompt entry (recommended)
39
- #
40
- # The :decryption_keyfile argument specifies the filename to read for option 3. It will be searched for in
41
- # the same XDG locations as the secrets file. The decryption keyfile will be checked for safe permission modes.
42
- #
43
- # NEVER hardcode your encryption key. This class intentionally does not accept a raw string of your decryption
44
- # key to discourage hardcoding your encryption key and committing it to version control.
45
- #
46
- # @param [String] namespace name of the subdirectory within XDG locations
47
- # @param [#read] decryption_keyfile Any #read capable object referring to the decryption key file
48
- def initialize(namespace:, decryption_keyfile: nil, configs_schema: nil, secrets_schema: nil)
49
- locator = FileLocator.new(namespace)
50
- search_paths = locator.search_paths.join(', ')
51
-
52
- begin
53
- @configs = Scope.new(load_configs(locator))
54
- rescue FileLocator::FileNotFoundError
55
- raise MissingConfigFileError,
56
- "No config file found. Create config.yml in one of these locations: #{ search_paths }"
57
- end
58
-
59
- begin
60
- @secrets = Scope.new(load_secrets(locator, decryption_keyfile))
61
- rescue FileLocator::FileNotFoundError
62
- raise MissingSecretsFileError,
63
- "No secrets file found. Create encrypted secrets.yml in one of these locations: #{ search_paths }"
64
- end
65
-
66
- freeze
67
- # instance_eval(&self.class.__override_block__)
68
- self.class.__override_block__&.call(self)
69
-
70
- validate_with configs_schema, secrets_schema
71
- end
72
-
73
- class << self
74
- attr_accessor :__override_block__
75
- end
76
-
77
- # Fetch from one of the two base scopes: :config or :secret.
78
- # Plural names are also accepted (ie. :configs and :secrets).
79
- #
80
- # @param base_scope [Symbol, String]
81
- def fetch(base_scope)
82
- case base_scope
83
- when /configs?/i
84
- @configs
85
- when /secrets?/i
86
- @secrets
87
- else
88
- raise ArgumentError, 'The root scope name must be either :config or :secret.'
89
- end
90
- end
91
-
92
- alias / fetch
93
- alias [] fetch
94
-
95
- private
96
-
97
- def load_configs(locator)
98
- file = locator.find('config', EXT::YAML)
99
-
100
- configs = parse(file.read)
101
-
102
- collision_key = configs.keys.collect(&:downcase).find { |key| env_hash.key? key }
103
- if collision_key
104
- hint = EnvConfigCollisionError::HINT
105
- raise EnvConfigCollisionError,
106
- "Both the environment and your config file have key #{ collision_key }. #{ hint }"
107
- end
108
-
109
- configs.merge(env_hash)
110
- end
111
-
112
- def env_hash
113
- ENV.to_hash.transform_keys(&:downcase).transform_keys(&:to_sym)
114
- end
115
-
116
- def load_secrets(locator, decryption_keyfile)
117
- file = locator.find('secrets', EXT::YAML)
118
-
119
- lockbox = begin
120
- decryption_key = Lockbox.master_key || resolve_key(decryption_keyfile, locator,
121
- "Enter master key to decrypt #{ file }:")
122
-
123
- Lockbox.new(key: decryption_key)
124
- rescue Lockbox::Error => e
125
- raise SecretsFileDecryptionError, e
126
- end
127
-
128
- bytes = file.binread
129
- file_data = begin
130
- lockbox.decrypt bytes
131
- rescue Lockbox::DecryptionError => e
132
- hint = 'Perhaps you used the wrong file decryption key?'
133
- raise SecretsFileDecryptionError, "Failed to open #{ file } (#{ e }). #{ hint }"
134
- end
135
-
136
- parse(file_data)
137
- end
138
-
139
- def parse(file_data)
140
- YAML.safe_load(file_data, symbolize_names: true) || {}
141
- end
142
-
143
- def resolve_key(pathname, locator, prompt)
144
- key_file = locator.find(pathname || DEFAULT_KEY_FILE_NAME)
145
-
146
- read_keyfile(key_file)
147
- rescue FileLocator::FileNotFoundError
148
- if $stdin.respond_to?(:noecho)
149
- warn prompt
150
- $stdin.noecho(&:gets).strip
151
- else
152
- raise SecretsFileDecryptionError,
153
- "Could not find file '#{ pathname }'. Searched in: #{ locator.search_paths }"
154
- end
155
- end
156
-
157
- def read_keyfile(key_file)
158
- permissions_mask = 0o777 # only the lowest three digits are perms, so masking
159
- stat = key_file.stat
160
- file_mode = stat.mode & permissions_mask
161
- # TODO: use stat.world_readable? etc instead
162
- unless ALLOWED_LOCKFILE_MODES.include? file_mode
163
- hint = "Try: chmod 600 #{ key_file }"
164
- raise SecretsFileDecryptionError,
165
- format("File '%<path>s' has improper permissions (%<mode>04o). %<hint>s",
166
- path: key_file,
167
- mode: file_mode,
168
- hint: hint)
169
- end
170
-
171
- key_file.read.strip
172
- end
173
-
174
- def validate_with(configs_schema, secrets_schema)
175
- env_keys = env_hash.keys
176
-
177
- configs_schema ||= Dry::Schema.define
178
-
179
- # Special schema for just the env variables, listing them explicitly allows for using validate_keys
180
- env_schema = Dry::Schema.define do
181
- env_keys.each do |key|
182
- optional(key)
183
- end
184
- end
185
-
186
- schema = Dry::Schema.define do
187
- config.validate_keys = true
188
-
189
- required(:configs).hash(configs_schema & env_schema)
190
-
191
- if secrets_schema
192
- required(:secrets).hash(secrets_schema)
193
- else
194
- required(:secrets)
195
- end
196
- end
197
-
198
- validation = schema.call(configs: @configs.to_h,
199
- secrets: @secrets.to_h)
200
-
201
- return true if validation.success?
202
-
203
- errs = validation.errors.messages.collect do |message|
204
- [message.path.collect do |p|
205
- ":#{ p }"
206
- end.join(' / '), message.text].join(' ')
207
- end
208
-
209
- raise SchemaValidationError, <<~ERR
210
- Validation errors:
211
- #{ errs.join("\n ") }
212
- ERR
213
- end
13
+ ::Invar::Reality.new(**args)
214
14
  end
215
15
 
216
16
  class << self
@@ -220,54 +20,7 @@ module Invar
220
20
  # @yieldparam the configs from the Invar
221
21
  # @return [void]
222
22
  def after_load(&block)
223
- ::Invar::Invar.__override_block__ = block
23
+ ::Invar::Reality.__override_block__ = block
224
24
  end
225
25
  end
226
-
227
- # Raised when no config file can be found within the search paths.
228
- class MissingConfigFileError < RuntimeError
229
- end
230
-
231
- # Raised when no secrets file can be found within the search paths.
232
- class MissingSecretsFileError < RuntimeError
233
- end
234
-
235
- # Raised when an error is encountered during secrets file encryption
236
- class SecretsFileEncryptionError < RuntimeError
237
- end
238
-
239
- # Raised when an error is encountered during secrets file decryption
240
- class SecretsFileDecryptionError < RuntimeError
241
- end
242
-
243
- # Raised when a key is defined in both the environment and the configuration file.
244
- class EnvConfigCollisionError < RuntimeError
245
- # Message hinting at possible solution
246
- HINT = 'Either rename your config entry or remove the environment variable.'
247
- end
248
-
249
- # Raised when there are config or secrets files found at multiple locations. You can resolve this by deciding on
250
- # one correct location and removing the alternate file(s).
251
- class AmbiguousSourceError < RuntimeError
252
- # Message hinting at possible solution
253
- HINT = 'Choose 1 correct one and delete the others.'
254
- end
255
-
256
- # Raised when #pretend is called but the testing extension has not been loaded.
257
- #
258
- # When raised during normal operation, it may mean the application is calling #pretend directly, which is strongly
259
- # discouraged. The feature is meant for testing.
260
- #
261
- # @see Invar#pretend
262
- class ImmutableRealityError < NoMethodError
263
- # Message and hint for a possible solution
264
- MSG = <<~MSG
265
- Method 'pretend' is defined in the testing extension. Try adding this to your test suite config file:
266
- require 'invar/test'
267
- MSG
268
- end
269
-
270
- # Raised when schema validation fails
271
- class SchemaValidationError < RuntimeError
272
- end
273
26
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: invar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robin Miller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-12-08 00:00:00.000000000 Z
11
+ date: 2022-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-schema
@@ -144,8 +144,8 @@ files:
144
144
  - invar.gemspec
145
145
  - lib/invar.rb
146
146
  - lib/invar/file_locator.rb
147
- - lib/invar/rake.rb
148
- - lib/invar/rake_tasks.rb
147
+ - lib/invar/rake/tasks.rb
148
+ - lib/invar/reality.rb
149
149
  - lib/invar/scope.rb
150
150
  - lib/invar/test.rb
151
151
  - lib/invar/version.rb
data/lib/invar/rake.rb DELETED
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rake'
4
- require 'io/console'
5
- require 'tempfile'
6
- require_relative 'rake_tasks'
7
-
8
- namespace :invar do
9
- namespace :configs do
10
- desc 'Create a new configuration file'
11
- task :create, [:namespace] do |task, args|
12
- Invar::RakeTasks::ConfigTask.new(args[:namespace], task).create
13
- end
14
-
15
- desc 'Edit the config in your default editor'
16
- task :edit, [:namespace] do |task, args|
17
- Invar::RakeTasks::ConfigTask.new(args[:namespace], task).edit
18
- end
19
- end
20
-
21
- namespace :secrets do
22
- desc 'Create a new encrypted secrets file'
23
- task :create, [:namespace] do |task, args|
24
- Invar::RakeTasks::SecretTask.new(args[:namespace], task).create
25
- end
26
-
27
- desc 'Edit the encrypted secrets file in your default editor'
28
- task :edit, [:namespace] do |task, args|
29
- Invar::RakeTasks::SecretTask.new(args[:namespace], task).edit
30
- end
31
- end
32
-
33
- # aliasing
34
- namespace :config do
35
- task :create, [:namespace] => ['configs:create']
36
- task :edit, [:namespace] => ['configs:edit']
37
- end
38
- namespace :secret do
39
- task :create, [:namespace] => ['secrets:create']
40
- task :edit, [:namespace] => ['secrets:edit']
41
- end
42
-
43
- desc 'Show directories to be searched for the given namespace'
44
- task :paths, [:namespace] do |task, args|
45
- Invar::RakeTasks::StateTask.new(args[:namespace], task).show_paths
46
- end
47
- end
@@ -1,178 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'invar'
4
-
5
- module Invar
6
- # Rake task implementation.
7
- #
8
- # The actual rake tasks themselves are thinly defined in invar/rake.rb (so that the external include
9
- # path is nice and short)
10
- module RakeTasks
11
- # Template config YAML file
12
- CONFIG_TEMPLATE = SECRETS_TEMPLATE = <<~YML
13
- ---
14
- YML
15
-
16
- # Tasks that use a namespace
17
- class NamespacedTask
18
- def initialize(namespace, task)
19
- if namespace.nil?
20
- raise NamespaceMissingError,
21
- "Namespace argument required. Run with: bundle exec rake #{ task.name }[namespace_here]"
22
- end
23
-
24
- @namespace = namespace
25
- @locator = FileLocator.new(@namespace)
26
- end
27
- end
28
-
29
- # Configuration file actions.
30
- class ConfigTask < NamespacedTask
31
- # Creates a config file in the appropriate location
32
- def create
33
- config_dir = @locator.search_paths.first
34
- config_dir.mkpath
35
-
36
- file = config_dir / 'config.yml'
37
- if file.exist?
38
- warn <<~MSG
39
- Abort: File exists. (#{ file })
40
- Maybe you meant to edit the file with bundle exec rake invar:secrets:edit[#{ @namespace }]?
41
- MSG
42
- exit 1
43
- end
44
-
45
- file.write CONFIG_TEMPLATE
46
-
47
- warn "Created file: #{ file }"
48
- end
49
-
50
- # Edits the existing config file in the appropriate location
51
- def edit
52
- configs_file = begin
53
- @locator.find('config.yml')
54
- rescue ::Invar::FileLocator::FileNotFoundError => e
55
- warn <<~ERR
56
- Abort: #{ e.message }. Searched in: #{ @locator.search_paths.join(', ') }
57
- Maybe you used the wrong namespace or need to create the file with bundle exec rake invar:configs:create?
58
- ERR
59
- exit 1
60
- end
61
-
62
- system(ENV.fetch('EDITOR', 'editor'), configs_file.to_s, exception: true)
63
-
64
- warn "File saved to: #{ configs_file }"
65
- end
66
- end
67
-
68
- # Secrets file actions.
69
- class SecretTask < NamespacedTask
70
- # Instructions hint for how to handle secret keys.
71
- SECRETS_INSTRUCTIONS = <<~INST
72
- Save this key to a secure password manager. You will need it to edit the secrets.yml file.
73
- INST
74
-
75
- # Creates a new encrypted secrets file and prints the generated encryption key to STDOUT
76
- def create
77
- config_dir = ::Invar::FileLocator.new(@namespace).search_paths.first
78
- config_dir.mkpath
79
-
80
- file = config_dir / 'secrets.yml'
81
-
82
- if file.exist?
83
- warn <<~ERR
84
- Abort: File exists. (#{ file })
85
- Maybe you meant to edit the file with bundle exec rake invar:secrets:edit[#{ @namespace }]?
86
- ERR
87
- exit 1
88
- end
89
-
90
- encryption_key = Lockbox.generate_key
91
-
92
- write_encrypted_file(file, encryption_key, SECRETS_TEMPLATE)
93
-
94
- warn "Created file #{ file }"
95
-
96
- warn SECRETS_INSTRUCTIONS
97
- warn 'Generated key is:'
98
- puts encryption_key
99
- end
100
-
101
- # Opens an editor for the decrypted contents of the secrets file. After closing the editor, the file will be
102
- # updated with the new encrypted contents.
103
- def edit
104
- secrets_file = begin
105
- @locator.find('secrets.yml')
106
- rescue ::Invar::FileLocator::FileNotFoundError => e
107
- warn <<~ERR
108
- Abort: #{ e.message }. Searched in: #{ @locator.search_paths.join(', ') }
109
- Maybe you used the wrong namespace or need to create the file with bundle exec rake invar:secrets:create?
110
- ERR
111
- exit 1
112
- end
113
-
114
- edit_encrypted_file(secrets_file)
115
-
116
- warn "File saved to #{ secrets_file }"
117
- end
118
-
119
- private
120
-
121
- def write_encrypted_file(file_path, encryption_key, content)
122
- lockbox = Lockbox.new(key: encryption_key)
123
-
124
- encrypted_data = lockbox.encrypt(content)
125
-
126
- # TODO: replace File.opens with photo_path.binwrite(uri.data) once FakeFS can handle it
127
- File.open(file_path.to_s, 'wb') { |f| f.write encrypted_data }
128
- end
129
-
130
- def edit_encrypted_file(file_path)
131
- encryption_key = determine_key(file_path)
132
-
133
- lockbox = build_lockbox(encryption_key)
134
-
135
- file_str = Tempfile.create(file_path.basename.to_s) do |tmp_file|
136
- decrypted = lockbox.decrypt(file_path.binread)
137
-
138
- tmp_file.write(decrypted)
139
- tmp_file.rewind # rewind needed because file does not get closed after write
140
- system(ENV.fetch('EDITOR', 'editor'), tmp_file.path, exception: true)
141
- tmp_file.read
142
- end
143
-
144
- write_encrypted_file(file_path, encryption_key, file_str)
145
- end
146
-
147
- def determine_key(file_path)
148
- encryption_key = Lockbox.master_key
149
-
150
- if encryption_key.nil? && $stdin.respond_to?(:noecho)
151
- warn "Enter master key to decrypt #{ file_path }:"
152
- encryption_key = $stdin.noecho(&:gets).strip
153
- end
154
-
155
- encryption_key
156
- end
157
-
158
- def build_lockbox(encryption_key)
159
- Lockbox.new(key: encryption_key)
160
- rescue ArgumentError => e
161
- raise SecretsFileEncryptionError, e
162
- end
163
- end
164
-
165
- # General status tasks
166
- class StateTask < NamespacedTask
167
- # Prints the current paths to be searched in
168
- def show_paths
169
- warn @locator.search_paths.join("\n")
170
- end
171
- end
172
-
173
- # Raised when the namespace Rake task parameter is missing. Add it in square brackets after the task name when
174
- # running Rake.
175
- class NamespaceMissingError < RuntimeError
176
- end
177
- end
178
- end