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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdaa156de0445e8a32b2d49d55ea11308d153d4c73658f6c4ff648a7c32a1440
4
- data.tar.gz: 5dff0715f11dad930b6dd485204d9a1f2b75a47afc9096cb33dd0c4f5a538423
3
+ metadata.gz: 0b419f026421b513b20f2950609f9b45339d86433b88e8372bd94746f6a72091
4
+ data.tar.gz: 7e0a5ac1b0dfe9ad7ee989aeb9d0051b06d179190328460ba09c273312b36b7d
5
5
  SHA512:
6
- metadata.gz: e5c7ffc7621e69441f17349d9e46a9d4f5146ed88a4c5b382524dfd64b7c0aa6b4aedc72bcb3ffac220509299983adff24263f86e8a327072ed34b933095ab3e
7
- data.tar.gz: 50e06b7b3fbe40c4baf522b95595bd447ec3e349c2e14a1cbf7da64500f2db5a64d006ec6b9f15a2320fdbc76f879f733d91ff555c26d7eef7432d1fc46ff059
6
+ metadata.gz: c36398b332b1bfa53a66d11a2c083af4a0f794e53fba518278fcef7ef41dfc63d51b4b010f508eeb898bf99c10ff55a2f813251bdfefe122a70e3254b87da46a
7
+ data.tar.gz: 53acc41e5da3aea61cda288cfe90f122e8d8871eea594d5e4b0857d127c01ba20a542df7087884c34e0e75589e968820d53b41db62c3f85f91d49eea9dbf88a2
@@ -43,6 +43,6 @@ jobs:
43
43
  - uses: actions/checkout@v2
44
44
 
45
45
  - name: Release Gem
46
- uses: CvX/publish-rubygems-action@master
46
+ uses: discourse/publish-rubygems-action@main
47
47
  env:
48
48
  RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}}
@@ -1,14 +1,14 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "config_skeleton"
3
3
 
4
- s.version = "0.3.0"
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', '> 0.a'
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'
@@ -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? and #sleep_duration).
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. Instantiate your new class, passing in an environment hash, and then call
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
- # MyConfigGenerator.new(ENV).start if __FILE__ == $0
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 < ServiceSkeleton
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
- # Create a new config generator.
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 ios = IO.select(
236
- [notifier.to_io, @terminate_r, @trigger_regen_r],
237
- [], [],
238
- sleep_duration.tap { |d| logger.debug(logloc) { "Sleeping for #{d} seconds" } }
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.gauge(:"#{service_name}_last_generation_timestamp", "When the last config generation run was made")
315
- metrics.gauge(:"#{service_name}_last_change_timestamp", "When the config file was last written to")
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({}, Time.now.to_f)
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({}, Time.now.to_f) } }
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
- logger.debug(logloc) { require 'digest/md5'; "Existing config hash: #{Digest::MD5.hexdigest(File.read(config_file))}, new config hash: #{Digest::MD5.hexdigest(File.read(tmpfile.path))}" }
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
- if diff.to_s != ""
458
- logger.info(logloc) { "Config has changed. Diff:\n#{diff.to_s}" }
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
- if force_reload || diff.to_s != ""
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({}, File.stat(config_file).mtime.to_f)
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({}, 1)
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({}, Time.now.to_f)
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({}, 0)
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({}, Time.now.to_f)
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: 0.3.0
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: 2020-11-26 00:00:00.000000000 Z
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.a
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.a
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: []