invar 0.4.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.
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