invar 0.4.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|