consul-client 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c1729a5c12181f89e6921f85d96d2df356466a7d
4
+ data.tar.gz: b52bd63b9e2befc9b9a4d24422195973577362c5
5
+ SHA512:
6
+ metadata.gz: 0aa261a9c0055617e26a30af166c99b07da4cef43491f1057a4ef597305ef02041af5f7e073f601b3b4f635df9c5011750496cf7a5bc7598d0b83bb55fd2eec1
7
+ data.tar.gz: 8201a743e89f3e39a475f41ee1295611b8e6502ce0460c37b939e241c57856783cb09ecc260ee02661274602f27fc3dc2c6e23532f6cc7c7dfdd570906a67240
@@ -0,0 +1,29 @@
1
+ Consul Client
2
+ =============
3
+
4
+ Ruby client library for Consul HTTP API, providing both a thin wrapper around
5
+ the raw API and higher level behaviours for operating in a Consul environment.
6
+
7
+ _This library is experimental! Be sure to thoroughly test and code review
8
+ before using for anything real._
9
+
10
+ Usage
11
+ -----
12
+
13
+ Simple API usage:
14
+
15
+ ```ruby
16
+ require 'consul/client'
17
+
18
+ client = Consul::Client.v1.http
19
+ client.get("/agent/self")
20
+ ```
21
+
22
+ See `example` directory for more:
23
+
24
+ * `puts_service.rb` is a minimum server that demostrates coordinated shutdown.
25
+ * `http_service.rb` builds on top of webrick for an auto-updating server with
26
+ coordinated restart.
27
+
28
+ A `Vagrantfile` is provided that makes three
29
+ Consul nodes, which is handy for playing around.
@@ -0,0 +1,27 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.authors = ["Xavier Shay"]
5
+ gem.email = ["contact@xaviershay.com"]
6
+ gem.description =
7
+ %q{Ruby client library for Consul HTTP API.}
8
+ gem.summary = %q{
9
+ Ruby client library for Consul HTTP API, providing both a thin wrapper
10
+ around the raw API and higher level behaviours for operating in a Consul
11
+ environment.
12
+ }
13
+ gem.homepage = "http://github.com/xaviershay/consul-client"
14
+
15
+ gem.executables = []
16
+ gem.required_ruby_version = '>= 2.1.2'
17
+ gem.files = Dir.glob("{spec,lib}/**/*.rb") + %w(
18
+ README.md
19
+ consul-client.gemspec
20
+ )
21
+ gem.test_files = Dir.glob("spec/**/*.rb")
22
+ gem.name = "consul-client"
23
+ gem.require_paths = ["lib"]
24
+ gem.license = "Apache 2.0"
25
+ gem.version = '0.1.0'
26
+ gem.has_rdoc = false
27
+ end
@@ -0,0 +1,61 @@
1
+ require 'consul/client/local_service'
2
+ require 'consul/client/service'
3
+ require 'consul/client/http'
4
+
5
+ require 'logger'
6
+
7
+ # Top-level Consul name space.
8
+ module Consul
9
+ # Top-level client name space. All public entry points are via this module.
10
+ module Client
11
+ # Default logger that silences all diagnostic output.
12
+ NULL_LOGGER = Logger.new("/dev/null")
13
+
14
+ # Provides builders that support V1 of the Consul HTTP API.
15
+ # @return [Consul::Client::V1]
16
+ def self.v1
17
+ V1.new
18
+ end
19
+
20
+ # Do not instantiate this class directly.
21
+ #
22
+ # @see Consul::Client.v1
23
+ class V1
24
+ # Returns high-level local service utility functions.
25
+ #
26
+ # @param name [String] name of the service. Must match service ID in
27
+ # Consul.
28
+ # @param http [Consul::Client::HTTP] http client to use.
29
+ # @param logger [Logger] logger for diagnostic information.
30
+ # @return [Consul::Client::LocalService]
31
+ # @example
32
+ # local = Consul::Client.v1.local_service('web')
33
+ # local.coordinated_shutdown! { $healthy = false }
34
+ def local_service(name, http: http, logger: http.logger)
35
+ LocalService.new(name, http: http, logger: logger)
36
+ end
37
+
38
+ # Returns high-level service utility functions.
39
+ #
40
+ # @example
41
+ # service = Consul::Client.v1.service('web')
42
+ # service.lock('leader') { puts "I am the cluster leader!" }
43
+ def service(*args)
44
+ Service.new(*args)
45
+ end
46
+
47
+ # Returns a thin wrapper around the Consult HTTP API.
48
+ #
49
+ # @param host [String] host of Consul agent.
50
+ # @param port [Integer] port to connect to Consul agent.
51
+ # @param logger [Logger] logger for diagnostic information.
52
+ # @return [Consul::Client::HTTP]
53
+ # @example
54
+ # http = Consul::Client.v1.http(logger: Logger.new($stdout))
55
+ # puts http.get("/get/self")["Member"]["Name"]
56
+ def http(host: "localhost", port: 8500, logger: NULL_LOGGER)
57
+ HTTP.new(*args)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,125 @@
1
+ require 'net/http'
2
+ require 'json'
3
+ require 'logger'
4
+
5
+ module Consul
6
+ module Client
7
+ # Any non-successful response from Consul will result in this error being
8
+ # thrown.
9
+ ResponseException = Class.new(StandardError)
10
+
11
+ # Low-level wrapper around consul HTTP api. Do not instantiate this class
12
+ # directly, instead use the appropriate factory methods.
13
+ #
14
+ # @see Consul::Client::V1#http
15
+ class HTTP
16
+ # @api private
17
+ def initialize(host:, port:, logger:)
18
+ @host = host
19
+ @port = port
20
+ @logger = logger
21
+ end
22
+
23
+ # Get JSON data from an endpoint.
24
+ #
25
+ # @param request_uri [String] portion of the HTTP path after the version
26
+ # base, such as +/agent/self+.
27
+ # @return [Object] parsed JSON response
28
+ # @raise [ResponseException] if non-200 response is received.
29
+ def get(request_uri)
30
+ url = base_uri + request_uri
31
+ logger.debug("GET #{url}")
32
+
33
+ uri = URI.parse(url)
34
+
35
+ response = http_request(:get, uri)
36
+
37
+ parse_body(response)
38
+ end
39
+
40
+ # Watch an endpoint until the value returned causes the block to evaluate
41
+ # +false+.
42
+ #
43
+ # @param request_uri [String] portion of the HTTP path after the version
44
+ # base, such as +/agent/self+.
45
+ # @return [Object] parsed JSON response
46
+ # @raise [ResponseException] if non-200 response is received.
47
+ # @example blocks until there are at least 3 passing nodes for the web service.
48
+ # http.get_while("/health/service/web?passing") do |data|
49
+ # data.size <= 2
50
+ # end
51
+ def get_while(request_uri, &block)
52
+ url = base_uri + request_uri
53
+ index = 0
54
+ json = nil
55
+
56
+ check = ->{
57
+ uri = URI.parse(url)
58
+ uri.query ||= ""
59
+ uri.query += "&index=#{index}&wait=10s"
60
+ logger.debug("GET #{uri}")
61
+
62
+ response = http_request(:get, uri)
63
+ index = response['x-consul-index'].to_i
64
+
65
+ json = parse_body(response)
66
+
67
+ block.(json)
68
+ }
69
+
70
+ while check.()
71
+ end
72
+
73
+ json
74
+ end
75
+
76
+ # Put request to an endpoint. If data is provided, it is JSON encoded and
77
+ # sent in the request body.
78
+ # @param request_uri [String] portion of the HTTP path after the version
79
+ # base, such as +/agent/self+.
80
+ # @param data [Object] body for request
81
+ # @return [Object] parsed JSON response
82
+ # @raise [ResponseException] if non-200 response is received.
83
+ def put(request_uri, data = nil)
84
+ url = base_uri + request_uri
85
+ logger.debug("PUT #{url}")
86
+
87
+ uri = URI.parse(url)
88
+
89
+ response = http_request(:put, uri, data)
90
+
91
+ parse_body(response)
92
+ end
93
+
94
+ attr_accessor :logger
95
+
96
+ protected
97
+
98
+ def base_uri
99
+ "http://#{@host}:#{@port}/v1"
100
+ end
101
+
102
+ def parse_body(response)
103
+ JSON.parse("[#{response.body}]")[0]
104
+ end
105
+
106
+ def http_request(method, uri, data = nil)
107
+ method = {
108
+ get: Net::HTTP::Get,
109
+ put: Net::HTTP::Put,
110
+ }.fetch(method)
111
+
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ request = method.new(uri.request_uri)
114
+ request.body = data.to_json if data
115
+ response = http.request(request)
116
+
117
+ if response.code.to_i >= 400
118
+ raise ResponseException, "#{response.code} on #{uri}"
119
+ end
120
+
121
+ response
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,65 @@
1
+ module Consul
2
+ module Client
3
+ # Provides operations on services running on the local machine. Do not
4
+ # instantiate this class directly, instead use the appropriate factory
5
+ # methods.
6
+ #
7
+ # @see Consul::Client::V1#local_service
8
+ class LocalService
9
+ # @api private
10
+ def initialize(name, http:, logger:)
11
+ @name = name
12
+ @consul = consul
13
+ consul.logger = logger
14
+ end
15
+
16
+ # Coordinate the shutdown of this node with the rest of the cluster so
17
+ # that a minimum number of nodes is always healthy. Blocks until a
18
+ # shutdown lock has been obtained and the cluster is healthy before
19
+ # yielding, in which callers should mark the service unhealthy (but
20
+ # continue to accept traffic). After the unhealthy state of the service
21
+ # has propagated and `grace_period` seconds has passed, this method
22
+ # returns and the caller should stop accepting new connections, finish
23
+ # existing work, then terminate.
24
+ #
25
+ # @param min_nodes [Integer] minimum require nodes for cluster to be
26
+ # considered healthy.
27
+ # @param grace_period [Integer] number of seconds to sleep after service
28
+ # has been marked unhealthy in the cluster. This is important so
29
+ # that any in-flight requests are still able to be handled.
30
+ def coordinated_shutdown!(min_nodes: 1, grace_period: 3, &block)
31
+ cluster = Consul::Client.v1.service(name, consul: consul)
32
+
33
+ cluster.lock("shutdown") do
34
+ cluster.wait_until_healthy!(min_nodes: min_nodes)
35
+ block.()
36
+ wait_until_unhealthy!
37
+
38
+ # Release lock here and perform shutdown in our own time, since we
39
+ # know the consistent view of nodes does not include this one and so
40
+ # is safe for other nodes to try restarting.
41
+ end
42
+
43
+ # Grace period for any in-flight connections on their way already
44
+ # before health check failure propagated.
45
+ #
46
+ # No way to avoid a sleep here.
47
+ Kernel.sleep grace_period
48
+ end
49
+
50
+ # Waits until the propagated health of this node is unhealthy so it is
51
+ # not receiving new traffic.
52
+ def wait_until_unhealthy!
53
+ agent = consul.get("/agent/self")["Member"]["Name"]
54
+ consul.get_while("/health/node/#{agent}") do |data|
55
+ status = data.detect {|x| x["CheckID"] == "service:#{name}" }["Status"]
56
+ status == 'passing'
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :name, :consul
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,68 @@
1
+ module Consul
2
+ module Client
3
+ # Provides cluster coordination features. Do not instantiate this class
4
+ # directly, instead use the appropriate factory methods.
5
+ #
6
+ # @see Consul::Client::V1#service
7
+ class Service
8
+ # @api private
9
+ def initialize(name, consul: Consul::Client.v1)
10
+ @name = name
11
+ @consul = consul
12
+ end
13
+
14
+ # Creates a session tied to this cluster, then blocks indefinitely until
15
+ # the requested lock can be acquired, then yields. The lock is released
16
+ # and the session destroyed when the block completes.
17
+ #
18
+ # @param key [String] the name of the lock to acquire. This is namespace
19
+ # under the service name and stored directly in the KV store,
20
+ # so make sure it does not conflict with other names. For
21
+ # instance, the leader lock for the +web+ service would be
22
+ # stored at +/kv/web/leader+.
23
+ def lock(key, &block)
24
+ session = consul.put("/session/create",
25
+ LockDelay: '5s',
26
+ Checks: ["service:#{name}", "serfHealth"]
27
+ )["ID"]
28
+ loop do
29
+ locked = consul.put("/kv/#{name}/#{key}?acquire=#{session}")
30
+
31
+ if locked
32
+ begin
33
+ block.call
34
+ ensure
35
+ consul.put("/kv/#{name}/#{key}?release=#{session}")
36
+ consul.put("/session/destroy/#{session}")
37
+ end
38
+ return
39
+ else
40
+ consul.get_while("/kv/#{name}/#{key}") do |body|
41
+ body[0]["Session"]
42
+ end
43
+ end
44
+ # TODO: Figure out why long poll doesn't work.
45
+ # https://gist.github.com/xaviershay/30128b968bde0e2d3e0b/edit
46
+ sleep 2
47
+ end
48
+ end
49
+
50
+ # Block indefinitely until the cluster is healthy.
51
+ #
52
+ # @param min_nodes [Integer] minimum number of nodes required to be
53
+ # healthy for the cluster as a whole to be considered healthy.
54
+ # This method will block until there is at least one more than
55
+ # this number, assuming that the caller is about to terminate.
56
+ def wait_until_healthy!(min_nodes: 1)
57
+ consul.get_while("/health/service/#{name}?passing") do |data|
58
+ data.size <= min_nodes
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :name
65
+ attr_reader :consul
66
+ end
67
+ end
68
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: consul-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Xavier Shay
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-08-17 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Ruby client library for Consul HTTP API.
14
+ email:
15
+ - contact@xaviershay.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - consul-client.gemspec
22
+ - lib/consul/client.rb
23
+ - lib/consul/client/http.rb
24
+ - lib/consul/client/local_service.rb
25
+ - lib/consul/client/service.rb
26
+ homepage: http://github.com/xaviershay/consul-client
27
+ licenses:
28
+ - Apache 2.0
29
+ metadata: {}
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 2.1.2
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubyforge_project:
46
+ rubygems_version: 2.2.2
47
+ signing_key:
48
+ specification_version: 4
49
+ summary: Ruby client library for Consul HTTP API, providing both a thin wrapper around
50
+ the raw API and higher level behaviours for operating in a Consul environment.
51
+ test_files: []
52
+ has_rdoc: false