consulkit 0.1.0

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