process_settings 0.4.0.1 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 191a509df72fd317ad7475301c1c1f9ed2b9234d
4
- data.tar.gz: 32913ebed29db8091d71c1e669d75964f0b2170f
3
+ metadata.gz: 587893ae016c645a43a870af29b50f4da16eb916
4
+ data.tar.gz: 9b3b4ad7e24c9d52a5dea8d459df143c2c671aac
5
5
  SHA512:
6
- metadata.gz: b09368b7c8d4d091e955d492be4231cc909719e544d170484e79f1ee27af606c42e927e87f465e7cb7587b02ecbe7750a0ff2bee4f2d91a75dd4dfd8cfc6f327
7
- data.tar.gz: 7aa5af5281aec6c0c9ec14f9c47c9bdf5e276ba7d6ceeb348dc2e62eebddb5f0553eeb13bcc6271c0a3ef7c957f6faaa6fbee27515ce007eed79c0ec5cf306bc
6
+ metadata.gz: 969389525e75f8b8e7884d2300c2726a6eb657b291110aa6347b5f4b07e0716e5587e2e3c0651ce6720f296849a77809eab0e6478b9e23157ea30f622d4ddacc
7
+ data.tar.gz: 80294ade9ce5babf95249641fbdddd815abaddf2cb3e70516377376ffc2dd0e839a1f731a1f6ae8db13197e2385ec72f4e95bd9fa29625a69d130f61bcd98b85
data/README.md CHANGED
@@ -1,7 +1,9 @@
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).
1
+ # ProcessSettings
2
+ This gem provides dynamic settings for Ruby processes. The settings are stored in YAML.
3
+ Settings are managed in a git repo, in separate YAML files for each concern (for example, each micro-service). Each YAML file can be targeted based on matching context values (for example, `service_name`).
4
+
5
+
6
+ The context can be either static to the process (for example, `service_name` or `data_center`) or dynamic (for example, the current web request `domain`).
5
7
 
6
8
  ## Installation
7
9
  To install this gem directly on your machine from rubygems, run the following:
@@ -11,27 +13,151 @@ gem install process_settings
11
13
 
12
14
  To install this gem in your bundler project, add the following to your Gemfile:
13
15
  ```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'
16
+ gem 'process_settings', '~> 0.4'
20
17
  ```
21
18
 
22
19
  ## Usage
23
- ### Initialization
24
- To use the contextual logger, all you need to do is initailize the object with your existing logger
20
+ The `ProcessSettings::Monitor` and related classes can be freely created and used at any time.
21
+ But typical usage is through the `ProcessSettings::Monitor.instance`.
22
+ That should be configured at process startup before use.
23
+ ### Configuration
24
+ Before using `ProcessSettings::Monitor.instance`, you must first configure the path to the combined process settings file on disk,
25
+ and provide a logger.
25
26
  ```ruby
26
27
  require 'process_settings'
28
+
29
+ ProcessSettings::Monitor.file_path = "/etc/process_settings/combined_process_settings.yml"
30
+ ProcessSettings::Monitor.logger = logger
31
+ ```
32
+ ### Monitor Initialization
33
+ The `ProcessSettings::Monitor` is a hybrid singleton. The class attribute `instance` returns
34
+ the current instance. If not already set, this is lazy-created based on the above configuration.
35
+
36
+ The monitor should be initialized with static (unchanging) context for your process:
37
+ ```
38
+ ProcessSettings::Monitor.static_context = {
39
+ "service_name" => "frontend",
40
+ "data_center" => "AWS-US-EAST-1"
41
+ }
42
+ ```
43
+ The `static_context` is important because it is used to pre-filter settings for the process.
44
+ For example, a setting that is targeted to `service_name: frontend` will match the above static context and
45
+ be simplified to `true`. In other processes with a different `service_name`, such a targeted setting will be
46
+ simplified to `false` and removed from memory.
47
+
48
+ Note that the `static_context` as well as `dynamic_context` must use strings, not symbols, for both keys and values.
49
+
50
+ ### Reading Settings
51
+ For the following section, consider this `combined_process_settings.yml` file:
52
+ ```
53
+ ---
54
+ - filename: frontend.yml
55
+ settings:
56
+ frontend:
57
+ log_level: info
58
+ - filename: frontend-microsite.yml
59
+ target:
60
+ domain: microsite.example.com
61
+ settings:
62
+ frontend:
63
+ log_level: debug
64
+ - meta:
65
+ version: 27
66
+ END: true
67
+ ```
68
+
69
+ To read a setting, application code should call the `[]` method on the `ProcessSettings` class. For example:
70
+ ```
71
+ log_level = ProcessSettings['frontend', 'log_level']
72
+ => "info"
73
+ ```
74
+ #### ProcessSettings[] interface
75
+ The `ProcessSettings[]` method delegates to `ProcessSettings::Monitor#[]` on the `instance`.
76
+
77
+ `[]` interface:
78
+
79
+ ```
80
+ [](*path, dynamic_context: {}, required: true)
81
+ ```
82
+
83
+ |argument|description|
84
+ |--------|-------------|
85
+ |_path_ |A series of 1 or more comma-separated strings forming a path to navigate the `settings` hash, starting at the top.|
86
+ |`dynamic_context:` |An optional hash of dynamic settings, used to target the settings. This will automatically be deep-merged with the static context. It may not contradict the static context. |
87
+ |`required:` |A boolean indicating if the setting is required to be present. If a setting is missing, then if `required` is truthy, a `ProcesssSettings::SettingsPathNotFound` exception will be raised. Otherwise, `nil` will be returned. Default: `true`.
88
+
89
+ Example with `dynamic_context`:
90
+ ```
91
+ log_level = ProcessSettings['frontend', 'log_level',
92
+ dynamic_context: { "domain" => "microsite.example.com" }
93
+ ]
94
+ => "debug"
27
95
  ```
28
96
 
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.
97
+ Example with `required: true` (default) that was not found:
98
+ ```
99
+ http_version = ProcessSettings['frontend', 'http_version']
100
+
101
+ exception raised!
102
+
103
+ ProcessSettings::SettingsPathNotFound: No settings found for path ["frontend", "http_version"]
104
+ ```
105
+
106
+ Here is the same example with `required: false`, applying a default value of `2`:
107
+ ```
108
+ http_version = ProcessSettings['frontend', 'http_version', required: false] || 2
109
+ ```
30
110
 
31
111
  ### Dynamic Settings
32
112
 
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.
113
+ The `ProcessSettings::Monitor` loads settings changes dynamically whenever the file changes,
114
+ by using the [listen](https://github.com/guard/listen) gem which in turn uses the `INotify` module of the Linux kernel, or `FSEvents` on MacOS. There is no need to restart the process or send it a signal to tell it to reload changes.
115
+
116
+ There are two ways to get access the latest settings from inside the process:
117
+
118
+ #### Read Latest Setting Through `ProcessSettings[]`
119
+
120
+ The simplest approach--as shown above--is to read the latest settings at any time through `ProcessSettings[]` (which delegates to `ProcessSettings::Monitor.instance`):
121
+ ```
122
+ http_version = ProcessSettings['frontend', 'http_version']
123
+ ```
124
+
125
+ #### Register an `on_change` Callback
126
+ Alternatively, if you need to execute some code when there is a change, register a callback with `ProcessSettings::Monitor#on_change`:
127
+ ```
128
+ ProcessSettings::Monitor.instance.on_change do
129
+ logger.level = ProcessSettings['frontend', 'log_level']
130
+ end
131
+ ```
132
+ Note that all callbacks run sequentially on the shared change monitoring thread, so please be considerate!
133
+
134
+ There is no provision for unregistering callbacks. Instead, replace the `instance` of the monitor with a new one.
135
+
136
+ ## Targeting
137
+ Each settings YAML file has an optional `target` key at the top level, next to `settings`.
138
+
139
+ If there is no `target` key, the target defaults to `true`, meaning all processes are targeted for these settings. (However, the settings may be overridden by other YAML files. See "Precedence" below.)
140
+
141
+ ### Hash Key-Values Are AND'd
142
+ To `target` on context values, provide a hash of key-value pairs. All keys must match for the target to be met. For example, consider this target hash:
143
+ ```
144
+ target:
145
+ service_name: frontend
146
+ data_center: AWS-US-EAST-1
147
+ ```
148
+ This will be applied in any process that has `service_name == "frontend"` AND is running in `data_center == "AWS-US-EAST-1"`.
149
+
150
+ ### Multiple Values Are OR'd
151
+ Values may be set to an array, in which case the key matches if _any_ of the values matches. For example, consider this target hash:
152
+ ```
153
+ target:
154
+ service_name: [frontend, auth]
155
+ data_center: AWS-US-EAST-1
156
+ ```
157
+ This will be applied in any process that has (`service_name == "frontend"` OR `service_name == "auth"`) AND `data_center == "AWS-US-EAST-1"`.
34
158
 
159
+ ### Precedence
160
+ The settings YAML files are always combined in alphabetical order by file path. Later settings take precedence over the earlier ones.
35
161
 
36
162
  ## Contributions
37
163
 
@@ -32,15 +32,16 @@ def parse_options(argv)
32
32
  options.verbose = false
33
33
  option_parser = OptionParser.new(argv) do |opt|
34
34
  opt.on('-v', '--verbose', 'Verbose mode.') { options.verbose = true }
35
+ opt.on('-n', '--version=VERSION', 'Set version number.') { |value| options.version = value.to_i unless value.empty? }
35
36
  opt.on('-r', '--root_folder=ROOT') { |o| options.root_folder = o }
36
37
  opt.on('-o', '--output=FILENAME', 'Output file.') { |o| options.output_filename = o }
37
38
  opt.on('-i', '--initial=FILENAME', 'Initial settings file for version inference.') { |o| options.initial_filename = o }
38
39
  end
39
40
 
40
- if option_parser.parse! && options.root_folder && options.output_filename && (ENV['BUILD_NUMBER'] || options.initial_filename)
41
+ if option_parser.parse! && options.root_folder && options.output_filename && (options.version || options.initial_filename)
41
42
  options
42
43
  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
+ warn "usage: #{PROGRAM_NAME} -r staging|production -o combined_process_settings.yml [--version=VERSION] [-i initial_combined_process_settings.yml] (-i required if --version= not set)"
44
45
  option_parser.summarize(STDERR)
45
46
  exit(1)
46
47
  end
@@ -82,7 +83,7 @@ options = parse_options(ARGV.dup)
82
83
 
83
84
  combined_settings = read_and_combine_settings(Pathname.new(options.root_folder) + SETTINGS_FOLDER)
84
85
 
85
- version_number = ENV['BUILD_NUMBER']&.to_i || default_version_number(options.initial_filename)
86
+ version_number = options.version || default_version_number(options.initial_filename)
86
87
  combined_settings << end_marker(version_number)
87
88
 
88
89
  yaml = combined_settings.to_yaml
@@ -34,6 +34,6 @@ system("rm -f tmp/combined_process_settings-A.yml tmp/combined_process_settings-
34
34
  system("sed '/^- meta:$/,$d' #{input_files[0]} > tmp/combined_process_settings-A.yml")
35
35
  system("sed '/^- meta:$/,$d' #{input_files[1]} > tmp/combined_process_settings-B.yml")
36
36
 
37
- system("diff tmp/combined_process_settings-A.yml tmp/combined_process_settings-B.yml")
37
+ system("diff -c tmp/combined_process_settings-A.yml tmp/combined_process_settings-B.yml | sed '1,3d'")
38
38
 
39
39
  system("rm -f tmp/combined_process_settings-A.yml tmp/combined_process_settings-B.yml")
@@ -107,7 +107,7 @@ if correlation_scorecard.any? { |_correllation_key, scorecard| scorecard[true].a
107
107
  system("rm -f tmp/old.yml tmp/new.yml")
108
108
  File.write("tmp/old.yml", ProcessSettings.plain_hash(best_correlation.last['__changes__']['old']).to_yaml)
109
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`
110
+ STDOUT << `diff -c tmp/old.yml tmp/new.yml | sed '1,3d'`
111
111
  else
112
112
  puts "No best correlation found?"
113
113
  end
@@ -7,8 +7,8 @@ require 'process_settings/monitor'
7
7
 
8
8
  module ProcessSettings
9
9
  class << self
10
- def [](value, dynamic_context = {})
11
- Monitor.instance.targeted_value(value, dynamic_context)
10
+ def [](*path, dynamic_context: {}, required: true)
11
+ Monitor.instance.targeted_value(*path, dynamic_context: dynamic_context, required: required)
12
12
  end
13
13
  end
14
14
  end
@@ -2,13 +2,24 @@
2
2
 
3
3
  module ProcessSettings
4
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']
5
+ # with a hash path like: hash.mine('honeypot', 'answer_odds')
6
6
  module HashPath
7
- def [](key)
8
- if key.is_a?(Hash)
9
- HashPath.hash_at_path(self, key)
10
- else
11
- super
7
+ # returns the value found at the given path
8
+ # if the path is not found:
9
+ # if a block is given, it is called and its value returned (may also raise an exception from the block)
10
+ # else, the not_found_value: is returned
11
+ def mine(*path_array, not_found_value: nil)
12
+ path_array.is_a?(Enumerable) && path_array.size > 0 or raise ArgumentError, "path must be 1 or more keys; got #{path_array.inspect}"
13
+ path_array.reduce(self) do |hash, key|
14
+ if hash.has_key?(key)
15
+ hash[key]
16
+ else
17
+ if block_given?
18
+ break yield
19
+ else
20
+ break not_found_value
21
+ end
22
+ end
12
23
  end
13
24
  end
14
25
 
@@ -31,24 +42,6 @@ module ProcessSettings
31
42
  hash[path]
32
43
  end
33
44
  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
45
  end
53
46
  end
54
47
  end
@@ -7,6 +7,8 @@ require 'listen'
7
7
  require 'active_support'
8
8
 
9
9
  module ProcessSettings
10
+ class SettingsPathNotFound < StandardError; end
11
+
10
12
  class Monitor
11
13
  attr_reader :file_path, :min_polling_seconds, :logger
12
14
  attr_reader :static_context, :untargeted_settings, :statically_targeted_settings
@@ -26,8 +28,8 @@ module ProcessSettings
26
28
 
27
29
  path = File.dirname(@file_path)
28
30
 
29
- @listener = file_change_notifier.to(path) do |modified, _added, _removed|
30
- if modified.include?(@file_path)
31
+ @listener = file_change_notifier.to(path) do |modified, added, _removed|
32
+ if modified.include?(@file_path) || added.include?(@file_path)
31
33
  @logger.info("ProcessSettings::Monitor file #{@file_path} changed. Reloading.")
32
34
  load_untargeted_settings
33
35
 
@@ -54,7 +56,10 @@ module ProcessSettings
54
56
  end
55
57
 
56
58
  # Assigns a new static context. Recomputes statically_targeted_settings.
59
+ # Keys must be strings or integers. No symbols.
57
60
  def static_context=(context)
61
+ self.class.ensure_no_symbols(context)
62
+
58
63
  @static_context = context
59
64
 
60
65
  load_statically_targeted_settings(force_retarget: true)
@@ -62,20 +67,32 @@ module ProcessSettings
62
67
 
63
68
  # Returns the process settings value at the given `path` using the given `dynamic_context`.
64
69
  # (It is assumed that the static context was already set through static_context=.)
65
- # Returns `nil` if nothing set at the given `path`.
66
- def targeted_value(path, dynamic_context)
70
+ # If nothing set at the given `path`:
71
+ # if required, raises SettingsPathNotFound
72
+ # else returns nil
73
+ def targeted_value(*path, dynamic_context:, required: true)
67
74
  # Merging the static context in is necessary to make sure that the static context isn't shifting
68
75
  # this can be rather costly to do every time if the dynamic context is not changing
69
76
  # TODO: Warn in the case where dynamic context was attempting to change a static value
70
77
  # TODO: Cache the last used dynamic context as a potential optimization to avoid unnecessary deep merges
71
78
  full_context = dynamic_context.deep_merge(static_context)
72
- statically_targeted_settings.reduce(nil) do |result, target_and_settings|
79
+ result = statically_targeted_settings.reduce(:not_found) do |latest_result, target_and_settings|
73
80
  # find last value from matching targets
74
81
  if target_and_settings.target.target_key_matches?(full_context)
75
- unless (value = HashPath.hash_at_path(target_and_settings.process_settings, path)).nil?
76
- result = value
82
+ if (value = target_and_settings.process_settings.json_doc.mine(*path, not_found_value: :not_found)) != :not_found
83
+ latest_result = value
77
84
  end
78
85
  end
86
+ latest_result
87
+ end
88
+
89
+ if result == :not_found
90
+ if required
91
+ raise SettingsPathNotFound, "no settings found for path #{path.inspect}"
92
+ else
93
+ nil
94
+ end
95
+ else
79
96
  result
80
97
  end
81
98
  end
@@ -124,6 +141,20 @@ module ProcessSettings
124
141
  @logger = new_logger
125
142
  Listen.logger = new_logger
126
143
  end
144
+
145
+ def ensure_no_symbols(value)
146
+ case value
147
+ when Symbol
148
+ raise ArgumentError, "symbol value #{value.inspect} found--should be String"
149
+ when Hash
150
+ value.each do |k, v|
151
+ k.is_a?(Symbol) and raise ArgumentError, "symbol key #{k.inspect} found--should be String"
152
+ ensure_no_symbols(v)
153
+ end
154
+ when Array
155
+ value.each { |v| ensure_no_symbols(v) }
156
+ end
157
+ end
127
158
  end
128
159
 
129
160
  # Calls all registered on_change callbacks. Rescues any exceptions they may raise.
@@ -5,31 +5,48 @@ require_relative 'targeted_settings'
5
5
  module ProcessSettings
6
6
  # This class will override a file with a higher version file; it accounts for minor version number use
7
7
  module ReplaceVersionedFile
8
+ class SourceVersionOlderError < StandardError; end
9
+ class FileDoesNotExistError < StandardError; end
10
+
8
11
  class << self
12
+ # Contracts
13
+ # source_file_path must be present
14
+ # destination_file_path must be present
15
+ # source_file_path must exist on filesystem
16
+ # source file version cannot be older destination version
9
17
  def replace_file_on_newer_file_version(source_file_path, destination_file_path)
10
18
  source_file_path.to_s == '' and raise ArgumentError, "source_file_path not present"
11
19
  destination_file_path.to_s == '' and raise ArgumentError, "destination_file_path not present"
20
+ File.exist?(source_file_path) or raise FileDoesNotExistError, "source file '#{source_file_path}' does not exist"
21
+ validate_source_version_is_not_older(source_file_path, destination_file_path)
12
22
 
13
23
  if source_version_is_newer?(source_file_path, destination_file_path)
14
- FileUtils.mv(source_file_path, destination_file_path)
15
- elsif source_file_path != destination_file_path # make sure we're not deleting destination file
16
- if File.exist?(source_file_path)
17
- FileUtils.remove_file(source_file_path) # clean up, remove left over file
18
- end
24
+ FileUtils.cp(source_file_path, destination_file_path)
19
25
  end
20
26
  end
21
27
 
22
28
  private
23
29
 
24
30
  def source_version_is_newer?(source_file_path, destination_file_path)
25
- if File.exist?(source_file_path)
26
- if File.exist?(destination_file_path)
27
- source_version = ProcessSettings::TargetedSettings.from_file(source_file_path, only_meta: true).version
28
- destination_version = ProcessSettings::TargetedSettings.from_file(destination_file_path, only_meta: true).version
29
-
30
- Gem::Version.new(source_version) > Gem::Version.new(destination_version)
31
- else
32
- true
31
+ if File.exist?(destination_file_path)
32
+ source_version = ProcessSettings::TargetedSettings.from_file(source_file_path, only_meta: true).version
33
+ destination_version = ProcessSettings::TargetedSettings.from_file(destination_file_path, only_meta: true).version
34
+
35
+ source_version.to_f > destination_version.to_f
36
+ else
37
+ true
38
+ end
39
+ end
40
+
41
+ def validate_source_version_is_not_older(source_file_path, destination_file_path)
42
+ if File.exist?(destination_file_path)
43
+ source_version = ProcessSettings::TargetedSettings.from_file(source_file_path, only_meta: true).version
44
+ destination_version = ProcessSettings::TargetedSettings.from_file(destination_file_path, only_meta: true).version
45
+
46
+ if source_version.to_f < destination_version.to_f
47
+ raise SourceVersionOlderError,
48
+ "source file '#{source_file_path}' is version #{source_version}"\
49
+ " and destination file '#{destination_file_path}' is version #{destination_version}"
33
50
  end
34
51
  end
35
52
  end
@@ -21,9 +21,5 @@ module ProcessSettings
21
21
  def eql?(rhs)
22
22
  self == rhs
23
23
  end
24
-
25
- def [](key)
26
- @json_doc[key]
27
- end
28
24
  end
29
25
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProcessSettings
4
- VERSION = '0.4.0.1'
4
+ VERSION = '0.4.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: process_settings
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0.1
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Invoca