config_skeleton 0.3.0 → 1.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 +4 -4
- data/.github/workflows/ruby.yml +1 -1
- data/config_skeleton.gemspec +4 -5
- data/lib/config_skeleton.rb +121 -56
- metadata +8 -20
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0b419f026421b513b20f2950609f9b45339d86433b88e8372bd94746f6a72091
|
4
|
+
data.tar.gz: 7e0a5ac1b0dfe9ad7ee989aeb9d0051b06d179190328460ba09c273312b36b7d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c36398b332b1bfa53a66d11a2c083af4a0f794e53fba518278fcef7ef41dfc63d51b4b010f508eeb898bf99c10ff55a2f813251bdfefe122a70e3254b87da46a
|
7
|
+
data.tar.gz: 53acc41e5da3aea61cda288cfe90f122e8d8871eea594d5e4b0857d127c01ba20a542df7087884c34e0e75589e968820d53b41db62c3f85f91d49eea9dbf88a2
|
data/.github/workflows/ruby.yml
CHANGED
data/config_skeleton.gemspec
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = "config_skeleton"
|
3
3
|
|
4
|
-
s.version = "
|
4
|
+
s.version = "1.1.0"
|
5
5
|
|
6
6
|
s.platform = Gem::Platform::RUBY
|
7
7
|
|
8
8
|
s.summary = "Dynamically generate configs and reload servers"
|
9
9
|
|
10
|
-
s.authors = ["Matt Palmer"]
|
11
|
-
s.email = ["matt.palmer@discourse.org"]
|
10
|
+
s.authors = ["Matt Palmer", "Discourse Team"]
|
11
|
+
s.email = ["matt.palmer@discourse.org", "team@discourse.org"]
|
12
12
|
s.homepage = "https://github.com/discourse/config_skeleton"
|
13
13
|
|
14
14
|
s.files = `git ls-files -z`.split("\0").reject { |f| f =~ /^(G|spec|Rakefile)/ }
|
@@ -16,9 +16,8 @@ Gem::Specification.new do |s|
|
|
16
16
|
s.required_ruby_version = ">= 2.3.0"
|
17
17
|
|
18
18
|
s.add_runtime_dependency 'diffy', '~> 3.0'
|
19
|
-
s.add_runtime_dependency 'frankenstein', '~> 1.0'
|
20
19
|
s.add_runtime_dependency 'rb-inotify', '~> 0.9'
|
21
|
-
s.add_runtime_dependency 'service_skeleton',
|
20
|
+
s.add_runtime_dependency 'service_skeleton', "~> 1.0"
|
22
21
|
|
23
22
|
s.add_development_dependency 'bundler'
|
24
23
|
s.add_development_dependency 'github-release'
|
data/lib/config_skeleton.rb
CHANGED
@@ -2,9 +2,16 @@ require 'diffy'
|
|
2
2
|
require 'fileutils'
|
3
3
|
require 'frankenstein'
|
4
4
|
require 'logger'
|
5
|
-
require 'rb-inotify'
|
6
5
|
require 'service_skeleton'
|
7
6
|
require 'tempfile'
|
7
|
+
require 'digest/md5'
|
8
|
+
|
9
|
+
begin
|
10
|
+
require 'rb-inotify' unless ENV["DISABLE_INOTIFY"]
|
11
|
+
rescue FFI::NotFoundError => e
|
12
|
+
STDERR.puts "ERROR: Unable to initialize rb-inotify. To disable, set DISABLE_INOTIFY=1"
|
13
|
+
raise
|
14
|
+
end
|
8
15
|
|
9
16
|
# Framework for creating config generation systems.
|
10
17
|
#
|
@@ -23,19 +30,18 @@ require 'tempfile'
|
|
23
30
|
#
|
24
31
|
# 1. Implement service-specific config generation and reloading code, by
|
25
32
|
# overriding the private methods #config_file, #config_data, and #reload_server
|
26
|
-
# (and also potentially #config_ok
|
33
|
+
# (and also potentially #config_ok?, #sleep_duration, #before_regenerate_config, and #after_regenerate_config).
|
27
34
|
# See the documentation for those methods for what they need to do.
|
28
35
|
#
|
29
36
|
# 1. Setup any file watchers you want with .watch and #watch.
|
30
37
|
#
|
31
|
-
# 1.
|
32
|
-
# #start. Something like this should do the trick:
|
38
|
+
# 1. Use the ServiceSkeleton Runner to start the service. Something like this should do the trick:
|
33
39
|
#
|
34
40
|
# class MyConfigGenerator < ConfigSkeleton
|
35
41
|
# # Implement all the necessary methods
|
36
42
|
# end
|
37
43
|
#
|
38
|
-
#
|
44
|
+
# ServiceSkeleton::Runner.new(MyConfigGenerator, ENV).run if __FILE__ == $0
|
39
45
|
#
|
40
46
|
# 1. Sit back and relax.
|
41
47
|
#
|
@@ -135,7 +141,8 @@ require 'tempfile'
|
|
135
141
|
# method, passing one or more strings containing the full path to files or
|
136
142
|
# directories to watch.
|
137
143
|
#
|
138
|
-
class ConfigSkeleton
|
144
|
+
class ConfigSkeleton
|
145
|
+
include ServiceSkeleton
|
139
146
|
# All ConfigSkeleton-related errors will be subclasses of this.
|
140
147
|
class Error < StandardError; end
|
141
148
|
|
@@ -154,6 +161,19 @@ class ConfigSkeleton < ServiceSkeleton
|
|
154
161
|
end
|
155
162
|
end
|
156
163
|
|
164
|
+
def self.inherited(klass)
|
165
|
+
klass.gauge :"#{klass.service_name}_last_generation_timestamp", docstring: "When the last config generation run was made"
|
166
|
+
klass.gauge :"#{klass.service_name}_last_change_timestamp", docstring: "When the config file was last written to"
|
167
|
+
klass.counter :"#{klass.service_name}_reload_total", docstring: "How many times we've asked the server to reload", labels: [:status]
|
168
|
+
klass.counter :"#{klass.service_name}_signals_total", docstring: "How many signals have been received (and handled)"
|
169
|
+
klass.gauge :"#{klass.service_name}_config_ok", docstring: "Whether the last config change was accepted by the server"
|
170
|
+
|
171
|
+
klass.hook_signal("HUP") do
|
172
|
+
logger.info("SIGHUP") { "received SIGHUP, triggering config regeneration" }
|
173
|
+
@trigger_regen_w << "."
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
157
177
|
# Declare a file watch on all instances of the config generator.
|
158
178
|
#
|
159
179
|
# When you're looking to watch a file whose path is well-known and never-changing, you
|
@@ -185,20 +205,8 @@ class ConfigSkeleton < ServiceSkeleton
|
|
185
205
|
@watches || []
|
186
206
|
end
|
187
207
|
|
188
|
-
|
189
|
-
#
|
190
|
-
# @param env [Hash<String, String>] the environment in which this config
|
191
|
-
# generator runs. Typically you'll just pass `ENV` in here, but you can
|
192
|
-
# pass in any hash you like, for testing purposes.
|
193
|
-
#
|
194
|
-
def initialize(env)
|
208
|
+
def initialize(*_)
|
195
209
|
super
|
196
|
-
|
197
|
-
hook_signal(:HUP) do
|
198
|
-
logger.info("SIGHUP") { "received SIGHUP, triggering config regeneration" }
|
199
|
-
@trigger_regen_w << "."
|
200
|
-
end
|
201
|
-
|
202
210
|
initialize_config_skeleton_metrics
|
203
211
|
@trigger_regen_r, @trigger_regen_w = IO.pipe
|
204
212
|
@terminate_r, @terminate_w = IO.pipe
|
@@ -232,11 +240,16 @@ class ConfigSkeleton < ServiceSkeleton
|
|
232
240
|
logger.debug(logloc) { "notifier fd is #{notifier.to_io.inspect}" }
|
233
241
|
|
234
242
|
loop do
|
235
|
-
if
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
243
|
+
if cooldown_duration > 0
|
244
|
+
logger.debug(logloc) { "Sleeping for #{cooldown_duration} seconds (cooldown)" }
|
245
|
+
IO.select([@terminate_r], [], [], cooldown_duration)
|
246
|
+
end
|
247
|
+
|
248
|
+
timeout = sleep_duration - cooldown_duration
|
249
|
+
logger.debug(logloc) { "Sleeping for #{timeout} seconds unless interrupted" }
|
250
|
+
ios = IO.select([notifier.to_io, @terminate_r, @trigger_regen_r], [], [], timeout)
|
251
|
+
|
252
|
+
if ios
|
240
253
|
if ios.first.include?(notifier.to_io)
|
241
254
|
logger.debug(logloc) { "inotify triggered" }
|
242
255
|
notifier.process
|
@@ -293,6 +306,7 @@ class ConfigSkeleton < ServiceSkeleton
|
|
293
306
|
# @see .watch for watching files and directories whose path never changes.
|
294
307
|
#
|
295
308
|
def watch(*files)
|
309
|
+
return if ENV["DISABLE_INOTIFY"]
|
296
310
|
files.each do |f|
|
297
311
|
if File.directory?(f)
|
298
312
|
notifier.watch(f, :recursive, :create, :modify, :delete, :move) { |ev| logger.info("#{logloc} watcher") { "detected #{ev.flags.join(", ")} on #{ev.watcher.path}/#{ev.name}; regenerating config" } }
|
@@ -304,22 +318,11 @@ class ConfigSkeleton < ServiceSkeleton
|
|
304
318
|
|
305
319
|
private
|
306
320
|
|
307
|
-
# Register metrics in the ServiceSkeleton metrics registry
|
308
|
-
#
|
309
|
-
# @return [void]
|
310
|
-
#
|
311
321
|
def initialize_config_skeleton_metrics
|
312
|
-
@config_generation = Frankenstein::Request.new("#{service_name}_generation", outgoing: false, description: "config generation", registry: metrics)
|
313
|
-
|
314
|
-
metrics.
|
315
|
-
metrics.
|
316
|
-
metrics.counter(:"#{service_name}_reload_total", "How many times we've asked the server to reload")
|
317
|
-
metrics.counter(:"#{service_name}_signals_total", "How many signals have been received (and handled)")
|
318
|
-
metrics.gauge(:"#{service_name}_config_ok", "Whether the last config change was accepted by the server")
|
319
|
-
|
320
|
-
metrics.last_generation_timestamp.set({}, 0)
|
321
|
-
metrics.last_change_timestamp.set({}, 0)
|
322
|
-
metrics.config_ok.set({}, 0)
|
322
|
+
@config_generation = Frankenstein::Request.new("#{self.class.service_name}_generation", outgoing: false, description: "config generation", registry: metrics)
|
323
|
+
metrics.last_generation_timestamp.set(0)
|
324
|
+
metrics.last_change_timestamp.set(0)
|
325
|
+
metrics.config_ok.set(0)
|
323
326
|
end
|
324
327
|
|
325
328
|
# Write out a config file if one doesn't exist, or do an initial regen run
|
@@ -334,7 +337,7 @@ class ConfigSkeleton < ServiceSkeleton
|
|
334
337
|
else
|
335
338
|
logger.info(logloc) { "No existing config file #{config_file} found; writing one" }
|
336
339
|
File.write(config_file, instrumented_config_data)
|
337
|
-
metrics.last_change_timestamp.set(
|
340
|
+
metrics.last_change_timestamp.set(Time.now.to_f)
|
338
341
|
end
|
339
342
|
end
|
340
343
|
|
@@ -362,6 +365,27 @@ class ConfigSkeleton < ServiceSkeleton
|
|
362
365
|
raise NotImplementedError, "config_data must be implemented in subclass."
|
363
366
|
end
|
364
367
|
|
368
|
+
# Run code before the config is regenerated and the config_file
|
369
|
+
# is written.
|
370
|
+
#
|
371
|
+
# @param force_reload [Boolean] Whether the regenerate_config was called with force_reload
|
372
|
+
# @param existing_config_hash [String] MD5 hash of the config file before regeneration.
|
373
|
+
#
|
374
|
+
# @note this can optionally be implemented by subclasses.
|
375
|
+
#
|
376
|
+
def before_regenerate_config(force_reload:, existing_config_hash:, existing_config_data:); end
|
377
|
+
|
378
|
+
# Run code after the config is regenerated and potentially a new file is written.
|
379
|
+
#
|
380
|
+
# @param force_reload [Boolean] Whether the regenerate_config was called with force_reload
|
381
|
+
# @param config_was_different [Boolean] Whether the diff of the old and new config was different.
|
382
|
+
# @param config_was_cycled [Boolean] Whether a new config file was cycled in.
|
383
|
+
# @param new_config_hash [String] MD5 hash of the new config file after write.
|
384
|
+
#
|
385
|
+
# @note this can optionally be implemented by subclasses.
|
386
|
+
#
|
387
|
+
def after_regenerate_config(force_reload:, config_was_different:, config_was_cycled:, new_config_hash:); end
|
388
|
+
|
365
389
|
# Verify that the currently running config is acceptable.
|
366
390
|
#
|
367
391
|
# In the event that a generated config is "bad", it may be possible to detect
|
@@ -401,7 +425,7 @@ class ConfigSkeleton < ServiceSkeleton
|
|
401
425
|
#
|
402
426
|
def instrumented_config_data
|
403
427
|
begin
|
404
|
-
@config_generation.measure { config_data.tap { metrics.last_generation_timestamp.set(
|
428
|
+
@config_generation.measure { config_data.tap { metrics.last_generation_timestamp.set(Time.now.to_f) } }
|
405
429
|
rescue => ex
|
406
430
|
log_exception(ex, logloc) { "Call to config_data raised exception" }
|
407
431
|
nil
|
@@ -425,12 +449,30 @@ class ConfigSkeleton < ServiceSkeleton
|
|
425
449
|
60
|
426
450
|
end
|
427
451
|
|
452
|
+
# How long to ignore signals/notifications after a config regeneration
|
453
|
+
#
|
454
|
+
# Hammering a downstream service with reload requests is often a bad idea.
|
455
|
+
# This method exists to allow subclasses to define a 'cooldown' duration.
|
456
|
+
# After each config regeneration, the config generator will sleep for this
|
457
|
+
# duration, regardless of any CONT signals or inotify events. Those events
|
458
|
+
# will be queued up, and processed at the end of the cooldown.
|
459
|
+
#
|
460
|
+
# @return [Integer] the number of seconds to 'cooldown' for. This *must* be
|
461
|
+
# greater than zero, and less than sleep_duration
|
462
|
+
#
|
463
|
+
def cooldown_duration
|
464
|
+
5
|
465
|
+
end
|
466
|
+
|
428
467
|
# The instance of INotify::Notifier that is holding our file watches.
|
429
468
|
#
|
430
469
|
# @return [INotify::Notifier]
|
431
470
|
#
|
432
471
|
def notifier
|
433
472
|
@notifier ||= INotify::Notifier.new
|
473
|
+
rescue NameError
|
474
|
+
raise if !ENV["DISABLE_INOTIFY"]
|
475
|
+
@notifier ||= Struct.new(:to_io).new(IO.pipe[1]) # Stub for macOS development
|
434
476
|
end
|
435
477
|
|
436
478
|
# Do the hard yards of actually regenerating the config and performing the reload.
|
@@ -443,31 +485,54 @@ class ConfigSkeleton < ServiceSkeleton
|
|
443
485
|
# @return [void]
|
444
486
|
#
|
445
487
|
def regenerate_config(force_reload: false)
|
488
|
+
data = File.read(config_file)
|
489
|
+
existing_config_hash = Digest::MD5.hexdigest(data)
|
490
|
+
before_regenerate_config(
|
491
|
+
force_reload: force_reload,
|
492
|
+
existing_config_hash: existing_config_hash,
|
493
|
+
existing_config_data: data
|
494
|
+
)
|
495
|
+
|
446
496
|
logger.debug(logloc) { "force? #{force_reload.inspect}" }
|
447
|
-
tmpfile = Tempfile.new(service_name, File.dirname(config_file))
|
497
|
+
tmpfile = Tempfile.new(self.class.service_name, File.dirname(config_file))
|
448
498
|
logger.debug(logloc) { "Tempfile is #{tmpfile.path}" }
|
449
499
|
unless (new_config = instrumented_config_data).nil?
|
450
500
|
File.write(tmpfile.path, new_config)
|
451
501
|
tmpfile.close
|
452
|
-
|
502
|
+
|
503
|
+
new_config_hash = Digest::MD5.hexdigest(File.read(tmpfile.path))
|
504
|
+
logger.debug(logloc) do
|
505
|
+
"Existing config hash: #{existing_config_hash}, new config hash: #{new_config_hash}"
|
506
|
+
end
|
453
507
|
|
454
508
|
match_perms(config_file, tmpfile.path)
|
455
509
|
|
456
|
-
diff = Diffy::Diff.new(config_file, tmpfile.path, source: 'files', context: 3, include_diff_info: true)
|
457
|
-
|
458
|
-
|
510
|
+
diff = Diffy::Diff.new(config_file, tmpfile.path, source: 'files', context: 3, include_diff_info: true).to_s
|
511
|
+
config_was_different = diff != ""
|
512
|
+
|
513
|
+
if config_was_different
|
514
|
+
logger.info(logloc) { "Config has changed. Diff:\n#{diff}" }
|
459
515
|
end
|
460
516
|
|
461
517
|
if force_reload
|
462
518
|
logger.debug(logloc) { "Forcing config reload because force_reload == true" }
|
463
519
|
end
|
464
520
|
|
465
|
-
|
521
|
+
config_was_cycled = false
|
522
|
+
if force_reload || config_was_different
|
466
523
|
cycle_config(tmpfile.path)
|
524
|
+
config_was_cycled = true
|
467
525
|
end
|
468
526
|
end
|
527
|
+
|
528
|
+
after_regenerate_config(
|
529
|
+
force_reload: force_reload,
|
530
|
+
config_was_different: config_was_different,
|
531
|
+
config_was_cycled: config_was_cycled,
|
532
|
+
new_config_hash: new_config_hash
|
533
|
+
)
|
469
534
|
ensure
|
470
|
-
metrics.last_change_timestamp.set(
|
535
|
+
metrics.last_change_timestamp.set(File.stat(config_file).mtime.to_f)
|
471
536
|
tmpfile.close rescue nil
|
472
537
|
tmpfile.unlink rescue nil
|
473
538
|
end
|
@@ -517,7 +582,7 @@ class ConfigSkeleton < ServiceSkeleton
|
|
517
582
|
logger.debug(logloc) { "Restored previous config file" }
|
518
583
|
File.rename(old_copy, config_file)
|
519
584
|
end
|
520
|
-
metrics.reload_total.increment(status: "failure")
|
585
|
+
metrics.reload_total.increment(labels: { status: "failure" })
|
521
586
|
|
522
587
|
return
|
523
588
|
end
|
@@ -525,21 +590,21 @@ class ConfigSkeleton < ServiceSkeleton
|
|
525
590
|
logger.debug(logloc) { "Server reloaded successfully" }
|
526
591
|
|
527
592
|
if config_ok?
|
528
|
-
metrics.config_ok.set(
|
593
|
+
metrics.config_ok.set(1)
|
529
594
|
logger.debug(logloc) { "Configuration successfully updated." }
|
530
|
-
metrics.reload_total.increment(status: "success")
|
531
|
-
metrics.last_change_timestamp.set(
|
595
|
+
metrics.reload_total.increment(labels: { status: "success" })
|
596
|
+
metrics.last_change_timestamp.set(Time.now.to_f)
|
532
597
|
else
|
533
|
-
metrics.config_ok.set(
|
598
|
+
metrics.config_ok.set(0)
|
534
599
|
if config_was_ok
|
535
600
|
logger.warn(logloc) { "New config file failed config_ok? test; rolling back to previous known-good config" }
|
536
601
|
File.rename(old_copy, config_file)
|
537
602
|
reload_server
|
538
|
-
metrics.reload_total.increment(status: "bad-config")
|
603
|
+
metrics.reload_total.increment(labels: { status: "bad-config" })
|
539
604
|
else
|
540
605
|
logger.warn(logloc) { "New config file failed config_ok? test; leaving new config in place because old config is broken too" }
|
541
|
-
metrics.reload_total.increment(status: "everything-is-awful")
|
542
|
-
metrics.last_change_timestamp.set(
|
606
|
+
metrics.reload_total.increment(labels: { status: "everything-is-awful" })
|
607
|
+
metrics.last_change_timestamp.set(Time.now.to_f)
|
543
608
|
end
|
544
609
|
end
|
545
610
|
ensure
|
metadata
CHANGED
@@ -1,14 +1,15 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: config_skeleton
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Matt Palmer
|
8
|
+
- Discourse Team
|
8
9
|
autorequire:
|
9
10
|
bindir: bin
|
10
11
|
cert_chain: []
|
11
|
-
date:
|
12
|
+
date: 2021-03-11 00:00:00.000000000 Z
|
12
13
|
dependencies:
|
13
14
|
- !ruby/object:Gem::Dependency
|
14
15
|
name: diffy
|
@@ -24,20 +25,6 @@ dependencies:
|
|
24
25
|
- - "~>"
|
25
26
|
- !ruby/object:Gem::Version
|
26
27
|
version: '3.0'
|
27
|
-
- !ruby/object:Gem::Dependency
|
28
|
-
name: frankenstein
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
30
|
-
requirements:
|
31
|
-
- - "~>"
|
32
|
-
- !ruby/object:Gem::Version
|
33
|
-
version: '1.0'
|
34
|
-
type: :runtime
|
35
|
-
prerelease: false
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
37
|
-
requirements:
|
38
|
-
- - "~>"
|
39
|
-
- !ruby/object:Gem::Version
|
40
|
-
version: '1.0'
|
41
28
|
- !ruby/object:Gem::Dependency
|
42
29
|
name: rb-inotify
|
43
30
|
requirement: !ruby/object:Gem::Requirement
|
@@ -56,16 +43,16 @@ dependencies:
|
|
56
43
|
name: service_skeleton
|
57
44
|
requirement: !ruby/object:Gem::Requirement
|
58
45
|
requirements:
|
59
|
-
- - "
|
46
|
+
- - "~>"
|
60
47
|
- !ruby/object:Gem::Version
|
61
|
-
version: 0
|
48
|
+
version: '1.0'
|
62
49
|
type: :runtime
|
63
50
|
prerelease: false
|
64
51
|
version_requirements: !ruby/object:Gem::Requirement
|
65
52
|
requirements:
|
66
|
-
- - "
|
53
|
+
- - "~>"
|
67
54
|
- !ruby/object:Gem::Version
|
68
|
-
version: 0
|
55
|
+
version: '1.0'
|
69
56
|
- !ruby/object:Gem::Dependency
|
70
57
|
name: bundler
|
71
58
|
requirement: !ruby/object:Gem::Requirement
|
@@ -195,6 +182,7 @@ dependencies:
|
|
195
182
|
description:
|
196
183
|
email:
|
197
184
|
- matt.palmer@discourse.org
|
185
|
+
- team@discourse.org
|
198
186
|
executables: []
|
199
187
|
extensions: []
|
200
188
|
extra_rdoc_files: []
|