global 1.1.0 → 2.0.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.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'global'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
@@ -9,10 +9,8 @@ Gem::Specification.new do |s|
9
9
  s.authors = ['Railsware LLC']
10
10
  s.email = 'contact@railsware.com'
11
11
 
12
- s.rubyforge_project = 'global'
13
-
14
- s.description = 'Simple way to load your configs from yaml'
15
- s.summary = 'Simple way to load your configs from yaml'
12
+ s.description = 'Simple way to load your configs from yaml/aws/gcp'
13
+ s.summary = 'Simple way to load your configs from yaml/aws/gcp'
16
14
 
17
15
  s.files = `git ls-files`.split("\n")
18
16
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
@@ -22,15 +20,12 @@ Gem::Specification.new do |s|
22
20
  s.homepage = 'https://github.com/railsware/global'
23
21
  s.licenses = ['MIT']
24
22
 
23
+ s.add_development_dependency 'aws-sdk-ssm', '~> 1'
24
+ s.add_development_dependency 'google-cloud-secret_manager', '~> 0'
25
25
  s.add_development_dependency 'rake', '~> 12.3.1'
26
26
  s.add_development_dependency 'rspec', '>= 3.0'
27
- s.add_development_dependency 'rubocop', '~> 0.57'
27
+ s.add_development_dependency 'rubocop', '~> 0.81.0'
28
28
  s.add_development_dependency 'simplecov', '~> 0.16.1'
29
- if defined?(JRUBY_VERSION)
30
- s.add_development_dependency 'therubyrhino', '>= 0'
31
- else
32
- s.add_development_dependency 'therubyracer', '>= 0'
33
- end
34
29
 
35
30
  s.add_runtime_dependency 'activesupport', '>= 2.0'
36
31
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
3
4
  require 'yaml'
4
5
 
5
6
  require 'active_support/core_ext/hash/indifferent_access'
@@ -7,7 +8,6 @@ require 'active_support/core_ext/hash/deep_merge'
7
8
 
8
9
  require 'global/configuration'
9
10
  require 'global/base'
10
- require 'global/engine' if defined?(Rails)
11
11
  require 'global/version'
12
12
 
13
13
  module Global
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Global
4
+ module Backend
5
+ # Loads Global configuration from the AWS Systems Manager Parameter Store
6
+ # https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html
7
+ #
8
+ # This backend requires the `aws-sdk` or `aws-sdk-ssm` gem, so make sure to add it to your Gemfile.
9
+ #
10
+ # Available options:
11
+ # - `prefix` (required): the prefix in Parameter Store; all parameters within the prefix will be loaded;
12
+ # make sure to add a trailing slash, if you want it
13
+ # see https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-su-organize.html
14
+ # - `client`: pass you own Aws::SSM::Client instance, or alternatively set:
15
+ # - `aws_options`: credentials and other AWS configuration options that are passed to AWS::SSM::Client.new
16
+ # see https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SSM/Client.html#initialize-instance_method
17
+ # If AWS access is already configured through environment variables,
18
+ # you don't need to pass the credentials explicitly.
19
+ #
20
+ # For Rails:
21
+ # - the `prefix` is optional and defaults to `/[Rails enviroment]/[Name of the app class]/`,
22
+ # for example: `/production/MyApp/`
23
+ # - to use a different app name, pass `app_name`,
24
+ # for example: `backend :aws_parameter_store, app_name: 'new_name_for_my_app'`
25
+ class AwsParameterStore
26
+
27
+ PATH_SEPARATOR = '/'
28
+
29
+ def initialize(options = {})
30
+ require_aws_gem
31
+ init_prefix(options)
32
+ init_client(options)
33
+ end
34
+
35
+ def load
36
+ build_configuration_from_parameters(load_all_parameters_from_ssm)
37
+ end
38
+
39
+ private
40
+
41
+ def require_aws_gem
42
+ require 'aws-sdk-ssm'
43
+ rescue LoadError
44
+ begin
45
+ require 'aws-sdk'
46
+ rescue LoadError
47
+ raise 'Either the `aws-sdk-ssm` or `aws-sdk` gem must be installed.'
48
+ end
49
+ end
50
+
51
+ def init_prefix(options)
52
+ @prefix = if defined?(Rails)
53
+ options.fetch(:prefix) do
54
+ environment = Rails.env.to_s
55
+ app_name = options.fetch(:app_name) { Rails.application.class.module_parent_name }
56
+ "/#{environment}/#{app_name}/"
57
+ end
58
+ else
59
+ options.fetch(:prefix)
60
+ end
61
+ end
62
+
63
+ def init_client(options)
64
+ if options.key?(:client)
65
+ @ssm = options[:client]
66
+ else
67
+ aws_options = options.fetch(:aws_options, {})
68
+ @ssm = Aws::SSM::Client.new(aws_options)
69
+ end
70
+ end
71
+
72
+ def load_all_parameters_from_ssm
73
+ response = load_parameters_from_ssm
74
+ all_parameters = response.parameters
75
+ loop do
76
+ break unless response.next_token
77
+
78
+ response = load_parameters_from_ssm(response.next_token)
79
+ all_parameters.concat(response.parameters)
80
+ end
81
+
82
+ all_parameters
83
+ end
84
+
85
+ def load_parameters_from_ssm(next_token = nil)
86
+ @ssm.get_parameters_by_path(
87
+ path: @prefix,
88
+ recursive: true,
89
+ with_decryption: true,
90
+ next_token: next_token
91
+ )
92
+ end
93
+
94
+ # builds a nested configuration hash from the array of parameters from SSM
95
+ def build_configuration_from_parameters(parameters)
96
+ configuration = {}
97
+ parameters.each do |parameter|
98
+ parameter_parts = parameter.name[@prefix.length..-1].split(PATH_SEPARATOR).map(&:to_sym)
99
+ param_container = parameter_parts[0..-2].reduce(configuration) do |container, part|
100
+ container[part] ||= {}
101
+ end
102
+ param_container[parameter_parts[-1]] = parameter.value
103
+ end
104
+
105
+ configuration
106
+ end
107
+
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Global
4
+ module Backend
5
+ # Loads Global configuration from the filesystem
6
+ #
7
+ # Available options:
8
+ # - `directory` (required): the directory with config files
9
+ # - `environment` (required): the environment to load
10
+ # - `yaml_whitelist_classes`: the set of classes that are permitted to unmarshal from the configuration files
11
+ #
12
+ # For Rails:
13
+ # - the `directory` is optional and defaults to `config/global`
14
+ # - the `environment` is optional and defaults to the current Rails environment
15
+ class Filesystem
16
+
17
+ FILE_ENV_SPLIT = '.'
18
+ YAML_EXT = '.yml'
19
+
20
+ def initialize(options = {})
21
+ if defined?(Rails)
22
+ @path = options.fetch(:path) { Rails.root.join('config', 'global').to_s }
23
+ @environment = options.fetch(:environment) { Rails.env.to_s }
24
+ else
25
+ @path = options.fetch(:path)
26
+ @environment = options.fetch(:environment)
27
+ end
28
+ @yaml_whitelist_classes = options.fetch(:yaml_whitelist_classes, [])
29
+ end
30
+
31
+ def load
32
+ load_from_path(@path)
33
+ end
34
+
35
+ private
36
+
37
+ def load_from_path(path)
38
+ load_from_file(path).deep_merge(load_from_directory(path))
39
+ end
40
+
41
+ def load_from_file(path)
42
+ config = {}
43
+
44
+ if File.exist?(file = "#{path}#{YAML_EXT}")
45
+ configurations = load_yml_file(file)
46
+ config = get_config_by_key(configurations, 'default')
47
+ config.deep_merge!(get_config_by_key(configurations, @environment))
48
+ if File.exist?(env_file = "#{path}#{FILE_ENV_SPLIT}#{@environment}#{YAML_EXT}")
49
+ config.deep_merge!(load_yml_file(env_file) || {})
50
+ end
51
+ end
52
+
53
+ config
54
+ end
55
+
56
+ def get_config_by_key(config, key)
57
+ return {} if config.empty?
58
+
59
+ config[key.to_sym] || config[key.to_s] || {}
60
+ end
61
+
62
+ def load_yml_file(file)
63
+ YAML.safe_load(
64
+ ERB.new(IO.read(file)).result,
65
+ [Date, Time, DateTime, Symbol].concat(@yaml_whitelist_classes),
66
+ [], true
67
+ )
68
+ end
69
+
70
+ def load_from_directory(path)
71
+ config = {}
72
+
73
+ if File.directory?(path)
74
+ Dir["#{path}/*"].each do |entry|
75
+ namespace = File.basename(entry, YAML_EXT)
76
+ next if namespace.include? FILE_ENV_SPLIT # skip files with dot(s) in name
77
+
78
+ file_with_path = File.join(File.dirname(entry), File.basename(entry, YAML_EXT))
79
+ config.deep_merge!(namespace => load_from_path(file_with_path))
80
+ end
81
+ end
82
+
83
+ config
84
+ end
85
+
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Global
4
+ module Backend
5
+ # Loads Global configuration from the Google Cloud Secret Manager
6
+ # https://cloud.google.com/secret-manager/docs
7
+ #
8
+ # This backend requires the `google-cloud-secret_manager` gem, so make sure to add it to your Gemfile.
9
+ #
10
+ # Available options:
11
+ # - `project_id` (required): Google Cloud project name
12
+ # - `prefix` (required): the prefix in Secret Manager; all parameters within the prefix will be loaded;
13
+ # make sure to add a undescore, if you want it
14
+ # see https://cloud.google.com/secret-manager/docs/overview
15
+ # - `client`: pass you own Google::Cloud::SecretManager instance, or alternatively set:
16
+ # - `gcp_options`: credentials and other Google cloud configuration options
17
+ # that are passed to Google::Cloud::SecretManager.configure
18
+ # see https://googleapis.dev/ruby/google-cloud-secret_manager/latest/index.html
19
+ # If Google Cloud access is already configured through environment variables,
20
+ # you don't need to pass the credentials explicitly.
21
+ #
22
+ # For Rails:
23
+ # - the `prefix` is optional and defaults to `[Rails enviroment]-[Name of the app class]-`,
24
+ # for example: `production-myapp-`
25
+ # - to use a different app name, pass `app_name`,
26
+ # for example: `backend :gcp_secret_manager, app_name: 'new_name_for_my_app'`
27
+ class GcpSecretManager
28
+
29
+ GCP_SEPARATOR = '/'
30
+ PATH_SEPARATOR = '-'
31
+
32
+ def initialize(options = {})
33
+ @project_id = options.fetch(:project_id)
34
+ require_gcp_gem
35
+ init_prefix(options)
36
+ init_client(options)
37
+ end
38
+
39
+ def load
40
+ pages = load_all_parameters_from_gcsm
41
+
42
+ configuration = {}
43
+ pages.each do |page|
44
+ configuration.deep_merge!(build_configuration_from_page(page))
45
+ end
46
+
47
+ configuration
48
+ end
49
+
50
+ private
51
+
52
+ def require_gcp_gem
53
+ require 'google/cloud/secret_manager'
54
+ rescue LoadError
55
+ raise 'The `google-cloud-secret_manager` gem must be installed.'
56
+ end
57
+
58
+ def init_prefix(options)
59
+ @prefix = if defined?(Rails)
60
+ options.fetch(:prefix) do
61
+ environment = Rails.env.to_s
62
+ app_name = options.fetch(:app_name) { Rails.application.class.module_parent_name }
63
+ "#{environment}-#{app_name}-"
64
+ end
65
+ else
66
+ options.fetch(:prefix)
67
+ end
68
+ end
69
+
70
+ def init_client(options)
71
+ if options.key?(:client)
72
+ @gcsm = options[:client]
73
+ else
74
+ gcp_options = options.fetch(:gcp_options, {})
75
+ @gcsm = Google::Cloud::SecretManager.secret_manager_service do |config|
76
+ config.credentials = gcp_options[:credentials] if gcp_options[:credentials]
77
+ config.timeout = gcp_options[:timeout] if gcp_options[:timeout]
78
+ end
79
+ end
80
+ end
81
+
82
+ def load_all_parameters_from_gcsm
83
+ response = load_parameters_from_gcsm
84
+ all_pages = [response]
85
+ loop do
86
+ break if response.next_page_token.empty?
87
+
88
+ response = load_parameters_from_gcsm(response.next_page_token)
89
+ all_pages << response
90
+ end
91
+
92
+ all_pages
93
+ end
94
+
95
+ def load_parameters_from_gcsm(next_token = nil)
96
+ @gcsm.list_secrets(
97
+ parent: @gcsm.project_path(project: @project_id),
98
+ page_size: 25_000,
99
+ page_token: next_token
100
+ )
101
+ end
102
+
103
+ # builds a nested configuration hash from the array of parameters from Secret Manager
104
+ def build_configuration_from_page(page)
105
+ configuration = {}
106
+
107
+ page.each do |parameter|
108
+ key_name = get_gcp_key_name(parameter)
109
+ next unless key_name.start_with?(@prefix)
110
+
111
+ parameter_parts = key_name[@prefix.length..-1].split(PATH_SEPARATOR).map(&:to_sym)
112
+ param_container = parameter_parts[0..-2].reduce(configuration) do |container, part|
113
+ container[part] ||= {}
114
+ end
115
+ param_container[parameter_parts[-1]] = get_latest_key_value(key_name)
116
+ end
117
+
118
+ configuration
119
+ end
120
+
121
+ def get_gcp_key_name(parameter)
122
+ parameter.name.split(GCP_SEPARATOR).last
123
+ end
124
+
125
+ def get_latest_key_value(key_name)
126
+ name = @gcsm.secret_version_path(
127
+ project: @project_id,
128
+ secret: key_name,
129
+ secret_version: 'latest'
130
+ )
131
+ version = @gcsm.access_secret_version(name: name)
132
+ version.payload.data
133
+ end
134
+
135
+ end
136
+ end
137
+ end
@@ -6,19 +6,21 @@ require 'json'
6
6
  module Global
7
7
  module Base
8
8
 
9
- FILE_ENV_SPLIT = '.'
10
- YAML_EXT = '.yml'
11
-
12
9
  extend self
13
10
 
14
- attr_writer :environment, :config_directory, :namespace, :except, :only, :yaml_whitelist_classes
15
-
16
11
  def configure
17
12
  yield self
18
13
  end
19
14
 
20
15
  def configuration
21
- @configuration ||= load_configuration(config_directory, environment)
16
+ raise 'Backend must be defined' unless @backends
17
+
18
+ @configuration ||= begin
19
+ configuration_hash = @backends.reduce({}) do |configuration, backend|
20
+ configuration.deep_merge(backend.load.with_indifferent_access)
21
+ end
22
+ Configuration.new(configuration_hash)
23
+ end
22
24
  end
23
25
 
24
26
  def reload!
@@ -26,93 +28,45 @@ module Global
26
28
  configuration
27
29
  end
28
30
 
29
- def environment
30
- @environment || raise('environment should be defined')
31
- end
32
-
33
- def config_directory
34
- @config_directory || raise('config_directory should be defined')
35
- end
36
-
37
- def namespace
38
- @namespace ||= 'Global'
39
- end
40
-
41
- def except
42
- @except ||= :all
43
- end
44
-
45
- def only
46
- @only ||= []
47
- end
48
-
49
- def yaml_whitelist_classes
50
- @yaml_whitelist_classes ||= []
51
- end
52
-
53
- def generate_js(options = {})
54
- current_namespace = options[:namespace] || namespace
55
-
56
- js_options = { except: except, only: only }.merge(options)
57
- "window.#{current_namespace} = #{configuration.filter(js_options).to_json}"
58
- end
59
-
60
- protected
61
-
62
- def load_configuration(dir, env)
63
- config = load_from_file(dir, env)
64
- config.deep_merge!(load_from_directory(dir, env))
65
- Configuration.new(config)
66
- end
67
-
68
- def load_from_file(dir, env)
69
- config = {}
70
-
71
- if File.exist?(file = "#{dir}#{YAML_EXT}")
72
- configurations = load_yml_file(file)
73
- config = get_config_by_key(configurations, 'default')
74
- config.deep_merge!(get_config_by_key(configurations, env))
75
- if File.exist?(env_file = "#{dir}#{FILE_ENV_SPLIT}#{env}#{YAML_EXT}")
76
- config.deep_merge!(load_yml_file(env_file) || {})
77
- end
31
+ # Add a backend to load configuration from.
32
+ #
33
+ # You can define several backends; they will all be loaded
34
+ # and the configuration hashes will be merged.
35
+ #
36
+ # Configure with either:
37
+ # Global.backend :filesystem, directory: 'config', environment: Rails.env
38
+ # or:
39
+ # Global.backend YourConfigurationBackend.new
40
+ #
41
+ # backend configuration classes MUST have a `load` method that returns a configuration Hash
42
+ def backend(backend, options = {})
43
+ @backends ||= []
44
+ if backend.is_a?(Symbol)
45
+ require "global/backend/#{backend}"
46
+ backend_class = Global::Backend.const_get(camel_case(backend.to_s))
47
+ @backends.push backend_class.new(options)
48
+ elsif backend.respond_to?(:load)
49
+ @backends.push backend
50
+ else
51
+ raise 'Backend must be either a Global::Backend class or a symbol'
78
52
  end
79
-
80
- config
81
- end
82
-
83
- def get_config_by_key(config, key)
84
- config[key.to_sym] || config[key.to_s] || {}
85
- end
86
-
87
- def load_yml_file(file)
88
- YAML.safe_load(
89
- ERB.new(IO.read(file)).result,
90
- [Date, Time, DateTime, Symbol].concat(yaml_whitelist_classes),
91
- [], true
92
- )
93
53
  end
94
54
 
95
- def load_from_directory(dir, env)
96
- config = {}
97
-
98
- if File.directory?(dir)
99
- Dir["#{dir}/*"].each do |entry|
100
- namespace = File.basename(entry, YAML_EXT)
101
- next if namespace.include? FILE_ENV_SPLIT # skip files with dot(s) in name
102
- file_with_path = File.join(File.dirname(entry), File.basename(entry, YAML_EXT))
103
- config.deep_merge!(namespace => load_configuration(file_with_path, env))
104
- end
105
- end
106
-
107
- config
108
- end
55
+ protected
109
56
 
110
57
  def respond_to_missing?(method, include_private = false)
111
58
  configuration.key?(method) || super
112
59
  end
113
60
 
114
61
  def method_missing(method, *args, &block)
115
- configuration.key?(method) ? configuration[method] : super
62
+ configuration.key?(method) ? configuration.get_configuration_value(method) : super
63
+ end
64
+
65
+ # from Bundler::Thor::Util.camel_case
66
+ def camel_case(str)
67
+ return str if str !~ /_/ && str =~ /[A-Z]+.*/
68
+
69
+ str.split('_').map(&:capitalize).join
116
70
  end
117
71
 
118
72
  end