diff --git a/CHANGELOG.md b/CHANGELOG.md index 213759ee..0bfaed5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,16 @@ ### BREAKING CHANGES After upgrade behaviour of `to_h` would change and match behaviour of `to_hash`. Check [#217](https://github.com/rubyconfig/config/issues/217#issuecomment-741953382) for more details. +`Config::Options#load_env!` and `Config::Options#reload_env!` have been removed. If you need to reload settings after modifying the `ENV` hash, use `Config.reload!` or `Config::Options#reload!` instead. ### Bug fixes * Added alias `to_h` for `to_hash` ([#277](https://github.com/railsconfig/config/issues/277)) +### Changes + +* Add `Config::Sources::EnvSource` for loading settings from flat `Hash`es with `String` keys and `String` values ([#299](https://github.com/railsconfig/config/pull/299)) + ## 2.2.3 ### Bug fixes diff --git a/README.md b/README.md index e2180809..5cd7fec0 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,50 @@ Settings.section.server # => 'google.com' Settings.section.ssl_enabled # => false ``` +### Working with AWS Secrets Manager + +It is possible to parse variables stored in an AWS Secrets Manager Secret as if they were environment variables by using `Config::Sources::EnvSource`. + +For example, the plaintext secret might look like this: + +```json +{ + "Settings.foo": "hello", + "Settings.bar": "world", +} +``` + +In order to load those settings, fetch the settings from AWS Secrets Manager, parse the plaintext as JSON, pass the resulting `Hash` into a new `EnvSource`, load the new source, and reload. + +```ruby +# fetch secrets from AWS +client = Aws::SecretsManager::Client.new +response = client.get_secret_value(secret_id: "#{ENV['ENVIRONMENT']}/my_application") +secrets = JSON.parse(response.secret_string) + +# load secrets into config +secret_source = Config::Sources::EnvSource.new(secrets) +Settings.add_source!(secret_source) +Settings.reload! +``` + +In this case, the following settings will be available: + +```ruby +Settings.foo # => "hello" +Settings.bar # => "world" +``` + +By default, `EnvSource` will use configuration for `env_prefix`, `env_separator`, `env_converter`, and `env_parse_values`, but any of these can be overridden in the constructor. + +```ruby +secret_source = Config::Sources::EnvSource.new(secrets, + prefix: 'MyConfig', + separator: '__', + converter: nil, + parse_values: false) +``` + ## Contributing You are very warmly welcome to help. Please follow our [contribution guidelines](CONTRIBUTING.md) diff --git a/lib/config.rb b/lib/config.rb index 94c40c2d..8b0d5ed7 100644 --- a/lib/config.rb +++ b/lib/config.rb @@ -4,6 +4,7 @@ require 'config/version' require 'config/sources/yaml_source' require 'config/sources/hash_source' +require 'config/sources/env_source' require 'config/validation/schema' require 'deep_merge' @@ -41,6 +42,8 @@ def self.load_files(*files) config.add_source!(file.to_s) end + config.add_source!(Sources::EnvSource.new(ENV)) if Config.use_env + config.load! config end diff --git a/lib/config/options.rb b/lib/config/options.rb index 92faac99..40e49f6a 100644 --- a/lib/config/options.rb +++ b/lib/config/options.rb @@ -31,47 +31,6 @@ def prepend_source!(source) @config_sources.unshift(source) end - def reload_env! - return self if ENV.nil? || ENV.empty? - - hash = Hash.new - - ENV.each do |variable, value| - separator = Config.env_separator - prefix = (Config.env_prefix || Config.const_name).to_s.split(separator) - - keys = variable.to_s.split(separator) - - next if keys.shift(prefix.size) != prefix - - keys.map! { |key| - case Config.env_converter - when :downcase then - key.downcase.to_sym - when nil then - key.to_sym - else - raise "Invalid ENV variables name converter: #{Config.env_converter}" - end - } - - leaf = keys[0...-1].inject(hash) { |h, key| - h[key] ||= {} - } - - unless leaf.is_a?(Hash) - conflicting_key = (prefix + keys[0...-1]).join(separator) - raise "Environment variable #{variable} conflicts with variable #{conflicting_key}" - end - - leaf[keys.last] = Config.env_parse_values ? __value(value) : value - end - - merge!(hash) - end - - alias :load_env! :reload_env! - # look through all our sources and rebuild the configuration def reload! conf = {} @@ -96,7 +55,6 @@ def reload! # swap out the contents of the OStruct with a hash (need to recursively convert) marshal_load(__convert(conf).marshal_dump) - reload_env! if Config.use_env validate! self @@ -223,17 +181,5 @@ def __convert(h) #:nodoc: end s end - - # Try to convert string to a correct type - def __value(v) - case v - when 'false' - false - when 'true' - true - else - Integer(v) rescue Float(v) rescue v - end - end end end diff --git a/lib/config/sources/env_source.rb b/lib/config/sources/env_source.rb new file mode 100644 index 00000000..df59e35e --- /dev/null +++ b/lib/config/sources/env_source.rb @@ -0,0 +1,73 @@ +module Config + module Sources + # Allows settings to be loaded from a "flat" hash with string keys, like ENV. + class EnvSource + attr_reader :prefix + attr_reader :separator + attr_reader :converter + attr_reader :parse_values + + def initialize(env, + prefix: Config.env_prefix || Config.const_name, + separator: Config.env_separator, + converter: Config.env_converter, + parse_values: Config.env_parse_values) + @env = env + @prefix = prefix.to_s.split(separator) + @separator = separator + @converter = converter + @parse_values = parse_values + end + + def load + return {} if @env.nil? || @env.empty? + + hash = Hash.new + + @env.each do |variable, value| + keys = variable.to_s.split(separator) + + next if keys.shift(prefix.size) != prefix + + keys.map! { |key| + case converter + when :downcase then + key.downcase + when nil then + key + else + raise "Invalid ENV variables name converter: #{converter}" + end + } + + leaf = keys[0...-1].inject(hash) { |h, key| + h[key] ||= {} + } + + unless leaf.is_a?(Hash) + conflicting_key = (prefix + keys[0...-1]).join(separator) + raise "Environment variable #{variable} conflicts with variable #{conflicting_key}" + end + + leaf[keys.last] = parse_values ? __value(value) : value + end + + hash + end + + private + + # Try to convert string to a correct type + def __value(v) + case v + when 'false' + false + when 'true' + true + else + Integer(v) rescue Float(v) rescue v + end + end + end + end +end diff --git a/spec/sources/env_source_spec.rb b/spec/sources/env_source_spec.rb new file mode 100644 index 00000000..ad86f9a7 --- /dev/null +++ b/spec/sources/env_source_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +module Config::Sources + describe EnvSource do + context 'configuration options' do + before :each do + Config.reset + Config.env_prefix = nil + Config.env_separator = '.' + Config.env_converter = :downcase + Config.env_parse_values = true + end + + context 'default configuration' do + it 'should use global prefix configuration by default' do + Config.env_prefix = 'MY_CONFIG' + + source = EnvSource.new({ 'MY_CONFIG.ACTION_MAILER' => 'enabled' }) + results = source.load + expect(results['action_mailer']).to eq('enabled') + end + + it 'should use global separator configuration by default' do + Config.env_separator = '__' + + source = EnvSource.new({ 'Settings__ACTION_MAILER__ENABLED' => 'yes' }) + results = source.load + expect(results['action_mailer']['enabled']).to eq('yes') + end + + it 'should use global converter configuration by default' do + Config.env_converter = nil + + source = EnvSource.new({ 'Settings.ActionMailer.Enabled' => 'yes' }) + results = source.load + expect(results['ActionMailer']['Enabled']).to eq('yes') + end + + it 'should use global parse_values configuration by default' do + Config.env_parse_values = false + + source = EnvSource.new({ 'Settings.ACTION_MAILER.ENABLED' => 'true' }) + results = source.load + expect(results['action_mailer']['enabled']).to eq('true') + end + end + + context 'configuration overrides' do + it 'should allow overriding prefix configuration' do + source = EnvSource.new({ 'MY_CONFIG.ACTION_MAILER' => 'enabled' }, + prefix: 'MY_CONFIG') + results = source.load + expect(results['action_mailer']).to eq('enabled') + end + + it 'should allow overriding separator configuration' do + source = EnvSource.new({ 'Settings__ACTION_MAILER__ENABLED' => 'yes' }, + separator: '__') + results = source.load + expect(results['action_mailer']['enabled']).to eq('yes') + end + + it 'should allow overriding converter configuration' do + source = EnvSource.new({ 'Settings.ActionMailer.Enabled' => 'yes' }, + converter: nil) + results = source.load + expect(results['ActionMailer']['Enabled']).to eq('yes') + end + + it 'should allow overriding parse_values configuration' do + source = EnvSource.new({ 'Settings.ACTION_MAILER.ENABLED' => 'true' }, + parse_values: false) + results = source.load + expect(results['action_mailer']['enabled']).to eq('true') + end + end + end + end +end