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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d55ae63c3cac3ec4c77ae3d20c2a8078fa65e937abdeaf10afae589f7e45d85
4
- data.tar.gz: 07c2dc5afd603e1e8c61ff0f5b6d7b5cbb0ce9ce510c8c645279c683f5adee4e
3
+ metadata.gz: 114069700875be6dd679f4731609175447186ddcbde862bdfef1d516c0f66601
4
+ data.tar.gz: 424208817f7b1c407272909e8c13e7e9e308dab0ca18e68ce5fda208c9536464
5
5
  SHA512:
6
- metadata.gz: 3ed1a58c33de8eca24db5823d221bde84e64a9bcba91d2007ac68095da458c61fd50ef24670b96c2186a577ab91c2fa90c289c680e4310abbdbf9a34f6339754
7
- data.tar.gz: 8932f5e8ee5545644a7d5d7950f5737b256ad9c4eb10cb1c0dd8f0b8333fd22d62557c0afa011b83ec508eb94b1f94068b7558ee082ce9f45f75448309b64e77
6
+ metadata.gz: 1da2b1664644625d452e3aa6d7f10435901dd63c1084f1e6d1f9b0ad87e1ffc977efef0e9d64db11ffb072ab495d2a2064ca5b25f1afcc0b27b0ba9750a2d550
7
+ data.tar.gz: a288a8a63fba3019181fe44011c21088136e0c89dc72fb7f870dd96ce2fafc2eef599b9eb1a327149ba0ac973d75cbdda3ef3d164345d6984b6e84e14ed550d0
@@ -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}}
data/.gitignore CHANGED
@@ -4,5 +4,6 @@
4
4
  /.yardoc
5
5
  /coverage
6
6
  /.bundle
7
+ /cfg.txt
7
8
 
8
9
  .rubocop-https---raw-githubusercontent-com-discourse-discourse-master--rubocop-yml
@@ -1,14 +1,14 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "config_skeleton"
3
3
 
4
- s.version = "0.2.6"
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', '> 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,22 +205,11 @@ 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
- 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.gauge(:"#{service_name}_last_generation_timestamp", "When the last config generation run was made")
316
- metrics.gauge(:"#{service_name}_last_change_timestamp", "When the config file was last written to")
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({}, Time.now.to_f)
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({}, Time.now.to_f) } }
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
- 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))}" }
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
- if diff.to_s != ""
459
- logger.info(logloc) { "Config has changed. Diff:\n#{diff.to_s}" }
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
- if force_reload || diff.to_s != ""
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({}, File.stat(config_file).mtime.to_f)
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({}, 1)
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({}, Time.now.to_f)
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({}, 0)
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({}, Time.now.to_f)
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.2.6
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: 2020-11-24 00:00:00.000000000 Z
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.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: []