consul-templaterb 1.0.3

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