process_settings 0.3.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.
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: []