global 1.1.0 → 2.0.0

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