consul-templaterb 1.0.3

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,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env ruby
2
+ # This script can be launched to get a uniq id for this instance
3
+ require 'consul/async/consul_template_engine'
4
+ require 'consul/async/process_handler'
5
+ require 'consul/async/version'
6
+ require 'optparse'
7
+ require 'optparse/uri'
8
+
9
+ def usage_text
10
+ "USAGE: #{__FILE__} [[options]]"
11
+ end
12
+
13
+ def compute_default_output(source)
14
+ dest = source.gsub(/\.erb$/, '')
15
+ raise "Source and destination cannot be the same in #{source}" if source == dest || dest.empty?
16
+ dest
17
+ end
18
+
19
+ options = {
20
+ consul: {
21
+ debug: {
22
+ network: false
23
+ },
24
+ base_url: ENV['CONSUL_HTTP_ADDR'] || 'http://locahost:8500',
25
+ token: nil,
26
+ retry_duration: 10, # On error, retry after n seconds
27
+ min_duration: 5, # On sucess and when differences are found
28
+ retry_on_non_diff: 3, # On success but when there are not differences
29
+ wait_duration: 600, # Delay to block in Consul
30
+ max_retry_duration: 600, # On consecutive errors, delay will increase, max value
31
+ missing_index_retry_time_on_diff: 15, # On endpoints without X-Consul-Index => next request
32
+ missing_index_retry_time_on_unchanged: 60, # Endpoints with X-Consul index and no diff
33
+ paths: {
34
+ '/v1/catalog/services': {
35
+ min_duration: 30, # Since services change a lot, refresh services every 30 seconds
36
+ },
37
+ '/v1/catalog/nodes': {
38
+ min_duration: 30, # Do not wake up before 30 seconds when node appear/disappear
39
+ },
40
+ '/v1/catalog/datacenters': {
41
+ min_duration: 60, # Datacenters are not added every minute, right?
42
+ },
43
+ '/v1/agent/metrics': {
44
+ min_duration: 60, # Refresh metrics only minute max
45
+ },
46
+ '/v1/agent/self': {
47
+ min_duration: 60, # Refresh self info every minute max
48
+ }
49
+ }
50
+ }
51
+ }
52
+ consul_engine = Consul::Async::ConsulTemplateEngine.new
53
+ @programs = {}
54
+ cur_sig_reload = 'HUP'.freeze
55
+ cur_sig_term = 'TERM'.freeze
56
+
57
+ optparse = OptionParser.new do |opts|
58
+ opts.banner = usage_text
59
+
60
+ opts.on('-h', '--help', 'Show help') do
61
+ STDERR.puts opts
62
+ exit 0
63
+ end
64
+
65
+ opts.on('-v', '--version', 'Show Version') do
66
+ STDERR.puts Consul::Async::VERSION
67
+ exit 0
68
+ end
69
+
70
+ opts.on('-c', '--consul-addr=<address>', String, 'Address of Consul, eg: http://locahost:8500') do |consul_url|
71
+ options[:consul][:base_url] = consul_url
72
+ end
73
+
74
+ opts.on('-t', '--consul-token=<token>', String, 'Use a token to connect to Consul') do |consul_token|
75
+ options[:consul][:token] = consul_token
76
+ end
77
+
78
+ opts.on('-w', '--wait=<min_duration>', Float, 'Wait at least n seconds before each template generation') do |min_duration|
79
+ options[:consul][:min_duration] = min_duration
80
+ end
81
+
82
+ opts.on('-r', '--retry-delay=<min_duration>', Float, 'Min Retry delay on Error/Missing Consul Index') do |min_duration|
83
+ options[:consul][:min_duration] = min_duration
84
+ end
85
+
86
+ opts.on('-k', '--hot-reload=<behavior>', String, 'Control hot reload behaviour, one of :'\
87
+ '[die (kill daemon on hot reload failure), '\
88
+ 'keep (on error, keep running), '\
89
+ 'disable (hot reload disabled)] ') do |hot_reload_behaviour|
90
+ consul_engine.hot_reload_failure = hot_reload_behaviour == 'die' ? nil : hot_reload_behaviour
91
+ end
92
+
93
+ def compute_signal(val, none_value)
94
+ valid_signals = Signal.list.keys
95
+ raise "Please #{val} specifiy a signal: #{valid_signals.inspect}" unless val
96
+ return nil if val == none_value
97
+ raise "Invalid signal, valid signals: #{valid_signals.inspect}" unless valid_signals.include? val
98
+ val
99
+ end
100
+
101
+ opts.on('-K', '--sig-term=kill_signal', String,
102
+ "Signal to sent to next --exec command on kill, default=#{cur_sig_term}") do |sig|
103
+ cur_sig_term = compute_signal(sig, nil)
104
+ end
105
+
106
+ opts.on('-R', '--sig-reload=reload_signal', String,
107
+ "Signal to sent to next --exec command on reload (NONE supported), default=#{cur_sig_reload}") do |sig|
108
+ cur_sig_reload = compute_signal(sig, 'NONE')
109
+ end
110
+
111
+ opts.on('-e', '--exec=<command>', String, 'Execute the following command') do |cmd|
112
+ sig_reload = cur_sig_reload
113
+ sig_term = cur_sig_term
114
+ consul_engine.add_template_callback do |all_ready, template_manager, results|
115
+ if all_ready
116
+ modified = results.reduce(false) { |a, e| a || (e.ready? && e.modified) }
117
+ if modified
118
+ if @programs[cmd].nil?
119
+ STDERR.puts "[EXEC] Starting process: #{cmd}... on_reload=#{sig_reload ? sig_reload : 'NONE'} on_term=#{sig_term}"
120
+ @programs[cmd] = Consul::Async::ProcessHandler.new(cmd, sig_reload: sig_reload, sig_term: sig_term)
121
+ @programs[cmd].start
122
+ else
123
+ @programs[cmd].reload
124
+ end
125
+ elsif !@programs[cmd].nil?
126
+ begin
127
+ @programs[cmd].process_status
128
+ rescue Consul::Async::ProcessDoesNotExist => e
129
+ STDERR.puts "[ERROR] The process is dead, aborting run: #{e.inspect}"
130
+ template_manager.terminate
131
+ EventMachine.stop
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ opts.on('-d', '--debug-network-usage', 'Debug the network usage') do
139
+ options[:consul][:debug][:network] = true
140
+ consul_engine.add_template_callback do |all_ready, template_manager, results|
141
+ if all_ready
142
+ mod = false
143
+ results = results.map do |res|
144
+ mod ||= res.modified
145
+ STDERR.puts "[INFO] Hot reload of template #{res.template_file} with success" if res.hot_reloaded
146
+ "#{res.modified ? 'WRITTEN' : 'UNCHANGED'}[#{res.output_file}]"
147
+ end.join(' ')
148
+ if mod
149
+ STDERR.puts("[INFO] File written: #{results} #{template_manager.net_info.inspect}")
150
+ else
151
+ STDERR.print "[DBUG] Files not changed #{results} #{template_manager.net_info.inspect}\r"
152
+ end
153
+ else
154
+ STDERR.print "[DBUG] Still waiting for data #{template_manager.net_info.inspect}...\r"
155
+ end
156
+ end
157
+ end
158
+
159
+ opts.on('-t', '--template erb_file:[output]:[command]', String, 'Add a erb template, its output and optional reload command') do |tpl|
160
+ splitted = tpl.split(':')
161
+ source = splitted[0]
162
+ dest = splitted[1]
163
+ unless dest
164
+ dest = compute_default_output(source)
165
+ STDERR.puts "-t --template #{tpl} : Since output has not been set, using #{dest}"
166
+ end
167
+ raise "Source and destination cannot be the same in #{tpl}" if source == dest || dest.empty?
168
+ command = splitted[2]
169
+ consul_engine.add_template(source, dest)
170
+
171
+ if command
172
+ consul_engine.add_template_callback do |_all_ready, _template_manager, results|
173
+ results.each do |res|
174
+ next unless res.ready? && res.modified && res.output_file == dest && res.template_file == source
175
+ # Our template has been fully rendered
176
+ system(command)
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ opts.on(nil, '--once', 'Do not run the process as a daemon') do
183
+ consul_engine.add_template_callback do |all_ready, template_manager, _|
184
+ if all_ready
185
+ STDERR.puts '[INFO] Program ends since daemon mode has been disabled, file(s) has been written'
186
+ template_manager.terminate
187
+ EventMachine.stop
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ def kill_program
194
+ @programs.each do |k, v|
195
+ STDERR.puts "Killing process #{k}..."
196
+ v.kill
197
+ end
198
+ @programs = {}
199
+ exit 0
200
+ end
201
+
202
+ optparse.parse!
203
+
204
+ # Find the max descriptors for our system
205
+ def find_max_descriptors(max_descripors)
206
+ i = max_descripors
207
+ max_size = 1024
208
+ while i != max_size && i > 1024
209
+ max_size = EM.set_descriptor_table_size i
210
+ i /= 2 if max_size < i
211
+ end
212
+ max_size
213
+ end
214
+
215
+ # Since we might be using a lots of descriptors, document this
216
+ new_size = find_max_descriptors(65_536)
217
+ STDERR.puts "Max number of descriptors set to #{new_size}" if options[:consul][:debug][:network]
218
+
219
+ # This is needed to avoid EM not to crash on some Linux Hosts
220
+ # When using a very large number of Consul Endpoints
221
+ # See https://github.com/eventmachine/eventmachine/issues/636#issuecomment-143313282
222
+ EM.epoll
223
+
224
+ consul_conf = Consul::Async::ConsulConfiguration.new(options[:consul])
225
+ template_manager = Consul::Async::ConsulEndPointsManager.new(consul_conf)
226
+
227
+ ARGV.each do |tpl|
228
+ dest = compute_default_output(tpl)
229
+ puts "Using #{dest} output for #{tpl}"
230
+ consul_engine.add_template(tpl, dest)
231
+ end
232
+
233
+ # Ensure to kill child process if any
234
+ %w[INT PIPE TERM].each do |sig|
235
+ Signal.trap(sig) do
236
+ STDERR.puts "[KILL] received #{sig}, stopping myself"
237
+ kill_program
238
+ end
239
+ end
240
+
241
+ consul_engine.run(template_manager)
242
+
243
+ # Kill possible child process if consul_engine.run did stop
244
+ kill_program
245
+
246
+ exit 0
@@ -0,0 +1,37 @@
1
+ # coding: utf-8
2
+
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'consul/async/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'consul-templaterb'
9
+
10
+ spec.version = Consul::Async::VERSION
11
+ spec.authors = ['SRE Core Services']
12
+ spec.email = ['sre-core-services@criteo.com']
13
+
14
+ spec.summary = 'Implementation of Consul template using Ruby and .erb templating language'
15
+ spec.homepage = 'https://github.com/criteo/consul-templaterb'
16
+ spec.description = 'A ruby implementation of Consul Template with support of erb templating'
17
+
18
+ spec.license = 'Apache v2'
19
+
20
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
21
+ f.match(%r{^(test|spec|features)/})
22
+ end
23
+ spec.bindir = 'bin'
24
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
25
+ spec.require_paths = ['lib']
26
+
27
+ spec.require_paths = ['lib']
28
+ spec.add_runtime_dependency 'em-http-request', '>= 1.1.5'
29
+
30
+ spec.add_development_dependency 'bundler', '>= 1.14'
31
+ spec.add_development_dependency 'rake', '~> 10.0'
32
+ spec.add_development_dependency 'rspec', '~> 3.0'
33
+ spec.add_development_dependency 'rspec_junit_formatter'
34
+ spec.add_development_dependency 'rubocop', '0.49.0'
35
+ spec.add_development_dependency 'rubocop-junit-formatter'
36
+ spec.add_development_dependency 'webmock'
37
+ end
@@ -0,0 +1,279 @@
1
+ require 'consul/async/utilities'
2
+ require 'em-http'
3
+ require 'thread'
4
+ require 'json'
5
+ module Consul
6
+ module Async
7
+ class ConsulConfiguration
8
+ attr_reader :base_url, :token, :retry_duration, :min_duration, :wait_duration, :max_retry_duration, :retry_on_non_diff,
9
+ :missing_index_retry_time_on_diff, :missing_index_retry_time_on_unchanged, :debug
10
+ def initialize(base_url: 'http://locahost:8500',
11
+ debug: { network: false },
12
+ token: nil,
13
+ retry_duration: 10,
14
+ min_duration: 0.1,
15
+ retry_on_non_diff: 5,
16
+ wait_duration: 600,
17
+ max_retry_duration: 600,
18
+ missing_index_retry_time_on_diff: 15,
19
+ missing_index_retry_time_on_unchanged: 60,
20
+ paths: {})
21
+ @base_url = base_url
22
+ @token = token
23
+ @debug = debug
24
+ @retry_duration = retry_duration
25
+ @min_duration = min_duration
26
+ @wait_duration = wait_duration
27
+ @max_retry_duration = max_retry_duration
28
+ @retry_on_non_diff = retry_on_non_diff
29
+ @missing_index_retry_time_on_diff = missing_index_retry_time_on_diff
30
+ @missing_index_retry_time_on_unchanged = missing_index_retry_time_on_unchanged
31
+ @paths = paths
32
+ end
33
+
34
+ def ch(path, symbol)
35
+ sub = @paths[path.to_sym]
36
+ if sub && sub[symbol]
37
+ STDERR.puts "[INFO] Overriding #{symbol}:=#{sub[symbol]} for #{path}"
38
+ sub[symbol]
39
+ else
40
+ method(symbol).call
41
+ end
42
+ end
43
+
44
+ def create(path)
45
+ return self unless @paths[path.to_sym]
46
+ ConsulConfiguration.new(base_url: ch(path, :base_url),
47
+ debug: ch(path, :debug),
48
+ token: ch(path, :token),
49
+ retry_duration: ch(path, :retry_duration),
50
+ min_duration: ch(path, :min_duration),
51
+ retry_on_non_diff: ch(path, :retry_on_non_diff),
52
+ wait_duration: ch(path, :wait_duration),
53
+ max_retry_duration: ch(path, :max_retry_duration),
54
+ missing_index_retry_time_on_diff: ch(path, :missing_index_retry_time_on_diff),
55
+ missing_index_retry_time_on_unchanged: ch(path, :missing_index_retry_time_on_unchanged),
56
+ paths: @paths)
57
+ end
58
+ end
59
+ class ConsulEndPointStats
60
+ attr_reader :successes, :errors, :start, :body_bytes
61
+ def initialize
62
+ @start = Time.now.utc
63
+ @successes = 0
64
+ @errors = 0
65
+ @body_bytes = 0
66
+ end
67
+
68
+ def on_reponse(res)
69
+ @successes += 1
70
+ @body_bytes = body_bytes + res.http.response.bytesize
71
+ end
72
+
73
+ def on_error(_http)
74
+ @errors += 1
75
+ end
76
+
77
+ def bytes_per_sec
78
+ diff = (Time.now.utc - start)
79
+ diff = 1 if diff < 1
80
+ (body_bytes / diff).round(0)
81
+ end
82
+
83
+ def bytes_per_sec_human
84
+ "#{Utilities.bytes_to_h(bytes_per_sec)}/s"
85
+ end
86
+
87
+ def body_bytes_human
88
+ Utilities.bytes_to_h(body_bytes)
89
+ end
90
+ end
91
+ class ConsulResult
92
+ attr_reader :data, :http, :x_consul_index, :last_update, :stats, :retry_in
93
+ def initialize(data, modified, http, x_consul_index, stats, retry_in)
94
+ @data = data
95
+ @modified = modified
96
+ @http = http
97
+ @x_consul_index = x_consul_index
98
+ @last_update = Time.now.utc
99
+ @stats = stats
100
+ @retry_in = retry_in
101
+ end
102
+
103
+ def modified?
104
+ @modified
105
+ end
106
+
107
+ def mutate(new_data)
108
+ @data = new_data.dup
109
+ end
110
+
111
+ def json
112
+ @data_json = JSON.parse(data) if @data_json.nil?
113
+ @data_json
114
+ end
115
+
116
+ def next_retry_at
117
+ next_retry + last_update
118
+ end
119
+ end
120
+ class HttpResponse
121
+ attr_reader :response_header, :response, :error
122
+ def initialize(http, override_nil_response = nil)
123
+ if http.nil?
124
+ @response_header = nil
125
+ @response = override_nil_response
126
+ @error = 'Not initialized yet'
127
+ else
128
+ @response_header = http.response_header.nil? ? nil : http.response_header.dup.freeze
129
+ @response = http.response.nil? || http.response.empty? ? override_nil_response : http.response.dup.freeze
130
+ @error = http.error.nil? ? nil : http.error.dup.freeze
131
+ end
132
+ end
133
+ end
134
+ class ConsulEndpoint
135
+ attr_reader :conf, :path, :x_consul_index, :queue, :stats, :last_result, :enforce_json_200, :start_time, :default_value, :query_params
136
+ def initialize(conf, path, enforce_json_200 = true, query_params = {}, default_value = '[]')
137
+ @conf = conf.create(path)
138
+ @default_value = default_value
139
+ @path = path
140
+ @queue = EM::Queue.new
141
+ @x_consul_index = 0
142
+ @s_callbacks = []
143
+ @e_callbacks = []
144
+ @enforce_json_200 = enforce_json_200
145
+ @start_time = Time.now.utc
146
+ @consecutive_errors = 0
147
+ @query_params = query_params
148
+ @stopping = false
149
+ @stats = ConsulEndPointStats.new
150
+ @last_result = ConsulResult.new(default_value, false, HttpResponse.new(nil), 0, stats, 1)
151
+ on_response { |result| @stats.on_reponse result }
152
+ on_error { |http| @stats.on_error http }
153
+ _enable_network_debug if conf.debug && conf.debug[:network]
154
+ fetch
155
+ queue << 0
156
+ end
157
+
158
+ def _enable_network_debug
159
+ on_response do |result|
160
+ state = result.x_consul_index.to_i < 1 ? '[WARN]' : '[ OK ]'
161
+ stats = result.stats
162
+ STDERR.puts "[DBUG]#{state}#{result.modified? ? '[MODFIED]' : '[NO DIFF]'}" \
163
+ "[s:#{stats.successes},err:#{stats.errors}]" \
164
+ "[#{stats.body_bytes_human.ljust(8)}][#{stats.bytes_per_sec_human.ljust(9)}]"\
165
+ " #{path.ljust(48)} idx:#{result.x_consul_index}, next in #{result.retry_in} s"
166
+ end
167
+ on_error { |http| STDERR.puts "[ERROR]: #{path}: #{http.error}" }
168
+ end
169
+
170
+ def on_response(&block)
171
+ @s_callbacks << block
172
+ end
173
+
174
+ def on_error(&block)
175
+ @e_callbacks << block
176
+ end
177
+
178
+ def ready?
179
+ @ready
180
+ end
181
+
182
+ def terminate
183
+ @stopping = true
184
+ end
185
+
186
+ private
187
+
188
+ def build_request(consul_index)
189
+ res = {
190
+ head: {
191
+ 'Accept' => 'application/json',
192
+ 'X-Consul-Index' => consul_index,
193
+ 'X-Consul-Token' => conf.token
194
+ },
195
+ path: path,
196
+ query: {
197
+ wait: "#{conf.wait_duration}s",
198
+ index: consul_index,
199
+ stale: 'stale'
200
+ },
201
+ keepalive: true,
202
+ callback: method(:on_response)
203
+ }
204
+ @query_params.each_pair do |k, v|
205
+ res[:query][k] = v
206
+ end
207
+ res
208
+ end
209
+
210
+ def find_x_consul_token(http)
211
+ http.response_header['X_CONSUL_INDEX']
212
+ end
213
+
214
+ def _handle_error(http, consul_index)
215
+ retry_in = [600, conf.retry_duration + 2**@consecutive_errors].min
216
+ STDERR.puts "[ERROR][#{path}] X-Consul-Index:#{consul_index} - #{http.error} - Retry in #{retry_in}s #{stats.body_bytes_human}"
217
+ @consecutive_errors += 1
218
+ http_result = HttpResponse.new(http)
219
+ EventMachine.add_timer(retry_in) do
220
+ yield
221
+ queue.push(consul_index)
222
+ end
223
+ @e_callbacks.each { |c| c.call(http_result) }
224
+ end
225
+
226
+ def fetch
227
+ options = {
228
+ connect_timeout: 5, # default connection setup timeout
229
+ inactivity_timeout: conf.wait_duration + 1, # default connection inactivity (post-setup) timeout
230
+ }
231
+ connection = EventMachine::HttpRequest.new(conf.base_url, options)
232
+ cb = proc do |consul_index|
233
+ http = connection.get(build_request(consul_index))
234
+ http.callback do
235
+ # Dirty hack, but contrary to other path, when key is not present, Consul returns 404
236
+ is_kv_empty = path.start_with?('/v1/kv') && http.response_header.status == 404
237
+ if !is_kv_empty && enforce_json_200 && http.response_header.status != 200 && http.response_header['Content-Type'] != 'application/json'
238
+ _handle_error(http, consul_index) { connection = EventMachine::HttpRequest.new(conf.base_url, options) }
239
+ else
240
+ n_consul_index = find_x_consul_token(http)
241
+ @consecutive_errors = 0
242
+ http_result = if is_kv_empty
243
+ HttpResponse.new(http, default_value)
244
+ else
245
+ HttpResponse.new(http)
246
+ end
247
+ new_content = http_result.response.freeze
248
+ modified = @last_result.nil? ? true : @last_result.data != new_content
249
+ if n_consul_index == consul_index || n_consul_index.nil?
250
+ retry_in = modified ? conf.missing_index_retry_time_on_diff : conf.missing_index_retry_time_on_unchanged
251
+ n_consul_index = consul_index
252
+ else
253
+ retry_in = modified ? conf.min_duration : conf.retry_on_non_diff
254
+ end
255
+ retry_in = 0.1 if retry_in < 0.1
256
+ unless @stopping
257
+ EventMachine.add_timer(retry_in) do
258
+ queue.push(n_consul_index)
259
+ end
260
+ end
261
+ result = ConsulResult.new(new_content, modified, http_result, n_consul_index, stats, retry_in)
262
+ @last_result = result
263
+ @ready = true
264
+ @s_callbacks.each { |c| c.call(result) }
265
+ end
266
+ end
267
+
268
+ http.errback do
269
+ unless @stopping
270
+ _handle_error(http, consul_index) { connection = EventMachine::HttpRequest.new(conf.base_url, options) }
271
+ end
272
+ end
273
+ queue.pop(&cb)
274
+ end
275
+ queue.pop(&cb)
276
+ end
277
+ end
278
+ end
279
+ end