service_skeleton 0.0.0.44.g75d07d7 → 0.0.0.48.g4a40599

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.
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ServiceSkeleton
3
+ module ServiceSkeleton
4
4
  class Error < StandardError
5
- class InvalidEnvironmentError < Error; end
6
5
  class CannotSanitizeEnvironmentError < Error; end
7
- class InheritanceContractError < Error; end
6
+ class InvalidEnvironmentError < Error; end
8
7
  class InvalidMetricNameError < Error; end
8
+ class InvalidServiceClassError < Error; end
9
9
  end
10
10
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "signal_manager"
5
+ require_relative "ultravisor_loggerstash"
6
+
7
+ require "frankenstein/ruby_gc_metrics"
8
+ require "frankenstein/ruby_vm_metrics"
9
+ require "frankenstein/process_metrics"
10
+ require "frankenstein/server"
11
+ require "prometheus/client/registry"
12
+ require "sigdump"
13
+ require "ultravisor"
14
+
15
+ module ServiceSkeleton
16
+ module Generator
17
+ def generate(config:, metrics_registry:, service_metrics:, service_signal_handlers:)
18
+ Ultravisor.new(logger: config.logger).tap do |ultravisor|
19
+ initialize_metrics(ultravisor, config, metrics_registry, service_metrics)
20
+ initialize_loggerstash(ultravisor, config, metrics_registry)
21
+ initialize_signals(ultravisor, config, service_signal_handlers, metrics_registry)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def initialize_metrics(ultravisor, config, registry, metrics)
28
+ Frankenstein::RubyGCMetrics.register(registry)
29
+ Frankenstein::RubyVMMetrics.register(registry)
30
+ Frankenstein::ProcessMetrics.register(registry)
31
+
32
+ metrics.each do |m|
33
+ registry.register(m)
34
+
35
+ method_name = m.method_name(config.service_name)
36
+
37
+ if registry.singleton_class.method_defined?(method_name)
38
+ raise ServiceSkeleton::Error::InvalidMetricNameError,
39
+ "Metric method #{method_name} is already defined"
40
+ end
41
+
42
+ registry.define_singleton_method(method_name) do
43
+ m
44
+ end
45
+ end
46
+
47
+ if config.metrics_port
48
+ config.logger.info(config.service_name) { "Starting metrics server on port #{config.metrics_port}" }
49
+ ultravisor.add_child(
50
+ id: :metrics_server,
51
+ klass: Frankenstein::Server,
52
+ method: :run,
53
+ args: [
54
+ port: config.metrics_port,
55
+ logger: config.logger,
56
+ metrics_prefix: :"#{config.service_name}_metrics_server",
57
+ registry: registry,
58
+ ]
59
+ )
60
+ end
61
+ end
62
+
63
+ def initialize_loggerstash(ultravisor, config, registry)
64
+ if config.logstash_server && !config.logstash_server.empty?
65
+ config.logger.info(config.service_name) { "Configuring loggerstash to send to #{config.logstash_server}" }
66
+
67
+ ultravisor.add_child(
68
+ id: :logstash_writer,
69
+ klass: LogstashWriter,
70
+ method: :run,
71
+ args: [
72
+ server_name: config.logstash_server,
73
+ metrics_registry: registry,
74
+ logger: config.logger,
75
+ ],
76
+ access: :unsafe
77
+ )
78
+
79
+ config.logger.singleton_class.prepend(Loggerstash::Mixin)
80
+
81
+ config.logger.instance_variable_set(:@ultravisor, ultravisor)
82
+ config.logger.singleton_class.prepend(ServiceSkeleton::UltravisorLoggerstash)
83
+ end
84
+ end
85
+
86
+ def initialize_signals(ultravisor, config, service_signals, metrics_registry)
87
+ counter = metrics_registry.counter(:"#{config.service_name}_signals_handled_total", docstring: "How many of each signal have been handled", labels: %i{signal})
88
+
89
+ ultravisor.add_child(
90
+ id: :signal_manager,
91
+ klass: ServiceSkeleton::SignalManager,
92
+ method: :run,
93
+ args: [
94
+ logger: config.logger,
95
+ counter: counter,
96
+ signals: global_signals(ultravisor) + wrap_service_signals(service_signals, ultravisor),
97
+ ],
98
+ shutdown: {
99
+ method: :shutdown,
100
+ timeout: 1,
101
+ }
102
+ )
103
+ end
104
+
105
+ def global_signals(ultravisor)
106
+ # For mysterious reasons of mystery, simplecov doesn't recognise these
107
+ # procs as being called, even though there are definitely tests for
108
+ # them. So...
109
+ #:nocov:
110
+ [
111
+ [
112
+ "USR1",
113
+ ->() {
114
+ logger.level -= 1 unless logger.level == Logger::DEBUG
115
+ logger.info($0) { "Received SIGUSR1; log level is now #{Logger::SEV_LABEL[logger.level]}." }
116
+ }
117
+ ],
118
+ [
119
+ "USR2",
120
+ ->() {
121
+ logger.level += 1 unless logger.level == Logger::ERROR
122
+ logger.info($0) { "Received SIGUSR2; log level is now #{Logger::SEV_LABEL[logger.level]}." }
123
+ }
124
+ ],
125
+ [
126
+ "HUP",
127
+ ->() {
128
+ logger.reopen
129
+ logger.info($0) { "Received SIGHUP; log file handle reopened" }
130
+ }
131
+ ],
132
+ [
133
+ "QUIT",
134
+ ->() { Sigdump.dump("+") }
135
+ ],
136
+ [
137
+ "INT",
138
+ ->() {
139
+ ultravisor.shutdown(wait: false, force: !!@shutting_down)
140
+ @shutting_down = true
141
+ }
142
+ ],
143
+ [
144
+ "TERM",
145
+ ->() {
146
+ ultravisor.shutdown(wait: false, force: !!@shutting_down)
147
+ @shutting_down = true
148
+ }
149
+ ]
150
+ ]
151
+ #:nocov:
152
+ end
153
+
154
+ def wrap_service_signals(signals, ultravisor)
155
+ [].tap do |signal_list|
156
+ signals.each do |service_name, sigs|
157
+ sigs.each do |sig, proc|
158
+ wrapped_proc = ->() { ultravisor[service_name.to_sym].unsafe_instance.instance_eval(&proc) }
159
+ signal_list << [sig, wrapped_proc]
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -1,23 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ServiceSkeleton
3
+ module ServiceSkeleton
4
4
  module LoggingHelpers
5
5
  private
6
6
 
7
7
  def log_exception(ex, progname = nil)
8
+ #:nocov:
8
9
  progname ||= "#{self.class.to_s}##{caller_locations(2, 1).first.label}"
9
10
 
10
11
  logger.error(progname) do
11
- #:nocov:
12
12
  explanation = if block_given?
13
13
  yield
14
14
  else
15
15
  nil
16
16
  end
17
- #:nocov:
18
17
 
19
18
  (["#{explanation}#{explanation ? ": " : ""}#{ex.message} (#{ex.class})"] + ex.backtrace).join("\n ")
20
19
  end
20
+ #:nocov:
21
21
  end
22
22
 
23
23
  def logloc
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceSkeleton
4
+ module MetricMethodName
5
+ def method_name(svc_name)
6
+ @name.to_s.gsub(/\A#{Regexp.quote(svc_name)}_/i, '').downcase
7
+ end
8
+ end
9
+ end
@@ -1,24 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ServiceSkeleton
3
+ require "prometheus/client"
4
+
5
+ require_relative "metric_method_name"
6
+
7
+ Prometheus::Client::Metric.include(ServiceSkeleton::MetricMethodName)
8
+
9
+ module ServiceSkeleton
4
10
  module MetricsMethods
5
- def service=(svc)
6
- @service = svc
11
+ def registered_metrics
12
+ @registered_metrics || []
7
13
  end
8
14
 
9
- def register(metric)
10
- method_name = metric.name.to_s.gsub(/\A#{Regexp.quote(@service.service_name)}_/, '').to_sym
15
+ def metric(metric)
16
+ @registered_metrics ||= []
11
17
 
12
- if self.class.method_defined?(method_name)
13
- raise ServiceSkeleton::Error::InvalidMetricNameError,
14
- "There is already a method named #{method_name} on ##metrics, so you can't have a metric named #{metric.name}"
15
- end
18
+ @registered_metrics << metric
19
+ end
16
20
 
17
- define_singleton_method(method_name) do
18
- metric
19
- end
21
+ def counter(name, docstring:, labels: [], preset_labels: {})
22
+ metric(Prometheus::Client::Counter.new(name, docstring: docstring, labels: labels, preset_labels: preset_labels))
23
+ end
24
+
25
+ def gauge(name, docstring:, labels: [], preset_labels: {})
26
+ metric(Prometheus::Client::Gauge.new(name, docstring: docstring, labels: labels, preset_labels: preset_labels))
27
+ end
28
+
29
+ def summary(name, docstring:, labels: [], preset_labels: {})
30
+ metric(Prometheus::Client::Summary.new(name, docstring: docstring, labels: labels, preset_labels: preset_labels))
31
+ end
20
32
 
21
- super
33
+ def histogram(name, docstring:, labels: [], preset_labels: {}, buckets: Prometheus::Client::Histogram::DEFAULT_BUCKETS)
34
+ metric(Prometheus::Client::Histogram.new(name, docstring: docstring, labels: labels, preset_labels: preset_labels, buckets: buckets))
22
35
  end
23
36
  end
24
37
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "logging_helpers"
5
+ require_relative "signal_manager"
6
+
7
+ require "frankenstein/ruby_gc_metrics"
8
+ require "frankenstein/ruby_vm_metrics"
9
+ require "frankenstein/process_metrics"
10
+ require "frankenstein/server"
11
+ require "prometheus/client/registry"
12
+ require "sigdump"
13
+ require "ultravisor"
14
+
15
+ module ServiceSkeleton
16
+ class Runner
17
+ include ServiceSkeleton::LoggingHelpers
18
+
19
+ def initialize(klass, env)
20
+ @config = (klass.config_class || ServiceSkeleton::Config).new(env, klass.service_name, klass.registered_variables)
21
+ @logger = @config.logger
22
+
23
+ @metrics_registry = Prometheus::Client::Registry.new
24
+
25
+ @ultravisor = ServiceSkeleton.generate(
26
+ config: @config,
27
+ metrics_registry: @metrics_registry,
28
+ service_metrics: klass.registered_metrics,
29
+ service_signal_handlers: { klass.service_name.to_sym => klass.registered_signal_handlers }
30
+ )
31
+
32
+ klass.register_ultravisor_children(@ultravisor, config: @config, metrics_registry: @metrics_registry)
33
+ end
34
+
35
+ def run
36
+ logger.info(logloc) { "Starting service #{@config.service_name}" }
37
+ logger.info(logloc) { (["Environment:"] + @config.env.map { |k, v| "#{k}=#{v.inspect}" }).join("\n ") }
38
+
39
+ @ultravisor.run
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :logger
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceSkeleton
4
+ module ServiceName
5
+ def service_name
6
+ service_name_from_class(self)
7
+ end
8
+
9
+ private
10
+
11
+ def service_name_from_class(klass)
12
+ klass.to_s
13
+ .gsub("::", "_")
14
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
15
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
16
+ .downcase
17
+ .gsub(/[^a-zA-Z0-9_]/, "_")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./logging_helpers"
4
+
5
+ module ServiceSkeleton
6
+ # Manage signals in a sane and safe manner.
7
+ #
8
+ # Signal handling is a shit of a thing. The code that runs when a signal is
9
+ # triggered can't use mutexes (which are used in all sorts of places you
10
+ # might not expect, like Logger!) or anything else that might block. This
11
+ # greatly constrains what you can do inside a signal handler, so the standard
12
+ # approach is to stuff a character down a pipe, and then have the *real*
13
+ # signal handling run later.
14
+ #
15
+ # Also, there's always the (slim) possibility that something else might have
16
+ # hooked into a signal we want to receive. Because only a single signal
17
+ # handler can be active for a given signal at a time, we need to "chain" the
18
+ # existing handler, by calling the previous signal handler from our signal
19
+ # handler after we've done what we need to do. This class takes care of
20
+ # that, too, because it's a legend.
21
+ #
22
+ # So that's what this class does: it allows you to specify signals and
23
+ # associated blocks of code to run, it sets up signal handlers which send
24
+ # notifications to a background thread and chain correctly, and it manages
25
+ # the background thread to receive the notifications and execute the
26
+ # associated blocks of code outside of the context of the signal handler.
27
+ #
28
+ class SignalManager
29
+ include ServiceSkeleton::LoggingHelpers
30
+
31
+ # Setup a signal handler instance.
32
+ #
33
+ # @param logger [Logger] the logger to use for all the interesting information
34
+ # about what we're up to.
35
+ #
36
+ def initialize(logger:, counter:, signals:)
37
+ @logger, @signal_counter, @signal_list = logger, counter, signals
38
+
39
+ @registry = Hash.new { |h, k| h[k] = SignalHandler.new(k) }
40
+
41
+ @signal_list.each do |sig, proc|
42
+ @registry[signum(sig)] << proc
43
+ end
44
+ end
45
+
46
+ def run
47
+ logger.info(logloc) { "Starting signal manager for #{@signal_list.length} signals" }
48
+
49
+ @r, @w = IO.pipe
50
+
51
+ install_signal_handlers
52
+
53
+ signals_loop
54
+ ensure
55
+ remove_signal_handlers
56
+ end
57
+
58
+ def shutdown
59
+ @r.close
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :logger
65
+
66
+ def signals_loop
67
+ #:nocov:
68
+ loop do
69
+ begin
70
+ if ios = IO.select([@r])
71
+ if ios.first.include?(@r)
72
+ if ios.first.first.eof?
73
+ logger.info(logloc) { "Signal pipe closed; shutting down" }
74
+ break
75
+ else
76
+ c = ios.first.first.read_nonblock(1)
77
+ logger.debug(logloc) { "Received character #{c.inspect} from signal pipe" }
78
+ handle_signal(c)
79
+ end
80
+ else
81
+ logger.error(logloc) { "Mysterious return from select: #{ios.inspect}" }
82
+ end
83
+ end
84
+ rescue IOError
85
+ # Something has gone terribly wrong here... bail
86
+ break
87
+ rescue StandardError => ex
88
+ log_exception(ex) { "Exception in select loop" }
89
+ end
90
+ end
91
+ #:nocov:
92
+ end
93
+
94
+ # Given a character (presumably) received via the signal pipe, execute the
95
+ # associated handler.
96
+ #
97
+ # @param char [String] a single character, corresponding to an entry in the
98
+ # signal registry.
99
+ #
100
+ # @return [void]
101
+ #
102
+ def handle_signal(char)
103
+ if @registry.has_key?(char.ord)
104
+ handler = @registry[char.ord]
105
+ logger.debug(logloc) { "#{handler.signame} received" }
106
+ @signal_counter.increment(labels: { signal: handler.signame.to_s })
107
+
108
+ begin
109
+ handler.call
110
+ rescue StandardError => ex
111
+ log_exception(ex) { "Exception while calling signal handler" }
112
+ end
113
+ else
114
+ logger.error(logloc) { "Unrecognised signal character: #{char.inspect}" }
115
+ end
116
+ end
117
+
118
+ def install_signal_handlers
119
+ @registry.values.each do |h|
120
+ h.write_pipe = @w
121
+ h.hook
122
+ end
123
+ end
124
+
125
+ def signum(spec)
126
+ if spec.is_a?(Integer)
127
+ return spec
128
+ end
129
+
130
+ if spec.is_a?(Symbol)
131
+ str = spec.to_s
132
+ elsif spec.is_a?(String)
133
+ str = spec.dup
134
+ else
135
+ raise ArgumentError,
136
+ "Unsupported class (#{spec.class}) of signal specifier #{spec.inspect}"
137
+ end
138
+
139
+ str.sub!(/\ASIG/i, '')
140
+
141
+ if Signal.list[str.upcase]
142
+ Signal.list[str.upcase]
143
+ else
144
+ raise ArgumentError,
145
+ "Unrecognised signal specifier #{spec.inspect}"
146
+ end
147
+ end
148
+
149
+ def remove_signal_handlers
150
+ @registry.values.each { |h| h.unhook }
151
+ end
152
+
153
+ class SignalHandler
154
+ attr_reader :signame
155
+ attr_writer :write_pipe
156
+
157
+ def initialize(signum)
158
+ @signum = signum
159
+ @callbacks = []
160
+
161
+ @signame = Signal.list.invert[@signum]
162
+ end
163
+
164
+ def <<(proc)
165
+ @callbacks << proc
166
+ end
167
+
168
+ def call
169
+ @callbacks.each { |cb| cb.call }
170
+ end
171
+
172
+ def hook
173
+ @handler = ->(_) do
174
+ #:nocov:
175
+ @write_pipe.write_nonblock(@signum.chr) rescue nil
176
+ @chain.call if @chain.respond_to?(:call)
177
+ #:nocov:
178
+ end
179
+
180
+ @chain = Signal.trap(@signum, &@handler)
181
+ end
182
+
183
+ def unhook
184
+ #:nocov:
185
+ tmp_handler = Signal.trap(@signum, "IGNORE")
186
+ if tmp_handler == @handler
187
+ # The current handler is ours, so we can replace it
188
+ # with the chained handler
189
+ Signal.trap(@signum, @chain)
190
+ else
191
+ # The current handler *isn't* ours, so we better
192
+ # put it back, because whoever owns it might get
193
+ # angry.
194
+ Signal.trap(@signum, tmp_handler)
195
+ end
196
+ #:nocov:
197
+ end
198
+ end
199
+
200
+ private_constant :SignalHandler
201
+ end
202
+ end