consulkit 0.1.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
+ SHA256:
3
+ metadata.gz: ed420f460a02053a0cdb911554388b96d9400118b3fc9fdad1a2534a3dbf9712
4
+ data.tar.gz: 3db9bb6b418452fa9501a9c68aa4e47b39d4007a18a0f08612c387841189316d
5
+ SHA512:
6
+ metadata.gz: ed6a2dbf2ba8ada0961d3c7491c8501478f50742910f7f588c0adc0291898fa96f47ff7ec66d2d699b1e3d3fe47b1c9914fe2be08674da6b8ae84fba686ce075
7
+ data.tar.gz: 909cb251f11788907a6988441a4dd95c86678ccd7a53a46ce85e4946847e92535d257a469661b92408396d97adef3a2b6cd67c2b4c111caa898ff977949efe08
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,27 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+
4
+ Metrics/MethodLength:
5
+ Max: 15
6
+
7
+ Layout/LineLength:
8
+ Max: 120
9
+
10
+ Metrics/BlockLength:
11
+ AllowedMethods: ['describe']
12
+
13
+ Layout/EmptyLinesAroundClassBody:
14
+ EnforcedStyle: empty_lines_except_namespace
15
+
16
+ Layout/EmptyLinesAroundModuleBody:
17
+ EnforcedStyle: empty_lines_except_namespace
18
+
19
+ Layout/HashAlignment:
20
+ EnforcedHashRocketStyle: table
21
+ EnforcedColonStyle: table
22
+
23
+ Style/TrailingCommaInHashLiteral:
24
+ EnforcedStyleForMultiline: consistent_comma
25
+
26
+ Style/TrailingCommaInArrayLiteral:
27
+ EnforcedStyleForMultiline: consistent_comma
data/Gemfile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'bundler', '~> 2.3'
8
+ gem 'rake', '~> 13.0'
9
+ gem 'rubocop', '~> 1.21'
10
+
11
+ group :test do
12
+ gem 'rspec', '~> 3.0'
13
+ gem 'vcr'
14
+ gem 'webmock'
15
+ end
16
+
17
+ group :development do
18
+ gem 'yard'
19
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,88 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ consulkit (0.1.0)
5
+ faraday (~> 2.7)
6
+ faraday-retry (~> 2.2)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.8.4)
12
+ public_suffix (>= 2.0.2, < 6.0)
13
+ ast (2.4.2)
14
+ crack (0.4.5)
15
+ rexml
16
+ diff-lcs (1.5.0)
17
+ faraday (2.7.8)
18
+ faraday-net_http (>= 2.0, < 3.1)
19
+ ruby2_keywords (>= 0.0.4)
20
+ faraday-net_http (3.0.2)
21
+ faraday-retry (2.2.0)
22
+ faraday (~> 2.0)
23
+ hashdiff (1.0.1)
24
+ json (2.6.3)
25
+ language_server-protocol (3.17.0.3)
26
+ parallel (1.23.0)
27
+ parser (3.2.2.3)
28
+ ast (~> 2.4.1)
29
+ racc
30
+ public_suffix (5.0.1)
31
+ racc (1.7.1)
32
+ rainbow (3.1.1)
33
+ rake (13.0.6)
34
+ regexp_parser (2.8.1)
35
+ rexml (3.2.5)
36
+ rspec (3.12.0)
37
+ rspec-core (~> 3.12.0)
38
+ rspec-expectations (~> 3.12.0)
39
+ rspec-mocks (~> 3.12.0)
40
+ rspec-core (3.12.2)
41
+ rspec-support (~> 3.12.0)
42
+ rspec-expectations (3.12.3)
43
+ diff-lcs (>= 1.2.0, < 2.0)
44
+ rspec-support (~> 3.12.0)
45
+ rspec-mocks (3.12.5)
46
+ diff-lcs (>= 1.2.0, < 2.0)
47
+ rspec-support (~> 3.12.0)
48
+ rspec-support (3.12.1)
49
+ rubocop (1.53.1)
50
+ json (~> 2.3)
51
+ language_server-protocol (>= 3.17.0)
52
+ parallel (~> 1.10)
53
+ parser (>= 3.2.2.3)
54
+ rainbow (>= 2.2.2, < 4.0)
55
+ regexp_parser (>= 1.8, < 3.0)
56
+ rexml (>= 3.2.5, < 4.0)
57
+ rubocop-ast (>= 1.28.0, < 2.0)
58
+ ruby-progressbar (~> 1.7)
59
+ unicode-display_width (>= 2.4.0, < 3.0)
60
+ rubocop-ast (1.29.0)
61
+ parser (>= 3.2.1.0)
62
+ ruby-progressbar (1.13.0)
63
+ ruby2_keywords (0.0.5)
64
+ unicode-display_width (2.4.2)
65
+ vcr (6.2.0)
66
+ webmock (3.18.1)
67
+ addressable (>= 2.8.0)
68
+ crack (>= 0.3.2)
69
+ hashdiff (>= 0.4.0, < 2.0.0)
70
+ yard (0.9.34)
71
+
72
+ PLATFORMS
73
+ arm64-darwin-22
74
+ universal-darwin-22
75
+ x86_64-linux
76
+
77
+ DEPENDENCIES
78
+ bundler (~> 2.3)
79
+ consulkit!
80
+ rake (~> 13.0)
81
+ rspec (~> 3.0)
82
+ rubocop (~> 1.21)
83
+ vcr
84
+ webmock
85
+ yard
86
+
87
+ BUNDLED WITH
88
+ 2.4.13
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Etsy
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # consulkit
2
+
3
+ Ruby API for interacting with HashiCorp's [Consul](https://www.consul.io/), heavily inspired by GitHub's [Octokit](https://github.com/octokit/octokit.rb).
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ gem "consulkit", :git => "git://github.com/ericnorris/consulkit.git"
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Get a client using the default options:
14
+
15
+ ```ruby
16
+ client = Consulkit::Client.new
17
+ ```
18
+
19
+ ...or provide customized options:
20
+
21
+ ```ruby
22
+ client = Consulkit.Client.new(http_addr: "https://consul.example.com", http_token: "token")
23
+ ```
24
+
25
+ ### KV Store
26
+
27
+ Reading keys:
28
+
29
+ ```ruby
30
+ # Read a single key
31
+ client.kv_read_single("foo")
32
+ # => {"LockIndex"=>0, "Key"=>"foo", "Flags"=>1234, "Value"=>"bar", "CreateIndex"=>3532, "ModifyIndex"=>3914}
33
+
34
+ # Read key recursively
35
+ client.kv_read_recursive("foo")
36
+ # => [{"LockIndex"=>0, "Key"=>"foo", "Flags"=>1234, "Value"=>"bar", "CreateIndex"=>3532, "ModifyIndex"=>3914}, ...]
37
+
38
+ # Specify your own query parameters
39
+ client.kv_read("foo", raw: true)
40
+ # => "bar"
41
+ ```
42
+
43
+ Writing keys:
44
+
45
+ ```ruby
46
+ # Write a key
47
+ client.kv_write("foo", "bar", flags: 1234)
48
+ # => true
49
+
50
+ # Write a key if it doesn't exist
51
+ client.kv_write_cas("foo", "bar", 0)
52
+ => false
53
+
54
+ > client.kv_write_cas("bar", "baz", 0)
55
+ => true
56
+ ```
57
+
58
+ ## Development
59
+
60
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
13
+
14
+ namespace :doc do
15
+ require 'yard'
16
+
17
+ YARD::Rake::YardocTask.new do |task|
18
+ task.files = ['README.md', 'LICENSE.md', 'lib/**/*.rb']
19
+ task.options = [
20
+ '--output-dir', 'doc/yard',
21
+ '--markup', 'markdown',
22
+ ]
23
+ end
24
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Consulkit
6
+ class Client
7
+ # Methods for accessing Consul's KV store.
8
+ module KV
9
+
10
+ # Reads the specified key, raising an exception if not found. The value of the keys in the response are
11
+ # automatically base64 decoded, if applicable.
12
+ #
13
+ # @see https://developer.hashicorp.com/consul/api-docs/kv#read-key
14
+ #
15
+ # @param key [String] the key to read.
16
+ # @option query_params [Hash] optional query parameters.
17
+ # @option query_params [Boolean] :recurse
18
+ # @option query_params [Boolean] :raw
19
+ # @option query_params [Boolean] :keys
20
+ # @option query_params [String] :separator
21
+ #
22
+ # @raise [Consulkit::Error::NotFound] if the key does not exist.
23
+ #
24
+ # @yield [Faraday::Response] The response from the underlying Faraday library.
25
+ #
26
+ # @return [Array<Hash>]
27
+ def kv_read!(key, query_params = {})
28
+ response = get("/v1/kv/#{key}", query_params)
29
+
30
+ if block_given?
31
+ yield response
32
+ else
33
+ kv_decode_response_body(response.body)
34
+ end
35
+ end
36
+
37
+ # Reads the specified key. The value of the keys in the response are automatically base64 decoded, if applicable.
38
+ #
39
+ # @see https://developer.hashicorp.com/consul/api-docs/kv#read-key
40
+ #
41
+ # @param key [String] the key to read.
42
+ # @option query_params [Hash] optional query parameters.
43
+ # @option query_params [Boolean] :recurse
44
+ # @option query_params [Boolean] :raw
45
+ # @option query_params [Boolean] :keys
46
+ # @option query_params [String] :separator
47
+ #
48
+ # @yield [Faraday::Response] The response from the underlying Faraday library.
49
+ #
50
+ # @return [Array<Hash>]
51
+ def kv_read(key, query_params = {}, &block)
52
+ kv_read!(key, query_params, &block)
53
+ rescue Consulkit::Error::NotFound
54
+ []
55
+ end
56
+
57
+ # Reads the specified key, expecting a single KV entry. The value of the key is automatically base64 decoded.
58
+ #
59
+ # @param key [String] the key to read.
60
+ #
61
+ # @return [Hash, nil]
62
+ def kv_read_single(key)
63
+ kv_read!(key).first
64
+ rescue Consulkit::Error::NotFound
65
+ nil
66
+ end
67
+
68
+ # Reads the specified key, and returns the X-Consul-Index value. The value of the keys in the response are
69
+ # automatically base64 decoded, if applicable.
70
+ #
71
+ # @see kv_read
72
+ #
73
+ # @param key [String] the key to read.
74
+ # @param query_params [Hash] optional query parameters.
75
+ #
76
+ # @return [Array<(Integer, Array<Hash>)>]
77
+ def kv_read_with_index(key, query_params = {})
78
+ kv_read!(key, **query_params) do |response|
79
+ [response.headers['X-Consul-Index'], kv_decode_response_body(response.body)]
80
+ end
81
+ rescue Consulkit::Error::NotFound => e
82
+ [e.response_headers['X-Consul-Index'], []]
83
+ end
84
+
85
+ # Reads the specified key, then yields the result of a blocking query for that key. The value of the keys in the
86
+ # response are automatically base64 decoded, if applicable. Return false from the block to end the loop.
87
+ #
88
+ # @see kv_read
89
+ #
90
+ # @param key [String] the key to read.
91
+ # @param query_params [Hash] optional query parameters.
92
+ #
93
+ # @yield [changed, result]
94
+ # @yieldparam changed [Boolean] true if the consul index has changed since the last query.
95
+ # @yieldparam result [Array<Hash>] the current result of the consul query.
96
+ def kv_read_blocking(key, wait = nil, query_params = {})
97
+ last_index, = kv_read_with_index(key, query_params)
98
+
99
+ loop do
100
+ new_index, result = kv_read_with_index(key, **query_params, wait: wait, index: last_index)
101
+
102
+ return unless yield(new_index != last_index, result)
103
+ end
104
+ end
105
+
106
+ # Reads the specified key recursively. The value of the keys in the response are automatically base64 decoded, if
107
+ # applicable.
108
+ #
109
+ # @see https://developer.hashicorp.com/consul/api-docs/kv#read-key
110
+ #
111
+ # @param key [String] the key to read.
112
+ # @param query_params [Hash] optional query parameters.
113
+ #
114
+ # @return [<Hash>]
115
+ def kv_read_recursive(key, query_params = {})
116
+ kv_read(key, **query_params, recurse: true)
117
+ end
118
+
119
+ # Reads the specified key recursively, returning a hash where each key is indexed by its key name. The value of
120
+ # the keys in the response are automatically base64 decoded, if applicable.
121
+ #
122
+ # @param key [String] the key to read.
123
+ #
124
+ # @return [Hash<String, Hash>]
125
+ def kv_read_recursive_as_hash(key)
126
+ kv_read_recursive(key).to_h do |entry|
127
+ [entry['Key'], entry]
128
+ end
129
+ end
130
+
131
+ # Creates or updates the specified key.
132
+ #
133
+ # @see https://developer.hashicorp.com/consul/api-docs/kv#create-update-key
134
+ # @see kv_write_cas
135
+ # @see kv_acquire_lock
136
+ # @see kv_release_lock
137
+ #
138
+ # @param key [String] the key to create or update.
139
+ # @option query_params [Hash] optional query parameters.
140
+ # @option query_params [Integer] :flags
141
+ # @option query_params [Integer] :cas
142
+ # @option query_params [String] :acquire
143
+ # @option query_params [String] :release
144
+ #
145
+ # @yield [Faraday::Response] The response from the underlying Faraday library.
146
+ #
147
+ # @return [Boolean]
148
+ def kv_write(key, value, query_params = {})
149
+ response = put("/v1/kv/#{key}", value, query_params)
150
+
151
+ if block_given?
152
+ yield response
153
+ else
154
+ response.body == true
155
+ end
156
+ end
157
+
158
+ # Atomically creates or updates the specified key, if and only if the modify index matches.
159
+ #
160
+ # @param key [String] the key to create or update.
161
+ # @param query_params [Hash] optional query parameters.
162
+ #
163
+ # return [Boolean]
164
+ def kv_write_cas(key, value, modify_index, query_params = {})
165
+ kv_write(key, value, **query_params, cas: modify_index)
166
+ end
167
+
168
+ # Atomically creates or updates the specified key by acquiring a lock with the session ID.
169
+ #
170
+ # @param key [String] the key to create or update.
171
+ # @param query_params [Hash] optional query parameters.
172
+ #
173
+ # return [Boolean]
174
+ def kv_acquire_lock(key, session_id, value = nil, query_params = {})
175
+ kv_write(key, value, **query_params, acquire: session_id)
176
+ end
177
+
178
+ # Atomically updates the specified key while releasing the associated lock.
179
+ #
180
+ # @param key [String] the key to update.
181
+ # @param query_params [Hash] optional query parameters.
182
+ #
183
+ # return [Boolean]
184
+ def kv_release_lock(key, session_id, value = nil, query_params = {})
185
+ kv_write(key, value, **query_params, release: session_id)
186
+ end
187
+
188
+ # Deletes the specified key.
189
+ #
190
+ # @param key [String] the key to delete.
191
+ # @param query_params [Hash] optional query parameters.
192
+ #
193
+ # return [Boolean]
194
+ def kv_delete(key, query_params = {})
195
+ delete("/v1/kv/#{key}", query_params).body == true
196
+ end
197
+
198
+ # Automatically base64 decodes the 'Value' of each KV entry in the body, if the body looks like a Consul KV entry
199
+ # list response.
200
+ #
201
+ # @param body [Object] the response body to decode.
202
+ #
203
+ # @return [Object, Array<Hash>] the body parameter, but with 'Value' base64 decoded, or the body parameter
204
+ # as-is.
205
+ def kv_decode_response_body(body)
206
+ return body unless body.is_a?(Array)
207
+ return body unless body.first.is_a?(Hash) && (body.first.key? 'Value')
208
+
209
+ body.each do |kv_entry|
210
+ next if kv_entry['Value'].nil?
211
+
212
+ kv_entry['Value'] = Base64.decode64(kv_entry['Value'])
213
+ end
214
+ end
215
+
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consulkit
4
+ class Client
5
+ # Methods for creating, deleting, and querying sessions.
6
+ module Session
7
+
8
+ # Creates a session.
9
+ #
10
+ # @see https://developer.hashicorp.com/consul/api-docs/session#create-session
11
+ #
12
+ # @example Create a session with a 60 second TTL and the 'delete' behavior.
13
+ # session_create(ttl: "60s", behavior: 'delete')
14
+ #
15
+ # @param session_opts [Hash] options to create the session with.
16
+ # @option session_opts [String] :lock_delay
17
+ # @option session_opts [String] :node
18
+ # @option session_opts [String] :name
19
+ # @option session_opts [Array<String>] :node_checks
20
+ # @option session_opts [Array<Hash>] :service_checks
21
+ # @option session_opts [String] :behavior
22
+ # @option session_opts [String] :ttl
23
+ #
24
+ # @return String the ID of the created session.
25
+ def session_create(session_opts = {})
26
+ put('/v1/session/create', camel_case_keys(session_opts)).body['ID']
27
+ end
28
+
29
+ # Deletes a session.
30
+ #
31
+ # @param session_id [String] the ID of the session to delete.
32
+ #
33
+ # @return [Boolean]
34
+ def session_delete(session_id)
35
+ delete("/v1/session/destroy/#{session_id}").body == true
36
+ end
37
+
38
+ # Reads a session.
39
+ #
40
+ # @param session_id [String] the ID of the session to read.
41
+ #
42
+ # @return [Boolean]
43
+ def session_read(session_id)
44
+ get("/v1/session/info/#{session_id}").body
45
+ end
46
+
47
+ # Renews a session.
48
+ #
49
+ # @param session_id [String] the ID of the session to renew.
50
+ #
51
+ # @return [Hash]
52
+ def session_renew(session_id)
53
+ put("/v1/session/renew/#{session_id}").body
54
+ end
55
+
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'consulkit/configurable'
4
+ require 'consulkit/client/kv'
5
+ require 'consulkit/client/session'
6
+
7
+ module Consulkit
8
+ # Client for the Consul API.
9
+ class Client
10
+
11
+ include Consulkit::Configurable
12
+ include Consulkit::Client::KV
13
+ include Consulkit::Client::Session
14
+
15
+ def initialize(options = {})
16
+ CONFIGURABLE_KEYS.each do |key|
17
+ value = options[key].nil? ? Consulkit.instance_variable_get("@#{key}") : options[key]
18
+
19
+ instance_variable_set("@#{key}", value)
20
+ end
21
+ end
22
+
23
+ def delete(url, query_params = {})
24
+ request(:delete, url, query_params, nil)
25
+ end
26
+
27
+ def get(url, query_params = {})
28
+ request(:get, url, query_params, nil)
29
+ end
30
+
31
+ def post(url, body = nil, query_params = {})
32
+ request(:post, url, query_params, body)
33
+ end
34
+
35
+ def put(url, body = nil, query_params = {})
36
+ request(:put, url, query_params, body)
37
+ end
38
+
39
+ def request(method, url, query_params, body)
40
+ http.run_request(method, url, body, nil) do |request|
41
+ request.params.update(query_params) if query_params
42
+ end
43
+ end
44
+
45
+ def http
46
+ opts = @connection_options
47
+
48
+ opts['builder'] = @middleware.dup if middleware
49
+
50
+ opts['request'] ||= {}
51
+ opts['request']['params_encoder'] = Faraday::FlatParamsEncoder
52
+
53
+ opts['headers'] ||= {}
54
+ opts['headers']['Authorization'] = "Bearer #{http_token}" if @http_token
55
+
56
+ @http ||= Faraday.new(http_addr, opts)
57
+ end
58
+
59
+ def camel_case_keys(hash)
60
+ hash.transform_keys { |k| k.to_s.split('_').collect(&:capitalize).join }
61
+ end
62
+
63
+ end
64
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consulkit
4
+ # Configuration options for the {Consulkit} singleton and individual {Client} instances.
5
+ module Configurable
6
+
7
+ # [String] the HTTP(s) address to use to connect to Consul
8
+ attr_accessor :http_addr
9
+
10
+ # [String] the ACL token used for authentication
11
+ attr_writer :http_token
12
+
13
+ # [Hash] Faraday connection options
14
+ attr_accessor :connection_options
15
+
16
+ # [Faraday::RackBuilder] middleware for Faraday
17
+ attr_accessor :middleware
18
+
19
+ CONFIGURABLE_KEYS = %i[
20
+ connection_options
21
+ http_addr
22
+ http_token
23
+ middleware
24
+ ].freeze
25
+
26
+ def setup!
27
+ CONFIGURABLE_KEYS.each do |key|
28
+ instance_variable_set(:"@#{key}", Consulkit::Defaults.send(key))
29
+ end
30
+
31
+ self
32
+ end
33
+
34
+ def options
35
+ CONFIGURABLE_KEYS.to_h { |key| [key, instance_variable_get("@#{key}")] }
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+ require 'consulkit/middleware/raise_error'
6
+
7
+ module Consulkit
8
+ # Default configuration options for the {Consulkit} singleton and individual {Client} instances.
9
+ module Defaults
10
+
11
+ HTTP_ADDR = 'http://localhost:8500'
12
+
13
+ HTTP_TOKEN = nil
14
+
15
+ MIDDLEWARE = Faraday::RackBuilder.new do |builder|
16
+ retry_exceptions = Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Consulkit::Error::Server]
17
+
18
+ builder.use(Faraday::Request::Json)
19
+ builder.use(Faraday::Response::Json)
20
+ builder.use(Faraday::Retry::Middleware, exceptions: retry_exceptions)
21
+ builder.use(Consulkit::Middleware::RaiseError)
22
+
23
+ builder.adapter Faraday.default_adapter
24
+ end
25
+
26
+ class << self
27
+
28
+ def connection_options
29
+ {}
30
+ end
31
+
32
+ def http_addr
33
+ ENV.fetch('CONSUL_HTTP_ADDR', HTTP_ADDR)
34
+ end
35
+
36
+ def http_token
37
+ ENV.fetch('CONSUL_HTTP_TOKEN', HTTP_TOKEN)
38
+ end
39
+
40
+ def middleware
41
+ MIDDLEWARE
42
+ end
43
+
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consulkit
4
+ # An error returned from the Consul API.
5
+ class Error < StandardError
6
+
7
+ def initialize(response)
8
+ @response = response
9
+
10
+ super(error_message)
11
+ end
12
+
13
+ # Returns the appropriate Consulkit::Error subclass based on the status and
14
+ # response message, or nil if the response is not an error.
15
+ #
16
+ # @param [Hash] response the HTTP response
17
+ #
18
+ # @return Consulkit::Error
19
+ def self.from_response(response)
20
+ return unless (error_class = error_class_for(response))
21
+
22
+ error_class.new(response)
23
+ end
24
+
25
+ def response_status
26
+ @response.status
27
+ end
28
+
29
+ def response_headers
30
+ @response.response_headers
31
+ end
32
+
33
+ def response_body
34
+ @response.response_body
35
+ end
36
+
37
+ private
38
+
39
+ def error_message
40
+ @response.instance_eval do
41
+ "#{method.to_s.upcase} #{url}: #{status} #{reason_phrase} - #{body}"
42
+ end
43
+ end
44
+
45
+ class Client < Error; end
46
+
47
+ class BadRequest < Client; end
48
+
49
+ class Forbidden < Client; end
50
+
51
+ class ACLNotFound < Forbidden; end
52
+
53
+ class NotFound < Client; end
54
+
55
+ class Server < Error; end
56
+
57
+ # @private
58
+ private_class_method def self.error_class_for(response)
59
+ status = response[:status].to_i
60
+ body = response[:body].to_s
61
+
62
+ case status
63
+ when 400 then BadRequest
64
+ when 403 then error_class_for_http403(body)
65
+ when 404 then NotFound
66
+
67
+ when 400..499 then Client
68
+ when 500..599 then Server
69
+ end
70
+ end
71
+
72
+ # @private
73
+ private_class_method def self.error_class_for_http403(body)
74
+ case body
75
+ when /acl not found/i then ACLNotFound
76
+ else Forbidden
77
+ end
78
+ end
79
+
80
+ end
81
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'consulkit/error'
4
+
5
+ module Consulkit
6
+ module Middleware
7
+ # This class raises a Consulkit-flavored exception based on the HTTP status codes returned by
8
+ # the API.
9
+ class RaiseError < Faraday::Middleware
10
+
11
+ def on_complete(response)
12
+ return unless (error = Consulkit::Error.from_response(response))
13
+
14
+ raise error
15
+ end
16
+
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consulkit
4
+ # Coordinates the usage of a consul key as distributed semaphore, following the algorithm described by Hashicorp
5
+ # [here](https://developer.hashicorp.com/consul/tutorials/developer-configuration/distributed-semaphore).
6
+ class SemaphoreCoordinator
7
+
8
+ # Initializes the coordinater using the given Consulkit client and key prefix.
9
+ #
10
+ # @param client [Consulkit::Client] the client to use.
11
+ # @param key_prefix [String] the key_prefix to use.
12
+ # @param logger [Logger] if non-nil, will log during semaphore operations.
13
+ def initialize(client, key_prefix, logger = nil)
14
+ @client = client
15
+ @key_prefix = key_prefix
16
+ @logger = logger || Logger.new(IO::NULL)
17
+ end
18
+
19
+ # Continually attempts to acquire the semaphore until successful using exponential backoff.
20
+ #
21
+ # @see Consulkit::Client.session_create
22
+ #
23
+ # @param session_id [String] the ID of a consul session to associate with the semaphore.
24
+ # @param limit [Integer] the maximum number of holders for the semaphore.
25
+ # @param backoff_cap [Float] the maximum interval to wait between attempts.
26
+ # @param timeout [Float] the maximum number of seconds to sleep before giving up; nil means try forever.
27
+ #
28
+ # @return [Boolean]
29
+ def acquire(session_id, limit, backoff_cap: 10.0, timeout: nil)
30
+ exponential_backoff(backoff_cap, timeout) do
31
+ @logger.info(%(calling try_acquire("#{session_id}", #{limit})))
32
+
33
+ try_acquire(session_id, limit)
34
+ end
35
+ end
36
+
37
+ # Continually attempts to release the semaphore until successful using exponential backoff.
38
+ #
39
+ # @param session_id [String] the ID of a consul session to associate with the semaphore.
40
+ # @param backoff_cap [Float] the maximum interval to wait between attempts.
41
+ # @param timeout [Float] the maximum number of seconds to sleep before giving up; nil means try forever.
42
+ #
43
+ # @return [Boolean]
44
+ def release(session_id, backoff_cap: 10.0, timeout: nil)
45
+ exponential_backoff(backoff_cap, timeout) do
46
+ @logger.info(%(calling try_release("#{session_id}")))
47
+
48
+ try_release(session_id)
49
+ end
50
+ end
51
+
52
+ # Attempts to acquire the semaphore, allowing up to the given limit of holders.
53
+ #
54
+ # @see Consulkit::Client.session_create
55
+ #
56
+ # @param session_id [String] the ID of a consul session to associate with the semaphore.
57
+ # @param limit [Integer] the maximum number of holders for the semaphore.
58
+ #
59
+ # @return [Boolean]
60
+ def try_acquire(session_id, limit)
61
+ raise ArgumentError, 'semaphore limit must be at least 1' if limit < 1
62
+
63
+ read!
64
+
65
+ return true if @holders.include? session_id
66
+
67
+ unless @contenders.include? session_id
68
+ return false unless @client.kv_acquire_lock("#{@key_prefix}/#{session_id}", session_id)
69
+
70
+ @contenders << session_id
71
+ end
72
+
73
+ return false if @holders.size >= limit
74
+
75
+ @logger.info("semaphore has less than #{limit} holders, attempting to grab")
76
+
77
+ write_coordination_key(@holders.dup.add(session_id))
78
+ end
79
+
80
+ # Attempts to release the semaphore.
81
+ #
82
+ # @param session_id [String] the ID of a consul session to associate with the semaphore.
83
+ #
84
+ # @return [Boolean]
85
+ def try_release(session_id)
86
+ read!
87
+
88
+ return true unless @holders.include? session_id
89
+
90
+ if @contenders.include? session_id
91
+ @client.kv_delete("#{@key_prefix}/#{session_id}")
92
+ @contenders.delete(session_id)
93
+ end
94
+
95
+ write_coordination_key(@holders.dup.delete(session_id))
96
+ end
97
+
98
+ private
99
+
100
+ # Executes a block using [exponential backoff with jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/).
101
+ #
102
+ # @param backoff_cap [Float] the maximum interval to wait between attempts.
103
+ # @param timeout [Float] the maximum number of seconds to sleep before giving up; nil means try forever.
104
+ #
105
+ # @yieldreturn [Boolean]
106
+ #
107
+ # @return [Boolean]
108
+ def exponential_backoff(backoff_cap, timeout)
109
+ raise ArgumentError, 'backoff_cap must be at least 1.0' if backoff_cap < 1
110
+
111
+ # start with 200ms backoff
112
+ current_backoff = 0.2
113
+ elapsed_time = 0
114
+
115
+ loop do
116
+ return true if yield
117
+
118
+ if !timeout.nil? && elapsed_time > timeout
119
+ @logger.info('timeout exceeded')
120
+
121
+ return false
122
+ end
123
+
124
+ # sleep up to the current_backoff value
125
+ time_to_sleep = rand(0..current_backoff)
126
+
127
+ @logger.info("call failed, will sleep #{time_to_sleep.truncate(3)} seconds until trying again")
128
+
129
+ sleep time_to_sleep
130
+
131
+ elapsed_time += time_to_sleep
132
+ current_backoff = [2 * current_backoff, backoff_cap].min
133
+ end
134
+ end
135
+
136
+ # Reads the semaphore data from the key prefix.
137
+ def read!
138
+ @logger.info("reading semaphore @ key prefix '#{@key_prefix}'")
139
+
140
+ @modify_index = 0
141
+ @contenders = Set[]
142
+ claimed_holders = Set[]
143
+
144
+ @client.kv_read_recursive(@key_prefix).each do |entry|
145
+ if entry['Key'] == coordination_key_name
146
+ @modify_index = entry['ModifyIndex']
147
+ claimed_holders = parse_holders(entry['Value'])
148
+ elsif entry['Session']
149
+ @contenders << entry['Session']
150
+ end
151
+ end
152
+
153
+ @holders = claimed_holders & @contenders
154
+ end
155
+
156
+ # Returns the expected name of the coordination key.
157
+ def coordination_key_name
158
+ "#{@key_prefix}/.lock"
159
+ end
160
+
161
+ # Parses the list of claimed semaphore holders.
162
+ def parse_holders(value)
163
+ Set.new(JSON.parse(value))
164
+ rescue JSON::ParserError, ArgumentError => e
165
+ @logger.warn("found invalid JSON or a non-array `holders` value: #{e}")
166
+
167
+ Set[]
168
+ end
169
+
170
+ # Attempts to write the coordination key with the optional new list of holders.
171
+ #
172
+ # @param new_holders [Set<String>] an optional new list of holders.
173
+ #
174
+ # @return [Boolean]
175
+ def write_coordination_key(new_holders = @holders)
176
+ if @client.kv_write_cas(coordination_key_name, JSON.generate(new_holders.to_a), @modify_index)
177
+ @holders = new_holders
178
+
179
+ return true
180
+ end
181
+
182
+ false
183
+ end
184
+
185
+ end
186
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consulkit
4
+
5
+ VERSION = '0.1.0'
6
+
7
+ end
data/lib/consulkit.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'consulkit/client'
4
+ require 'consulkit/configurable'
5
+ require 'consulkit/defaults'
6
+ require 'consulkit/semaphore_coordinator'
7
+ require 'consulkit/version'
8
+
9
+ # Ruby toolkit for the Consul API.
10
+ #
11
+ # @see https://developer.hashicorp.com/consul/api-docs
12
+ module Consulkit
13
+
14
+ class << self
15
+
16
+ include Consulkit::Configurable
17
+
18
+ def client
19
+ Consulkit::Client.new(options)
20
+ end
21
+
22
+ def semaphore_coordinator(key_prefix, client: nil, logger: nil)
23
+ Consulkit::SemaphoreCoordinator.new(client || self.client, key_prefix, logger)
24
+ end
25
+
26
+ end
27
+
28
+ end
29
+
30
+ Consulkit.setup!
data/shell.nix ADDED
@@ -0,0 +1,15 @@
1
+ with (import <nixpkgs> {});
2
+
3
+ mkShell {
4
+ name = "consulkit";
5
+
6
+ buildInputs = [
7
+ ruby
8
+ bundler
9
+ rubocop
10
+ ];
11
+
12
+ shellHook = ''
13
+ bin/setup
14
+ '';
15
+ }
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: consulkit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eric Norris
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 1980-01-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.2'
41
+ description:
42
+ email:
43
+ - enorris@etsy.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rspec"
49
+ - ".rubocop.yml"
50
+ - Gemfile
51
+ - Gemfile.lock
52
+ - LICENSE.txt
53
+ - README.md
54
+ - Rakefile
55
+ - lib/consulkit.rb
56
+ - lib/consulkit/client.rb
57
+ - lib/consulkit/client/kv.rb
58
+ - lib/consulkit/client/session.rb
59
+ - lib/consulkit/configurable.rb
60
+ - lib/consulkit/defaults.rb
61
+ - lib/consulkit/error.rb
62
+ - lib/consulkit/middleware/raise_error.rb
63
+ - lib/consulkit/semaphore_coordinator.rb
64
+ - lib/consulkit/version.rb
65
+ - shell.nix
66
+ homepage: https://github.com/etsy/consulkit
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ homepage_uri: https://github.com/etsy/consulkit
71
+ source_code_uri: https://github.com/etsy/consulkit
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 2.6.0
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.4.13
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Ruby toolkit for the Consul API
91
+ test_files: []