process_settings 0.4.0.1 → 0.4.1

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