config_skeleton 0.2.6 → 1.0.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/.gitignore +1 -0
- data/config_skeleton.gemspec +4 -5
- data/lib/config_skeleton.rb +97 -53
- 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: 114069700875be6dd679f4731609175447186ddcbde862bdfef1d516c0f66601
|
4
|
+
data.tar.gz: 424208817f7b1c407272909e8c13e7e9e308dab0ca18e68ce5fda208c9536464
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1da2b1664644625d452e3aa6d7f10435901dd63c1084f1e6d1f9b0ad87e1ffc977efef0e9d64db11ffb072ab495d2a2064ca5b25f1afcc0b27b0ba9750a2d550
|
7
|
+
data.tar.gz: a288a8a63fba3019181fe44011c21088136e0c89dc72fb7f870dd96ce2fafc2eef599b9eb1a327149ba0ac973d75cbdda3ef3d164345d6984b6e84e14ed550d0
|
data/.github/workflows/ruby.yml
CHANGED
data/.gitignore
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 = "0.
|
4
|
+
s.version = "1.0.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,22 +205,11 @@ 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
|
-
regenerate_config(force_reload: true)
|
200
|
-
end
|
201
|
-
|
202
210
|
initialize_config_skeleton_metrics
|
203
211
|
@trigger_regen_r, @trigger_regen_w = IO.pipe
|
212
|
+
@terminate_r, @terminate_w = IO.pipe
|
204
213
|
end
|
205
214
|
|
206
215
|
# Expose the write pipe which can be written to to trigger a config
|
@@ -230,8 +239,6 @@ class ConfigSkeleton < ServiceSkeleton
|
|
230
239
|
|
231
240
|
logger.debug(logloc) { "notifier fd is #{notifier.to_io.inspect}" }
|
232
241
|
|
233
|
-
@terminate_r, @terminate_w = IO.pipe
|
234
|
-
|
235
242
|
loop do
|
236
243
|
if ios = IO.select(
|
237
244
|
[notifier.to_io, @terminate_r, @trigger_regen_r],
|
@@ -294,6 +301,7 @@ class ConfigSkeleton < ServiceSkeleton
|
|
294
301
|
# @see .watch for watching files and directories whose path never changes.
|
295
302
|
#
|
296
303
|
def watch(*files)
|
304
|
+
return if ENV["DISABLE_INOTIFY"]
|
297
305
|
files.each do |f|
|
298
306
|
if File.directory?(f)
|
299
307
|
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" } }
|
@@ -305,22 +313,11 @@ class ConfigSkeleton < ServiceSkeleton
|
|
305
313
|
|
306
314
|
private
|
307
315
|
|
308
|
-
# Register metrics in the ServiceSkeleton metrics registry
|
309
|
-
#
|
310
|
-
# @return [void]
|
311
|
-
#
|
312
316
|
def initialize_config_skeleton_metrics
|
313
|
-
@config_generation = Frankenstein::Request.new("#{service_name}_generation", outgoing: false, description: "config generation", registry: metrics)
|
314
|
-
|
315
|
-
metrics.
|
316
|
-
metrics.
|
317
|
-
metrics.counter(:"#{service_name}_reload_total", "How many times we've asked the server to reload")
|
318
|
-
metrics.counter(:"#{service_name}_signals_total", "How many signals have been received (and handled)")
|
319
|
-
metrics.gauge(:"#{service_name}_config_ok", "Whether the last config change was accepted by the server")
|
320
|
-
|
321
|
-
metrics.last_generation_timestamp.set({}, 0)
|
322
|
-
metrics.last_change_timestamp.set({}, 0)
|
323
|
-
metrics.config_ok.set({}, 0)
|
317
|
+
@config_generation = Frankenstein::Request.new("#{self.class.service_name}_generation", outgoing: false, description: "config generation", registry: metrics)
|
318
|
+
metrics.last_generation_timestamp.set(0)
|
319
|
+
metrics.last_change_timestamp.set(0)
|
320
|
+
metrics.config_ok.set(0)
|
324
321
|
end
|
325
322
|
|
326
323
|
# Write out a config file if one doesn't exist, or do an initial regen run
|
@@ -335,7 +332,7 @@ class ConfigSkeleton < ServiceSkeleton
|
|
335
332
|
else
|
336
333
|
logger.info(logloc) { "No existing config file #{config_file} found; writing one" }
|
337
334
|
File.write(config_file, instrumented_config_data)
|
338
|
-
metrics.last_change_timestamp.set(
|
335
|
+
metrics.last_change_timestamp.set(Time.now.to_f)
|
339
336
|
end
|
340
337
|
end
|
341
338
|
|
@@ -363,6 +360,27 @@ class ConfigSkeleton < ServiceSkeleton
|
|
363
360
|
raise NotImplementedError, "config_data must be implemented in subclass."
|
364
361
|
end
|
365
362
|
|
363
|
+
# Run code before the config is regenerated and the config_file
|
364
|
+
# is written.
|
365
|
+
#
|
366
|
+
# @param force_reload [Boolean] Whether the regenerate_config was called with force_reload
|
367
|
+
# @param existing_config_hash [String] MD5 hash of the config file before regeneration.
|
368
|
+
#
|
369
|
+
# @note this can optionally be implemented by subclasses.
|
370
|
+
#
|
371
|
+
def before_regenerate_config(force_reload:, existing_config_hash:, existing_config_data:); end
|
372
|
+
|
373
|
+
# Run code after the config is regenerated and potentially a new file is written.
|
374
|
+
#
|
375
|
+
# @param force_reload [Boolean] Whether the regenerate_config was called with force_reload
|
376
|
+
# @param config_was_different [Boolean] Whether the diff of the old and new config was different.
|
377
|
+
# @param config_was_cycled [Boolean] Whether a new config file was cycled in.
|
378
|
+
# @param new_config_hash [String] MD5 hash of the new config file after write.
|
379
|
+
#
|
380
|
+
# @note this can optionally be implemented by subclasses.
|
381
|
+
#
|
382
|
+
def after_regenerate_config(force_reload:, config_was_different:, config_was_cycled:, new_config_hash:); end
|
383
|
+
|
366
384
|
# Verify that the currently running config is acceptable.
|
367
385
|
#
|
368
386
|
# In the event that a generated config is "bad", it may be possible to detect
|
@@ -402,7 +420,7 @@ class ConfigSkeleton < ServiceSkeleton
|
|
402
420
|
#
|
403
421
|
def instrumented_config_data
|
404
422
|
begin
|
405
|
-
@config_generation.measure { config_data.tap { metrics.last_generation_timestamp.set(
|
423
|
+
@config_generation.measure { config_data.tap { metrics.last_generation_timestamp.set(Time.now.to_f) } }
|
406
424
|
rescue => ex
|
407
425
|
log_exception(ex, logloc) { "Call to config_data raised exception" }
|
408
426
|
nil
|
@@ -432,6 +450,9 @@ class ConfigSkeleton < ServiceSkeleton
|
|
432
450
|
#
|
433
451
|
def notifier
|
434
452
|
@notifier ||= INotify::Notifier.new
|
453
|
+
rescue NameError
|
454
|
+
raise if !ENV["DISABLE_INOTIFY"]
|
455
|
+
@notifier ||= Struct.new(:to_io).new(IO.pipe[1]) # Stub for macOS development
|
435
456
|
end
|
436
457
|
|
437
458
|
# Do the hard yards of actually regenerating the config and performing the reload.
|
@@ -444,31 +465,54 @@ class ConfigSkeleton < ServiceSkeleton
|
|
444
465
|
# @return [void]
|
445
466
|
#
|
446
467
|
def regenerate_config(force_reload: false)
|
468
|
+
data = File.read(config_file)
|
469
|
+
existing_config_hash = Digest::MD5.hexdigest(data)
|
470
|
+
before_regenerate_config(
|
471
|
+
force_reload: force_reload,
|
472
|
+
existing_config_hash: existing_config_hash,
|
473
|
+
existing_config_data: data
|
474
|
+
)
|
475
|
+
|
447
476
|
logger.debug(logloc) { "force? #{force_reload.inspect}" }
|
448
|
-
tmpfile = Tempfile.new(service_name, File.dirname(config_file))
|
477
|
+
tmpfile = Tempfile.new(self.class.service_name, File.dirname(config_file))
|
449
478
|
logger.debug(logloc) { "Tempfile is #{tmpfile.path}" }
|
450
479
|
unless (new_config = instrumented_config_data).nil?
|
451
480
|
File.write(tmpfile.path, new_config)
|
452
481
|
tmpfile.close
|
453
|
-
|
482
|
+
|
483
|
+
new_config_hash = Digest::MD5.hexdigest(File.read(tmpfile.path))
|
484
|
+
logger.debug(logloc) do
|
485
|
+
"Existing config hash: #{existing_config_hash}, new config hash: #{new_config_hash}"
|
486
|
+
end
|
454
487
|
|
455
488
|
match_perms(config_file, tmpfile.path)
|
456
489
|
|
457
|
-
diff = Diffy::Diff.new(config_file, tmpfile.path, source: 'files', context: 3, include_diff_info: true)
|
458
|
-
|
459
|
-
|
490
|
+
diff = Diffy::Diff.new(config_file, tmpfile.path, source: 'files', context: 3, include_diff_info: true).to_s
|
491
|
+
config_was_different = diff != ""
|
492
|
+
|
493
|
+
if config_was_different
|
494
|
+
logger.info(logloc) { "Config has changed. Diff:\n#{diff}" }
|
460
495
|
end
|
461
496
|
|
462
497
|
if force_reload
|
463
498
|
logger.debug(logloc) { "Forcing config reload because force_reload == true" }
|
464
499
|
end
|
465
500
|
|
466
|
-
|
501
|
+
config_was_cycled = false
|
502
|
+
if force_reload || config_was_different
|
467
503
|
cycle_config(tmpfile.path)
|
504
|
+
config_was_cycled = true
|
468
505
|
end
|
469
506
|
end
|
507
|
+
|
508
|
+
after_regenerate_config(
|
509
|
+
force_reload: force_reload,
|
510
|
+
config_was_different: config_was_different,
|
511
|
+
config_was_cycled: config_was_cycled,
|
512
|
+
new_config_hash: new_config_hash
|
513
|
+
)
|
470
514
|
ensure
|
471
|
-
metrics.last_change_timestamp.set(
|
515
|
+
metrics.last_change_timestamp.set(File.stat(config_file).mtime.to_f)
|
472
516
|
tmpfile.close rescue nil
|
473
517
|
tmpfile.unlink rescue nil
|
474
518
|
end
|
@@ -518,7 +562,7 @@ class ConfigSkeleton < ServiceSkeleton
|
|
518
562
|
logger.debug(logloc) { "Restored previous config file" }
|
519
563
|
File.rename(old_copy, config_file)
|
520
564
|
end
|
521
|
-
metrics.reload_total.increment(status: "failure")
|
565
|
+
metrics.reload_total.increment(labels: { status: "failure" })
|
522
566
|
|
523
567
|
return
|
524
568
|
end
|
@@ -526,21 +570,21 @@ class ConfigSkeleton < ServiceSkeleton
|
|
526
570
|
logger.debug(logloc) { "Server reloaded successfully" }
|
527
571
|
|
528
572
|
if config_ok?
|
529
|
-
metrics.config_ok.set(
|
573
|
+
metrics.config_ok.set(1)
|
530
574
|
logger.debug(logloc) { "Configuration successfully updated." }
|
531
|
-
metrics.reload_total.increment(status: "success")
|
532
|
-
metrics.last_change_timestamp.set(
|
575
|
+
metrics.reload_total.increment(labels: { status: "success" })
|
576
|
+
metrics.last_change_timestamp.set(Time.now.to_f)
|
533
577
|
else
|
534
|
-
metrics.config_ok.set(
|
578
|
+
metrics.config_ok.set(0)
|
535
579
|
if config_was_ok
|
536
580
|
logger.warn(logloc) { "New config file failed config_ok? test; rolling back to previous known-good config" }
|
537
581
|
File.rename(old_copy, config_file)
|
538
582
|
reload_server
|
539
|
-
metrics.reload_total.increment(status: "bad-config")
|
583
|
+
metrics.reload_total.increment(labels: { status: "bad-config" })
|
540
584
|
else
|
541
585
|
logger.warn(logloc) { "New config file failed config_ok? test; leaving new config in place because old config is broken too" }
|
542
|
-
metrics.reload_total.increment(status: "everything-is-awful")
|
543
|
-
metrics.last_change_timestamp.set(
|
586
|
+
metrics.reload_total.increment(labels: { status: "everything-is-awful" })
|
587
|
+
metrics.last_change_timestamp.set(Time.now.to_f)
|
544
588
|
end
|
545
589
|
end
|
546
590
|
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: 0.
|
4
|
+
version: 1.0.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-01 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: []
|