process_settings 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 4da1b4678116cf897726de9273822dadff5ffaf0
4
+ data.tar.gz: 6679ca9c2cfdf4f6022b5bb4121b09fd1956f640
5
+ SHA512:
6
+ metadata.gz: bb07fcbffc25721635643c43f86b95bb7207e95cb058ddb046dde708961ebdf037536b99d1b2748b5df5b1f730c715cb2f91a7e511ac38596b6f2c7667825ef2
7
+ data.tar.gz: a7a28a9c6be634475492386f89730cf79ff00fbf6f403075315fa8717941da6edc18b2227b4b150b3bb57d0518bc048d9d53d7a6b1c00d3f021efbb0674a66e1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 Invoca Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # ProcessSettings [![Build Status](https://travis-ci.org/Invoca/process_settings.svg?branch=master)](https://travis-ci.org/Invoca/process_settings) [![Coverage Status](https://coveralls.io/repos/github/Invoca/process_settings/badge.svg?branch=master)](https://coveralls.io/github/Invoca/process_settings?branch=master) [![Gem Version](https://badge.fury.io/rb/process_settings.svg)](https://badge.fury.io/rb/process_settings)
2
+ This gem provides dynamic settings for Linux processes. These settings are stored in JSON.
3
+ They including a targeting notation so that each settings group can be targeted based on matching context values.
4
+ The context can be either static to the process (for example, service_name or data_center) or dynamic (for example, the domain of the current web request).
5
+
6
+ ## Installation
7
+ To install this gem directly on your machine from rubygems, run the following:
8
+ ```ruby
9
+ gem install process_settings
10
+ ```
11
+
12
+ To install this gem in your bundler project, add the following to your Gemfile:
13
+ ```ruby
14
+ gem 'process_settings', '~> 0.3'
15
+ ```
16
+
17
+ To use an unreleased version, add it to your Gemfile for Bundler:
18
+ ```ruby
19
+ gem 'process_settings', git: 'git@github.com:Invoca/process_settings'
20
+ ```
21
+
22
+ ## Usage
23
+ ### Initialization
24
+ To use the contextual logger, all you need to do is initailize the object with your existing logger
25
+ ```ruby
26
+ require 'process_settings'
27
+ ```
28
+
29
+ TODO: Fill in here how to use the Monitor's instance method to get current settings, how to register for on_change callbacks, etc.
30
+
31
+ ### Dynamic Settings
32
+
33
+ In order to load changes dynamically, `ProcessSettings` relies on INotify module of the Linux kernel. On kernels that do not have this module (MacOS for example), you will see a warning on STDERR that changes will not be loaded while the process runs.
34
+
35
+
36
+ ## Contributions
37
+
38
+ Contributions to this project are always welcome. Please thoroughly read our [Contribution Guidelines](https://github.com/Invoca/process_settings/blob/master/CONTRIBUTING.md) before starting any work.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'ostruct'
6
+ require 'pathname'
7
+ require 'fileutils'
8
+ require 'yaml'
9
+ require_relative '../lib/process_settings/targeted_settings'
10
+
11
+ PROGRAM_NAME = File.basename($PROGRAM_NAME)
12
+ SETTINGS_FOLDER = 'settings'
13
+
14
+ def end_marker(version)
15
+ {
16
+ 'meta' => {
17
+ 'version' => version,
18
+ 'END' => true
19
+ }
20
+ }
21
+ end
22
+
23
+ # returns a version number one higher than what's in the latest_output_file
24
+ def default_version_number(latest_output_file)
25
+ process_settings = ProcessSettings::TargetedSettings.from_file(latest_output_file, only_meta: true)
26
+ process_settings.version + 1
27
+ end
28
+
29
+
30
+ def parse_options(argv)
31
+ options = OpenStruct.new
32
+ options.verbose = false
33
+ option_parser = OptionParser.new(argv) do |opt|
34
+ opt.on('-v', '--verbose', 'Verbose mode.') { options.verbose = true }
35
+ opt.on('-r', '--root_folder=ROOT') { |o| options.root_folder = o }
36
+ opt.on('-o', '--output=FILENAME', 'Output file.') { |o| options.output_filename = o }
37
+ opt.on('-i', '--initial=FILENAME', 'Initial settings file for version inference.') { |o| options.initial_filename = o }
38
+ end
39
+
40
+ if option_parser.parse! && options.root_folder && options.output_filename && (ENV['BUILD_NUMBER'] || options.initial_filename)
41
+ options
42
+ else
43
+ warn "usage: #{PROGRAM_NAME} -r staging|production -o combined_process_settings.yml [-i initial_combined_process_settings.yml] (required if BUILD_NUMBER not set)"
44
+ option_parser.summarize(STDERR)
45
+ exit(1)
46
+ end
47
+ end
48
+
49
+ def read_and_combine_settings(settings_folder)
50
+ pushd(settings_folder) do
51
+ Dir.glob("**/*.yml").sort.map do |settings_path|
52
+ settings = { 'filename' => settings_path } # start with the filename so it appears at the top of each section
53
+ settings.merge!(YAML.load_file(settings_path))
54
+ settings
55
+ end
56
+ end
57
+ end
58
+
59
+ def pushd(folder)
60
+ pwd = FileUtils.pwd
61
+ FileUtils.cd(folder)
62
+ yield
63
+ ensure
64
+ FileUtils.cd(pwd)
65
+ end
66
+
67
+ def add_warning_comment(yaml, root_folder, program_name, settings_folder)
68
+ warning_comment = <<~EOS
69
+ #
70
+ # Don't edit this file directly! It was generated by #{program_name} from the files in #{root_folder.split('/').last}/#{settings_folder}/.
71
+ #
72
+ EOS
73
+
74
+ yaml.sub("\n", "\n" + warning_comment)
75
+ end
76
+
77
+ #
78
+ # main
79
+ #
80
+
81
+ options = parse_options(ARGV.dup)
82
+
83
+ combined_settings = read_and_combine_settings(Pathname.new(options.root_folder) + SETTINGS_FOLDER)
84
+
85
+ version_number = ENV['BUILD_NUMBER']&.to_i || default_version_number(options.initial_filename)
86
+ combined_settings << end_marker(version_number)
87
+
88
+ yaml = combined_settings.to_yaml
89
+ yaml_with_warning_comment = add_warning_comment(yaml, options.root_folder, PROGRAM_NAME, SETTINGS_FOLDER)
90
+
91
+ output_filename = options.output_filename
92
+ tmp_output_filename = "#{output_filename}.tmp"
93
+
94
+ system("rm -f #{tmp_output_filename}")
95
+ File.write(tmp_output_filename, yaml_with_warning_comment)
96
+
97
+ system(<<~EOS)
98
+ if cmp #{tmp_output_filename} #{output_filename} --silent; then
99
+ #{"echo #{options.root_folder}: unchanged;" if options.verbose}
100
+ rm -f #{tmp_output_filename};
101
+ else
102
+ #{"echo #{options.root_folder}: UPDATING;" if options.verbose}
103
+ mv #{tmp_output_filename} #{output_filename};
104
+ fi
105
+ EOS
106
+
107
+ exit(0)
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Diffs two combined_process_settings.yml files. Skips over the meta-data (version) at the END.
5
+ # A filename of - means STDIN.
6
+
7
+ require 'fileutils'
8
+
9
+ PROGRAM_NAME = File.basename($PROGRAM_NAME)
10
+
11
+ unless ARGV.size == 2
12
+ puts "usage: #{PROGRAM_NAME} <path-to-combined_process_settings-A.yml> <path-to-combined_process_settings-B.yml>"
13
+ exit 1
14
+ end
15
+
16
+ path_to_combine_process_settings = ARGV[0]
17
+
18
+ input_files =
19
+ ARGV.map do |path|
20
+ if path == '-'
21
+ ''
22
+ else
23
+ unless File.exists?(path)
24
+ warn "#{path} not found--must be a path to combined_process_settings.yml"
25
+ exit 1
26
+ end
27
+ "< #{path}"
28
+ end
29
+ end
30
+
31
+ system("rm -f tmp/combined_process_settings-A.yml tmp/combined_process_settings-B.yml")
32
+
33
+ # remove the meta-data from the end
34
+ system("sed '/^- meta:$/,$d' #{input_files[0]} > tmp/combined_process_settings-A.yml")
35
+ system("sed '/^- meta:$/,$d' #{input_files[1]} > tmp/combined_process_settings-B.yml")
36
+
37
+ system("diff tmp/combined_process_settings-A.yml tmp/combined_process_settings-B.yml")
38
+
39
+ system("rm -f tmp/combined_process_settings-A.yml tmp/combined_process_settings-B.yml")
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #TODO: I need to add a test for this file. Probably by moving the algorithm into methods that are tested and leaving
5
+ #TODO: this as a command line shell. -Colin
6
+
7
+ require_relative '../lib/process_settings/targeted_settings'
8
+ require_relative '../lib/process_settings/util'
9
+ require 'yaml'
10
+ require 'set'
11
+
12
+ CORRELATION_KEYS = [
13
+ ['node'],
14
+ ['app'],
15
+ ['region'],
16
+ ['app', 'node'],
17
+ ['app', 'region'],
18
+ ['deployGroup'],
19
+ ['app', 'deployGroup']
20
+ ].freeze
21
+
22
+ PROGRAM_NAME = File.basename($PROGRAM_NAME)
23
+
24
+ def parse_options(argv)
25
+ if argv.size == 3
26
+ argv
27
+ else
28
+ warn "usage: #{PROGRAM_NAME} <path-to-flat_services.yml> <path-to-combined_process_settings-OLD.yml> <path-to-combined_process_settings-NEW.yml>"
29
+ exit(1)
30
+ end
31
+ end
32
+
33
+
34
+ #
35
+ # main
36
+ #
37
+ path_to_flat_services_yml, path_to_combined_process_settings_yml_old, path_to_combined_process_settings_yml_new = parse_options(ARGV.dup)
38
+
39
+ flat_services_yml_json_doc = YAML.load_file(path_to_flat_services_yml)
40
+ combined_process_settings_yml_json_doc_old = YAML.load_file(path_to_combined_process_settings_yml_old)
41
+ combined_process_settings_yml_json_doc_new = YAML.load_file(path_to_combined_process_settings_yml_new)
42
+
43
+ targeted_process_settings_old = ProcessSettings::TargetedSettings.from_array(combined_process_settings_yml_json_doc_old)
44
+ targeted_process_settings_new = ProcessSettings::TargetedSettings.from_array(combined_process_settings_yml_json_doc_new)
45
+
46
+ flat_services_with_targeted_settings = Hash.new { |h, service| h[service] = Hash.new { |h2, node| h2[node] = [] } }
47
+
48
+ correlation_scorecard = Hash.new { |h, k| h[k] = { false => Set.new, true => Set.new } }
49
+
50
+ flat_services_yml_json_doc.each do |service, nodes|
51
+ nodes.each do |node, pods|
52
+ pods.each_with_index do |pod_context, pod_index|
53
+ node_attrs = pod_context.dup
54
+ node_attrs['service'] = service
55
+ node_attrs['node'] = node
56
+ node_attrs['pod_index'] = pod_index if pods.size > 1
57
+ node_targeted_process_settings_old = targeted_process_settings_old.with_static_context(pod_context)
58
+ node_targeted_process_settings_new = targeted_process_settings_new.with_static_context(pod_context)
59
+
60
+ flat_services_with_targeted_settings[service][node] << node_attrs
61
+
62
+ if (changed = node_targeted_process_settings_old != node_targeted_process_settings_new)
63
+ node_old = node_targeted_process_settings_old.map do |node_targeted_process_setting|
64
+ {
65
+ 'target' => node_targeted_process_setting.target,
66
+ "process_settings" => node_targeted_process_setting.process_settings
67
+ }
68
+ end
69
+ node_new = node_targeted_process_settings_new.map do |node_targeted_process_setting|
70
+ {
71
+ 'target' => node_targeted_process_setting.target,
72
+ "process_settings" => node_targeted_process_setting.process_settings
73
+ }
74
+ end
75
+
76
+ changes_hash = node_attrs['__changes__'] = {
77
+ 'old' => node_old,
78
+ 'new' => node_new
79
+ }
80
+ changes_hash['pod_index'] = pod_index if pods.size > 1
81
+ end
82
+
83
+ CORRELATION_KEYS.each do |correlation_key|
84
+ scorecard = correlation_scorecard[correlation_key]
85
+
86
+ correlation_key_values = correlation_key.map { |key| { key => node_attrs[key] } }
87
+ scorecard[changed] << correlation_key_values
88
+ if changed
89
+ scorecard['__changes__'] = changes_hash
90
+ scorecard['__pods__'] ||= 0
91
+ scorecard['__pods__'] += 1
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ if correlation_scorecard.any? { |_correllation_key, scorecard| scorecard[true].any? }
99
+ perfect_correlations = correlation_scorecard.select do |_correllation_key, scorecard|
100
+ (scorecard[false] & scorecard[true]).empty?
101
+ end
102
+
103
+ if (best_correlation = perfect_correlations.min_by { |_correllation_key, scorecard| scorecard[true].size })
104
+ puts "#{best_correlation.last['__pods__']} pods"
105
+ puts best_correlation.last[true].to_a.first.to_yaml
106
+ puts "Diff:"
107
+ system("rm -f tmp/old.yml tmp/new.yml")
108
+ File.write("tmp/old.yml", ProcessSettings.plain_hash(best_correlation.last['__changes__']['old']).to_yaml)
109
+ File.write("tmp/new.yml", ProcessSettings.plain_hash(best_correlation.last['__changes__']['new']).to_yaml)
110
+ STDOUT << `diff tmp/old.yml tmp/new.yml`
111
+ else
112
+ puts "No best correlation found?"
113
+ end
114
+ else
115
+ puts "No changes"
116
+ end
117
+
118
+ # puts flat_services_with_targeted_settings.to_yaml
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Pulls the version from a combined_process_settings.yml file and prints it on STDOUT.
5
+
6
+ require_relative '../lib/process_settings/targeted_settings'
7
+
8
+ PROGRAM_NAME = File.basename($PROGRAM_NAME)
9
+
10
+ unless ARGV.size == 1
11
+ puts "usage: #{PROGRAM_NAME} <path-to-combined_process_settings.yml>"
12
+ exit 1
13
+ end
14
+
15
+ targeted_settings = ProcessSettings::TargetedSettings.from_file(ARGV[0], only_meta: true)
16
+
17
+ puts targeted_settings.version
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessSettings
4
+ # Module for mixing into `Hash` or other class with `[]` that you want to be able to index
5
+ # with a hash path like: hash['app.service_name' => 'frontend']
6
+ module HashPath
7
+ def [](key)
8
+ if key.is_a?(Hash)
9
+ HashPath.hash_at_path(self, key)
10
+ else
11
+ super
12
+ end
13
+ end
14
+
15
+ class << self
16
+ def hash_at_path(hash, path)
17
+ if path.is_a?(Hash)
18
+ case path.size
19
+ when 0
20
+ hash
21
+ when 1
22
+ path_key, path_value = path.first
23
+ if (remaining_hash = hash[path_key])
24
+ remaining_path = path_value
25
+ hash_at_path(remaining_hash, remaining_path)
26
+ end
27
+ else
28
+ raise ArgumentError, "path may have at most 1 key (got #{path.inspect})"
29
+ end
30
+ else
31
+ hash[path]
32
+ end
33
+ end
34
+
35
+ def set_hash_at_path(hash, path)
36
+ path.is_a?(Hash) or raise ArgumentError, "got unexpected non-hash value (#{hash[path]}"
37
+ case path.size
38
+ when 0
39
+ hash
40
+ when 1
41
+ path_key, path_value = path.first
42
+ if path_value.is_a?(Hash)
43
+ set_hash_at_path(remaining_hash, remaining_path)
44
+ else
45
+ hash[path_key] = path_value
46
+ end
47
+ else
48
+ raise ArgumentError, "path may have at most 1 key (got #{path.inspect})"
49
+ end
50
+ hash
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hash_path'
4
+
5
+ module ProcessSettings
6
+ class HashWithHashPath < Hash
7
+ prepend HashPath
8
+ end
9
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'targeted_settings'
4
+ require_relative 'hash_path'
5
+ require 'psych'
6
+ require 'listen'
7
+ require 'active_support'
8
+
9
+ module ProcessSettings
10
+ class Monitor
11
+ attr_reader :file_path, :min_polling_seconds
12
+ attr_reader :static_context, :untargeted_settings, :statically_targeted_settings
13
+
14
+ DEFAULT_MIN_POLLING_SECONDS = 5
15
+
16
+ def initialize(file_path, logger:)
17
+ @file_path = File.expand_path(file_path)
18
+ @logger = logger
19
+ @on_change_callbacks = []
20
+ @static_context = {}
21
+
22
+ # to eliminate any race condition:
23
+ # 1. set up file watcher
24
+ # 2. start it
25
+ # 3. load the file
26
+ # 4. run the watcher (which should trigger if any changes have been made since (2))
27
+
28
+ path = File.dirname(@file_path)
29
+
30
+ @listener = file_change_notifier.to(path) do |modified, _added, _removed|
31
+ if modified.include?(@file_path)
32
+ @logger.info("ProcessSettings::Monitor file #{@file_path} changed. Reloading.")
33
+ load_untargeted_settings
34
+ end
35
+ end
36
+
37
+ @listener.start
38
+
39
+ load_untargeted_settings
40
+ end
41
+
42
+ # stops listening for changes
43
+ def stop
44
+ @listener.stop
45
+ end
46
+
47
+ # Registers the given callback block to be called when settings change.
48
+ # These are run using the shared thread that monitors for changes so be courteous and don't monopolize it!
49
+ def on_change(&callback)
50
+ @on_change_callbacks << callback
51
+ end
52
+
53
+ # Loads the most recent settings from disk and returns them.
54
+ def load_untargeted_settings
55
+ @untargeted_settings = load_file(file_path)
56
+ end
57
+
58
+ # Assigns a new static context. Recomputes statically_targeted_settings.
59
+ def static_context=(context)
60
+ @static_context = context
61
+
62
+ statically_targeted_settings(force: true)
63
+ end
64
+
65
+ # Loads the latest untargeted settings from disk. Returns the current process settings as a TargetAndProcessSettings given
66
+ # by applying the static context to the current untargeted settings from disk.
67
+ # If these have changed, borrows this thread to call notify_on_change.
68
+ def statically_targeted_settings(force: false)
69
+ if force || @last_untargetted_settings != @untargeted_settings
70
+ @statically_targeted_settings = @untargeted_settings.with_static_context(@static_context)
71
+ @last_untargetted_settings = @untargeted_settings
72
+
73
+ notify_on_change
74
+ end
75
+
76
+ @statically_targeted_settings
77
+ end
78
+
79
+ # Returns the process settings value at the given `path` using the given `dynamic_context`.
80
+ # (It is assumed that the static context was already set through static_context=.)
81
+ # Returns `nil` if nothing set at the given `path`.
82
+ def targeted_value(path, dynamic_context)
83
+ # Merging the static context in is necessary to make sure that the static context isn't shifting
84
+ # this can be rather costly to do every time if the dynamic context is not changing
85
+ # TODO: Warn in the case where dynamic context was attempting to change a static value
86
+ # TODO: Cache the last used dynamic context as a potential optimization to avoid unnecessary deep merges
87
+ full_context = dynamic_context.deep_merge(static_context)
88
+ statically_targeted_settings.reduce(nil) do |result, target_and_settings|
89
+ # find last value from matching targets
90
+ if target_and_settings.target.target_key_matches?(full_context)
91
+ unless (value = HashPath.hash_at_path(target_and_settings.process_settings, path)).nil?
92
+ result = value
93
+ end
94
+ end
95
+ result
96
+ end
97
+ end
98
+
99
+ class << self
100
+ attr_accessor :file_path
101
+ attr_reader :logger
102
+
103
+ def clear_instance
104
+ @instance = nil
105
+ end
106
+
107
+ def instance
108
+ file_path or raise ArgumentError, "#{self}::file_path must be set before calling instance method"
109
+ logger or raise ArgumentError, "#{self}::logger must be set before calling instance method"
110
+ @instance ||= new(file_path, logger: logger)
111
+ end
112
+
113
+ def logger=(new_logger)
114
+ @logger = new_logger
115
+ Listen.logger = new_logger
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def notify_on_change
122
+ @on_change_callbacks.each do |callback|
123
+ begin
124
+ callback.call(self)
125
+ rescue => ex
126
+ logger.error("ProcessSettings::Monitor#notify_on_change rescued exception:\n#{ex.class}: #{ex.message}")
127
+ end
128
+ end
129
+ end
130
+
131
+ def load_file(file_path)
132
+ TargetedSettings.from_file(file_path)
133
+ end
134
+
135
+ def file_change_notifier
136
+ Listen
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'targeted_settings'
4
+
5
+ module ProcessSettings
6
+ # This class will override a file with a higher version file; it accounts for minor version number use
7
+ class ReplaceVersionedFile
8
+
9
+ def replace_file_on_newer_file_version(source_file_name, destination_file_name)
10
+ if source_version_is_newer?(source_file_name, destination_file_name)
11
+ FileUtils.mv(source_file_name, destination_file_name)
12
+ elsif source_file_name != destination_file_name # make sure we're not deleting destination file
13
+ FileUtils.remove_file(source_file_name) # clean up, remove left over file
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def source_version_is_newer?(source_file_name, destination_file_name)
20
+ source_version = ProcessSettings::TargetedSettings.from_file(source_file_name, only_meta: true).version
21
+ destination_version = ProcessSettings::TargetedSettings.from_file(destination_file_name, only_meta: true).version
22
+
23
+ Gem::Version.new(source_version) > Gem::Version.new(destination_version)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hash_with_hash_path'
4
+
5
+ module ProcessSettings
6
+ class Settings
7
+ include Comparable
8
+
9
+ attr_reader :json_doc
10
+
11
+ def initialize(json_doc)
12
+ json_doc.is_a?(Hash) or raise ArgumentError, "ProcessSettings must be a Hash; got #{json_doc.inspect}"
13
+
14
+ @json_doc = HashWithHashPath[json_doc]
15
+ end
16
+
17
+ def ==(rhs)
18
+ json_doc == rhs.json_doc
19
+ end
20
+
21
+ def eql?(rhs)
22
+ self == rhs
23
+ end
24
+
25
+ def [](key)
26
+ @json_doc[key]
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessSettings
4
+ class Target
5
+ include Comparable
6
+
7
+ attr_reader :json_doc
8
+
9
+ def initialize(json_doc)
10
+ @json_doc = json_doc
11
+ end
12
+
13
+ def target_key_matches?(context_hash)
14
+ @json_doc == {} || self.class.target_key_matches?(@json_doc, context_hash)
15
+ end
16
+
17
+ def with_static_context(static_context_hash)
18
+ new_json_doc = self.class.with_static_context(@json_doc, static_context_hash)
19
+ self.class.new(new_json_doc)
20
+ end
21
+
22
+ def ==(rhs)
23
+ json_doc == rhs.json_doc
24
+ end
25
+
26
+ def eql?(rhs)
27
+ self == rhs
28
+ end
29
+
30
+ class << self
31
+ def with_static_context(target_value, static_context_hash)
32
+ case target_value
33
+ when Array
34
+ with_static_context_array(target_value, static_context_hash)
35
+ when Hash
36
+ with_static_context_hash(target_value, static_context_hash)
37
+ when true, false
38
+ !target_value == !static_context_hash
39
+ else
40
+ target_value == static_context_hash
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def with_static_context_array(target_value, static_context_hash)
47
+ target_value.any? do |value|
48
+ with_static_context(value, static_context_hash)
49
+ end
50
+ end
51
+
52
+ def with_static_context_hash(target_value, static_context_hash)
53
+ result = target_value.reduce({}) do |hash, (key, value)|
54
+ if static_context_hash.has_key?(key)
55
+ context_at_key = static_context_hash[key]
56
+ sub_value = with_static_context(value, context_at_key)
57
+ case sub_value
58
+ when true # this hash entry is true, so omit it
59
+ when false # this hash entry is false, so hash is false
60
+ return false
61
+ else
62
+ raise ArgumentError, "Got #{sub_value.inspect}???"
63
+ end
64
+ else
65
+ hash[key] = value
66
+ end
67
+ hash
68
+ end
69
+
70
+ if result.any?
71
+ result
72
+ else
73
+ true
74
+ end
75
+ end
76
+
77
+ public
78
+
79
+ def target_key_matches?(target_value, context_hash)
80
+ case target_value
81
+ when Array
82
+ target_value.any? { |value| target_key_matches?(value, context_hash) }
83
+ when Hash
84
+ target_value.all? do |key, value|
85
+ if (context_at_key = context_hash[key])
86
+ target_key_matches?(value, context_at_key)
87
+ end
88
+ end
89
+ when true, false
90
+ target_value
91
+ else
92
+ target_value == context_hash
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'target'
4
+ require_relative 'settings'
5
+
6
+ module ProcessSettings
7
+ # This class encapsulates a single YAML file with target and process_settings.
8
+ class TargetAndSettings
9
+ attr_reader :filename, :target, :process_settings
10
+
11
+ def initialize(filename, target, settings)
12
+ @filename = filename
13
+
14
+ target.is_a?(Target) or raise ArgumentError, "target must be a ProcessTarget; got #{target.inspect}"
15
+ @target = target
16
+
17
+ settings.is_a?(Settings) or raise ArgumentError, "settings must be a ProcessSettings; got #{settings.inspect}"
18
+ @process_settings = settings
19
+ end
20
+
21
+ def ==(rhs)
22
+ to_json_doc == rhs.to_json_doc
23
+ end
24
+
25
+ def eql?(rhs)
26
+ self == rhs
27
+ end
28
+
29
+ def to_json_doc
30
+ {
31
+ "target" => @target.json_doc,
32
+ "settings" => @process_settings.json_doc
33
+ }
34
+ end
35
+
36
+ class << self
37
+ def from_json_docs(filename, target_json_doc, settings_json_doc)
38
+ target_json_doc = Target.new(target_json_doc)
39
+
40
+ process_settings = Settings.new(settings_json_doc)
41
+
42
+ new(filename, target_json_doc, process_settings)
43
+ end
44
+ end
45
+
46
+ # returns a copy of self with target simplified based on given static_context_hash (or returns self if there is no difference)
47
+ def with_static_context(static_context_hash)
48
+ new_target = target.with_static_context(static_context_hash)
49
+ if new_target == @target
50
+ self
51
+ else
52
+ self.class.new(@filename, new_target, @process_settings)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'target_and_settings'
5
+
6
+ module ProcessSettings
7
+ # This class encapsulates an ordered collection of TargetAndSettings (each of which came from one YAML file).
8
+ class TargetedSettings
9
+ KEY_NAMES = ["filename", "target", "settings"].freeze
10
+
11
+ attr_reader :targeted_settings_array, :version
12
+
13
+ def initialize(targeted_settings_array, version:)
14
+ targeted_settings_array.is_a?(Array) or raise ArgumentError, "targeted_settings_array must be an Array of Hashes; got #{targeted_settings_array.inspect}"
15
+ targeted_settings_array.each do |target_and_settings|
16
+ target_and_settings.is_a?(TargetAndSettings) or
17
+ raise ArgumentError, "targeted_settings_array entries must each be a TargetAndProcessSettings; got #{target_and_settings.inspect}"
18
+ end
19
+
20
+ @targeted_settings_array = targeted_settings_array
21
+
22
+ @version = version or raise ArgumentError, "version must not be empty"
23
+ end
24
+
25
+ def ==(rhs)
26
+ to_json_doc == rhs.to_json_doc
27
+ end
28
+
29
+ def eql?(rhs)
30
+ self == rhs
31
+ end
32
+
33
+ def to_json_doc
34
+ @targeted_settings_array.map(&:to_json_doc)
35
+ end
36
+
37
+ def to_yaml
38
+ to_json_doc.to_yaml
39
+ end
40
+
41
+ class << self
42
+ def from_array(settings_array, only_meta: false)
43
+ settings_array.is_a?(Array) or raise ArgumentError, "settings_array must be an Array of Hashes; got #{settings_array.inspect}"
44
+ meta_hash = nil
45
+
46
+ targeted_settings_array =
47
+ settings_array.map do |settings_hash|
48
+ settings_hash.is_a?(Hash) or raise ArgumentError, "settings_array entries must each be a Hash; got #{settings_hash.inspect}"
49
+
50
+ meta_hash and raise ArgumentError, "\"meta\" marker must be at end; got #{settings_hash.inspect} after"
51
+ if (meta_hash = settings_hash["meta"])
52
+ meta_hash.to_a.last == ['END', true] or raise ArgumentError, "END: true must be at end of file; got #{meta_hash.inspect}"
53
+ next
54
+ end
55
+
56
+ unless only_meta
57
+ filename = settings_hash["filename"]
58
+ target_settings_hash = settings_hash["target"] || true
59
+ settings_settings_hash = settings_hash["settings"]
60
+
61
+ settings_settings_hash or raise ArgumentError, "settings_array entries must each have 'settings' hash: #{settings_hash.inspect}"
62
+
63
+ (extra_keys = settings_hash.keys - KEY_NAMES).empty? or
64
+ raise ArgumentError, "settings_array entries must each have exactly these keys: #{KEY_NAMES.inspect}; got these extras: #{extra_keys.inspect}\nsettings_hash: #{settings_hash.inspect}"
65
+
66
+ TargetAndSettings.from_json_docs(filename, target_settings_hash, settings_settings_hash)
67
+ end
68
+ end.compact
69
+
70
+ meta_hash or raise ArgumentError, "Missing meta: marker at end; got #{settings_array.inspect}"
71
+
72
+ new(targeted_settings_array, version: meta_hash['version'])
73
+ end
74
+
75
+ def from_file(file_path, only_meta: false)
76
+ json_doc = Psych.load_file(file_path)
77
+ from_array(json_doc, only_meta: only_meta)
78
+ end
79
+ end
80
+
81
+ def matching_settings(context_hash)
82
+ @targeted_settings_array.select do |target_and_settings|
83
+ target_and_settings.target.target_key_matches?(context_hash)
84
+ end
85
+ end
86
+
87
+ # returns the collection of targeted_settings with target simplified based on given static_context_hash
88
+ # omits entries whose targeting is then false
89
+ def with_static_context(static_context_hash)
90
+ @targeted_settings_array.map do |target_and_settings|
91
+ new_target_and_process_settings = target_and_settings.with_static_context(static_context_hash)
92
+ new_target_and_process_settings if new_target_and_process_settings.target.json_doc
93
+ end.compact
94
+ end
95
+
96
+ def settings_with_static_context(static_context_hash)
97
+ result_settings =
98
+ @settings_array.map do |target_and_settings|
99
+ if (new_target = target.with_static_context(static_context_hash))
100
+ TargetAndSettings.new(new_target, target_and_settings.settings)
101
+ end
102
+ end.compact
103
+
104
+ self.class.new(result_settings)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessSettings
4
+ class << self
5
+ def plain_hash(json_doc)
6
+ case json_doc
7
+ when Hash
8
+ result = {}
9
+ json_doc.each { |key, value| result[key] = plain_hash(value) }
10
+ result
11
+ when Array
12
+ json_doc.map { |value| plain_hash(value) }
13
+ else
14
+ if json_doc.respond_to?(:json_doc)
15
+ plain_hash(json_doc.json_doc)
16
+ else
17
+ json_doc
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessSettings
4
+ end
5
+
6
+ require 'process_settings/monitor'
7
+
8
+ module ProcessSettings
9
+ class << self
10
+ def [](value, dynamic_context = {})
11
+ Monitor.instance.targeted_value(value, dynamic_context)
12
+ end
13
+ end
14
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: process_settings
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Colin Kelley
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-09-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: listen
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ description: Targed process settings that dynamically reload without restarting the
56
+ process
57
+ email: colin@invoca.com
58
+ executables:
59
+ - combine_process_settings
60
+ - diff_process_settings
61
+ - process_settings_for_services
62
+ - process_settings_version
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - LICENSE
67
+ - README.md
68
+ - bin/combine_process_settings
69
+ - bin/diff_process_settings
70
+ - bin/process_settings_for_services
71
+ - bin/process_settings_version
72
+ - lib/process_settings.rb
73
+ - lib/process_settings/hash_path.rb
74
+ - lib/process_settings/hash_with_hash_path.rb
75
+ - lib/process_settings/monitor.rb
76
+ - lib/process_settings/replace_versioned_file.rb
77
+ - lib/process_settings/settings.rb
78
+ - lib/process_settings/target.rb
79
+ - lib/process_settings/target_and_settings.rb
80
+ - lib/process_settings/targeted_settings.rb
81
+ - lib/process_settings/util.rb
82
+ homepage: https://rubygems.org/gems/process_settings
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ source_code_uri: https://github.com/Invoca/process_settings
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubyforge_project:
103
+ rubygems_version: 2.6.13
104
+ signing_key:
105
+ specification_version: 4
106
+ summary: Dynamic process settings
107
+ test_files: []