config_skeleton 0.2.6 → 1.0.0

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