invar 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/invar/rake.rb ADDED
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,178 @@
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
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invar
4
+ # A set of configurations
5
+ class Scope
6
+ def initialize(data = nil)
7
+ @data = convert(data)
8
+
9
+ @data.freeze
10
+ @data_override = {}
11
+ freeze
12
+ end
13
+
14
+ # Retrieve the value for the given key
15
+ #
16
+ # @param [symbol] key
17
+ # @raise KeyError if no such key exists.
18
+ # @see #override
19
+ def fetch(key)
20
+ key = key.downcase.to_sym
21
+ @data_override.fetch(key, @data.fetch(key))
22
+ rescue KeyError => e
23
+ raise KeyError, "#{ e.message }. Known keys are #{ @data.keys.sort.collect { |k| ":#{ k }" }.join(', ') }"
24
+ end
25
+
26
+ alias / fetch
27
+ alias [] fetch
28
+
29
+ def pretend(**)
30
+ raise ::Invar::ImmutableRealityError, ::Invar::ImmutableRealityError::MSG
31
+ end
32
+
33
+ def to_h
34
+ @data.merge(@data_override).to_h.transform_values do |value|
35
+ case value
36
+ when Scope
37
+ value.to_h
38
+ else
39
+ value
40
+ end
41
+ end
42
+ end
43
+
44
+ def key?(key_name)
45
+ to_h.key?(key_name)
46
+ end
47
+
48
+ private
49
+
50
+ def convert(data)
51
+ (data || {}).dup.each_with_object({}) do |pair, agg|
52
+ key, value = pair
53
+
54
+ agg[key] = value.is_a?(Hash) ? Scope.new(value) : value
55
+ end
56
+ end
57
+ end
58
+ end
data/lib/invar/test.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'invar'
4
+
5
+ module Invar
6
+ # Extension to the standard class
7
+ class Scope
8
+ # Overrides the given set of key-value pairs. This is intended to only be used in testing environments,
9
+ # where you may need contextual adjustments to suit the test situation.
10
+ #
11
+ # @param [Hash] pairs the hash of pairs to override.
12
+ def pretend(pairs)
13
+ @data_override.merge!(pairs.transform_keys(&:to_sym))
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invar
4
+ # Current version of the gem
5
+ VERSION = '0.4.0'
6
+ end
data/lib/invar.rb ADDED
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'invar/version'
4
+ require 'invar/file_locator'
5
+ require 'invar/scope'
6
+
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.
13
+ module Invar
14
+ # Alias for Invar::Invar.new
15
+ #
16
+ # @see Invar.new
17
+ 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
214
+ end
215
+
216
+ class << self
217
+ # Block that will be run after loading from config files and prior to freezing. It is intended to allow
218
+ # for test suites to tweak configurations without having to duplicate the entire config file.
219
+ #
220
+ # @yieldparam the configs from the Invar
221
+ # @return [void]
222
+ def after_load(&block)
223
+ ::Invar::Invar.__override_block__ = block
224
+ end
225
+ 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
+ end