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 +7 -0
- data/LICENSE +21 -0
- data/README.md +38 -0
- data/bin/combine_process_settings +107 -0
- data/bin/diff_process_settings +39 -0
- data/bin/process_settings_for_services +118 -0
- data/bin/process_settings_version +17 -0
- data/lib/process_settings/hash_path.rb +54 -0
- data/lib/process_settings/hash_with_hash_path.rb +9 -0
- data/lib/process_settings/monitor.rb +139 -0
- data/lib/process_settings/replace_versioned_file.rb +26 -0
- data/lib/process_settings/settings.rb +29 -0
- data/lib/process_settings/target.rb +97 -0
- data/lib/process_settings/target_and_settings.rb +56 -0
- data/lib/process_settings/targeted_settings.rb +107 -0
- data/lib/process_settings/util.rb +22 -0
- data/lib/process_settings.rb +14 -0
- metadata +107 -0
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 [](https://travis-ci.org/Invoca/process_settings) [](https://coveralls.io/github/Invoca/process_settings?branch=master) [](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,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: []
|