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 +4 -4
- data/README.md +61 -1
- data/RELEASE_NOTES.md +8 -2
- data/lib/invar/rake/tasks.rb +254 -0
- data/lib/invar/reality.rb +290 -0
- data/lib/invar/scope.rb +3 -0
- data/lib/invar/version.rb +1 -1
- data/lib/invar.rb +7 -254
- metadata +4 -4
- data/lib/invar/rake.rb +0 -47
- data/lib/invar/rake_tasks.rb +0 -178
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6e3d07cd5d6d704c0bec06fe0803e0a5fd553f69e54f74501ddd7e9639353172
|
4
|
+
data.tar.gz: 3ecb948cb3b856d9f15d0d1d985f5e59db62532a724e8c4c748287532d1efac2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
*
|
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
|
-
*
|
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
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/
|
5
|
-
require 'invar/scope'
|
4
|
+
require 'invar/reality'
|
6
5
|
|
7
|
-
|
8
|
-
|
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::
|
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::
|
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
|
+
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-
|
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/
|
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
|
data/lib/invar/rake_tasks.rb
DELETED
@@ -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
|