sitegoose 0.3.3

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.
@@ -0,0 +1,523 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What every module shares: the site's credentials, sending to the
4
+ # collector, logging, background jobs, and the dashboard's module switches
5
+ # (Site settings -> Server, GET /events/platform/config about once a minute).
6
+ #
7
+ # A module the dashboard turns off stays loaded but does nothing; until the
8
+ # switches arrive, or when they can't be fetched, every loaded module stays on.
9
+ # Sending never raises into the app. Background jobs are threads that start
10
+ # again in a forked worker (Puma, Unicorn, Passenger), where threads don't
11
+ # survive: through Process._fork (Ruby 3.1+), and, for Ruby 3.0, by the
12
+ # middleware noticing the process ID changed.
13
+
14
+ require "json"
15
+ require "net/http"
16
+ require "openssl"
17
+ require "socket"
18
+ require "uri"
19
+ require "etc"
20
+ require "time"
21
+
22
+ module SiteGoose
23
+ TIMEOUT = 5
24
+ MIN_SYNC = 30
25
+ RETRY = 60
26
+ SESSION_HEADER = "x-analytics-session"
27
+ RUNTIME = "Ruby #{RUBY_VERSION}"
28
+
29
+ # A string as UTF-8 that JSON can carry: anything else (binary, a broken
30
+ # encoding) is mended rather than raising on the way out. max: characters.
31
+ def self.text(value, max = nil)
32
+ s = value.nil? ? +"" : value.to_s
33
+ s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
34
+ s = s.scrub("�") unless s.valid_encoding?
35
+ max ? s[0, max] : s
36
+ end
37
+
38
+ def self.iso_now(at = nil)
39
+ (at || Time.now).utc.iso8601(3)
40
+ end
41
+
42
+ def self.clock
43
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ end
45
+
46
+ # At exit (and Sidekiq's shutdown) everything waiting gets one overall
47
+ # deadline, so the package never outlasts a container's or PM2's grace
48
+ # period: each send waits at most what's left, none starts after it, and
49
+ # what wasn't sent waits in the errors file.
50
+ EXIT_DEADLINE = 5
51
+ @deadline = nil
52
+
53
+ class << self
54
+ # Runs the block with every send (and job stop) inside seconds from now.
55
+ def within(seconds = EXIT_DEADLINE)
56
+ before = @deadline
57
+ ends = clock + seconds
58
+ @deadline = before ? [before, ends].min : ends
59
+ yield
60
+ ensure
61
+ @deadline = before
62
+ end
63
+
64
+ # Seconds left before the deadline, or nil when there is none.
65
+ def time_left
66
+ ends = @deadline
67
+ ends ? [ends - clock, 0.0].max : nil
68
+ end
69
+
70
+ # How long one send may wait: normal, cut to what's left of a deadline,
71
+ # nil once it has passed (nothing more is sent).
72
+ def send_timeout(normal)
73
+ left = time_left
74
+ return normal if left.nil?
75
+ return nil if left < 0.05
76
+
77
+ [normal, left].min
78
+ end
79
+ end
80
+
81
+ # A header from a hash with lowercase names ("" when missing).
82
+ def self.header(headers, name)
83
+ value = headers && headers[name.downcase]
84
+ value.is_a?(String) ? value.strip : ""
85
+ end
86
+
87
+ def self.hostname
88
+ Socket.gethostname.to_s
89
+ rescue StandardError
90
+ ""
91
+ end
92
+
93
+ # Options given as a hash with string or symbol keys -> symbol keys.
94
+ def self.symbolize(options)
95
+ return {} unless options.is_a?(Hash)
96
+
97
+ options.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
98
+ end
99
+
100
+ Response = Struct.new(:status, :headers, :body) do
101
+ def ok?
102
+ status >= 200 && status < 300
103
+ end
104
+
105
+ def json
106
+ JSON.parse(body.to_s.empty? ? "null" : body)
107
+ rescue JSON::ParserError, TypeError
108
+ nil
109
+ end
110
+ end
111
+
112
+ # Fork safety. Two things go wrong when a process forks while a background
113
+ # thread works: the thread is gone in the child, and a send in flight there
114
+ # may hold a lock the child then waits on forever. So a fork waits (at most
115
+ # a send's timeout) for the sends in flight, a send waits for a fork, and
116
+ # in the child every background job starts again.
117
+ module Forking
118
+ @mutex = Mutex.new
119
+ @cond = ConditionVariable.new
120
+ @in_flight = 0
121
+ @forking = false
122
+ @pid = Process.pid
123
+ @children = []
124
+ CHECK_LOCK = Mutex.new
125
+
126
+ class << self
127
+ attr_reader :pid
128
+
129
+ def register(item)
130
+ @mutex.synchronize { @children << item }
131
+ end
132
+
133
+ def unregister(item)
134
+ @mutex.synchronize { @children.delete(item) }
135
+ end
136
+
137
+ def sending
138
+ deadline = SiteGoose.clock + TIMEOUT
139
+ @mutex.synchronize do
140
+ while @forking && (left = deadline - SiteGoose.clock) > 0
141
+ @cond.wait(@mutex, left)
142
+ end
143
+ @in_flight += 1
144
+ end
145
+ begin
146
+ yield
147
+ ensure
148
+ @mutex.synchronize do
149
+ @in_flight -= 1 if @in_flight.positive?
150
+ @cond.broadcast
151
+ end
152
+ end
153
+ end
154
+
155
+ def before_fork
156
+ deadline = SiteGoose.clock + TIMEOUT + 1
157
+ @mutex.synchronize do
158
+ @forking = true
159
+ while @in_flight.positive? && (left = deadline - SiteGoose.clock) > 0
160
+ @cond.wait(@mutex, left)
161
+ end
162
+ end
163
+ rescue StandardError, ThreadError
164
+ nil
165
+ end
166
+
167
+ def after_fork_in_parent
168
+ @mutex.synchronize do
169
+ @forking = false
170
+ @cond.broadcast
171
+ end
172
+ @mutex.synchronize { @children.dup }.each { |c| c.respond_to?(:after_fork_in_parent) && c.after_fork_in_parent }
173
+ rescue StandardError, ThreadError
174
+ nil
175
+ end
176
+
177
+ # In the child (or, on Ruby 3.0, the first time a request sees a new
178
+ # process ID): fresh locks, and every job started again.
179
+ def after_fork_in_child
180
+ @mutex = Mutex.new
181
+ @cond = ConditionVariable.new
182
+ @in_flight = 0
183
+ @forking = false
184
+ @children.dup.each do |child|
185
+ child.after_fork_in_child
186
+ rescue StandardError
187
+ nil
188
+ end
189
+ # Last, so a request that checks meanwhile waits for the restart
190
+ # rather than running on the parent's queues.
191
+ @pid = Process.pid
192
+ end
193
+
194
+ # The Ruby 3.0 path, and a safety net on every other: a fork that went
195
+ # around Process._fork. Under a lock, so two requests arriving at a new
196
+ # worker at once restart its threads once. (A thread that held it at
197
+ # the fork doesn't exist in the child, and Ruby releases what a thread
198
+ # left behind there.)
199
+ def check
200
+ return if @pid == Process.pid
201
+
202
+ CHECK_LOCK.synchronize { after_fork_in_child if @pid != Process.pid }
203
+ end
204
+ end
205
+
206
+ # Process._fork is what fork, Process.fork and Kernel#fork all call (Ruby 3.1+).
207
+ module Hook
208
+ def _fork
209
+ Forking.before_fork
210
+ begin
211
+ pid = super
212
+ rescue Exception # rubocop:disable Lint/RescueException -- raised on
213
+ # No child (EAGAIN, out of memory): sends must not keep waiting for one.
214
+ Forking.after_fork_in_parent
215
+ raise
216
+ end
217
+ if pid.zero?
218
+ Forking.after_fork_in_child
219
+ else
220
+ Forking.after_fork_in_parent
221
+ end
222
+ pid
223
+ end
224
+ end
225
+
226
+ Process.singleton_class.prepend(Hook) if Process.respond_to?(:_fork)
227
+ end
228
+
229
+ # Runs the block in a background thread; the block returns the seconds
230
+ # until its next run. Never lets the job die, and starts again after a fork.
231
+ class Periodic
232
+ @running = []
233
+ @lock = Mutex.new
234
+
235
+ class << self
236
+ def running
237
+ @lock.synchronize { @running.dup }
238
+ end
239
+
240
+ def add(job)
241
+ @lock.synchronize { @running << job }
242
+ end
243
+
244
+ def remove(job)
245
+ @lock.synchronize { @running.delete(job) }
246
+ end
247
+ end
248
+
249
+ attr_reader :thread
250
+
251
+ def initialize(first = 0, &job)
252
+ @job = job
253
+ @first = first
254
+ @stopped = false
255
+ start
256
+ Periodic.add(self)
257
+ Forking.register(self)
258
+ end
259
+
260
+ def start
261
+ return if @stopped
262
+
263
+ @mutex = Mutex.new
264
+ @cond = ConditionVariable.new
265
+ @wake = false
266
+ @thread = Thread.new { run }
267
+ @thread.name = "analytics-platform" if @thread.respond_to?(:name=)
268
+ @thread.report_on_exception = false
269
+ end
270
+
271
+ def stopped?
272
+ @stopped
273
+ end
274
+
275
+ def run
276
+ wait = @first
277
+ until @stopped
278
+ @mutex.synchronize do
279
+ @cond.wait(@mutex, wait) if !@wake && !@stopped && wait.positive?
280
+ @wake = false
281
+ end
282
+ break if @stopped
283
+
284
+ wait = begin
285
+ @job.call
286
+ rescue SignalException, SystemExit
287
+ break
288
+ rescue Exception # rubocop:disable Lint/RescueException -- a job never dies
289
+ RETRY
290
+ end
291
+ wait = RETRY unless wait.is_a?(Numeric) && wait >= 0
292
+ end
293
+ end
294
+
295
+ def run_now
296
+ @mutex.synchronize do
297
+ @wake = true
298
+ @cond.signal
299
+ end
300
+ end
301
+
302
+ # Stops the job and waits (up to wait seconds) for its thread to end.
303
+ def stop(wait = 2.0)
304
+ @stopped = true
305
+ begin
306
+ @mutex.synchronize do
307
+ @wake = true
308
+ @cond.signal
309
+ end
310
+ rescue ThreadError
311
+ nil
312
+ end
313
+ Periodic.remove(self)
314
+ Forking.unregister(self)
315
+ thread = @thread
316
+ left = SiteGoose.time_left
317
+ wait = [wait, left].min if wait && left
318
+ thread.join(wait) if wait && wait.positive? && thread&.alive? && thread != Thread.current
319
+ end
320
+
321
+ def after_fork_in_child
322
+ start
323
+ end
324
+ end
325
+
326
+ # This process's resident memory in MB (Linux /proc), or nil.
327
+ def self.rss_mb
328
+ statm = File.read("/proc/self/statm")
329
+ pages = statm.split[1].to_i
330
+ (pages * page_size / 1_048_576.0).round
331
+ rescue StandardError
332
+ nil
333
+ end
334
+
335
+ def self.page_size
336
+ @page_size ||= begin
337
+ Etc.sysconf(Etc::SC_PAGESIZE)
338
+ rescue StandardError
339
+ 4096
340
+ end
341
+ end
342
+
343
+ class Core
344
+ attr_reader :site_id, :secret, :endpoint, :server_name, :user_agent
345
+
346
+ def initialize(site_id, secret, endpoint, debug: false, server_name: nil, logger: nil)
347
+ @site_id = site_id.to_s
348
+ # The name this service checks in under (X-Platform-App, on the switches
349
+ # request): server_name, as every module sends it, else the machine's.
350
+ @server_name = SiteGoose.text(server_name || SiteGoose.hostname, 100)
351
+ @started = Time.now.to_f
352
+ @secret = secret.to_s
353
+ @endpoint = endpoint.to_s.sub(%r{/+\z}, "")
354
+ @valid = @site_id.match?(/\A[a-fA-F0-9]{24}\z/) && !@secret.empty? && !@endpoint.empty?
355
+ @debug = debug ? true : false
356
+ @logger = logger
357
+ @user_agent = "analytics-platform-ruby/#{VERSION}"
358
+ @switches = {}
359
+ @headers = []
360
+ @flags = {}
361
+ @maintenance = { "on" => false }
362
+ @etag = ""
363
+ @loaded = []
364
+ @job = nil
365
+ end
366
+
367
+ def valid?
368
+ @valid
369
+ end
370
+
371
+ def log(mod, *args)
372
+ return unless @debug
373
+
374
+ line = "[analytics-platform:#{mod}] #{args.join(' ')}"
375
+ @logger ? @logger.warn(line) : warn(line)
376
+ rescue StandardError
377
+ nil
378
+ end
379
+
380
+ # JSON to the collector. auth :body puts site_id and sitesecret in the
381
+ # body (the event endpoints), :bearer sends the secret as a header
382
+ # (firewall, switches). Returns a Response, or nil when it couldn't be sent.
383
+ def request(mod, path, method: "POST", body: nil, auth: :body, headers: nil)
384
+ return nil unless @valid
385
+
386
+ uri = URI.parse(@endpoint + path)
387
+ all = { "User-Agent" => @user_agent }
388
+ data = nil
389
+ unless body.nil?
390
+ body = body.merge("site_id" => @site_id, "sitesecret" => @secret) if auth == :body
391
+ data = JSON.generate(body)
392
+ all["Content-Type"] = "application/json"
393
+ end
394
+ all["Authorization"] = "Bearer #{@secret}" if auth == :bearer
395
+ all.merge!(headers) if headers
396
+ klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
397
+ req = klass.new(uri.request_uri, all)
398
+ req.body = data if data
399
+ wait = SiteGoose.send_timeout(TIMEOUT)
400
+ return nil if wait.nil?
401
+
402
+ Forking.sending do
403
+ http = Net::HTTP.new(uri.host, uri.port)
404
+ http.use_ssl = uri.scheme == "https"
405
+ http.open_timeout = wait
406
+ http.read_timeout = wait
407
+ http.write_timeout = wait if http.respond_to?(:write_timeout=)
408
+ res = http.start { |h| h.request(req) }
409
+ status = res.code.to_i
410
+ log(mod, "collector answered #{status} to #{path}") if status >= 400
411
+ Response.new(status, res.each_header.to_h { |k, v| [k.downcase, v] }, res.body.to_s)
412
+ end
413
+ rescue StandardError, Timeout::Error => e
414
+ log(mod, "could not reach the collector (#{path}): #{e.class}: #{e.message}")
415
+ nil
416
+ end
417
+
418
+ # ---- Module switches ----
419
+
420
+ # False only when the dashboard turned the module off.
421
+ def on?(name)
422
+ @switches[name.to_s] != false
423
+ end
424
+ alias is_on on?
425
+
426
+ # This service's check-in, form-encoded so any name is plain ASCII:
427
+ # Health -> All systems shows it running and when it was last heard from.
428
+ # nil when it can't be built: the switches still sync, without it.
429
+ def check_in
430
+ return nil if @server_name.empty?
431
+
432
+ fields = [["server", @server_name], ["kind", "process"], ["pid", Process.pid.to_s],
433
+ ["up_s", (Time.now.to_f - @started).to_i.to_s], ["runtime", RUNTIME]]
434
+ rss = SiteGoose.rss_mb
435
+ fields << ["rss_mb", rss.to_s] if rss
436
+ URI.encode_www_form(fields)
437
+ rescue StandardError
438
+ nil
439
+ end
440
+
441
+ HEADER_NAME = /\A[A-Za-z][A-Za-z0-9-]{0,63}\z/.freeze
442
+
443
+ def sync_switches
444
+ headers = { "X-Platform-Modules" => @loaded.join(",") }
445
+ app = check_in
446
+ headers["X-Platform-App"] = app if app
447
+ headers["If-None-Match"] = @etag unless @etag.empty?
448
+ res = request("core", "/events/platform/config?site_id=#{@site_id}", method: "GET", auth: :bearer, headers: headers)
449
+ return RETRY unless res && res.status == 200
450
+
451
+ data = res.json
452
+ data = {} unless data.is_a?(Hash)
453
+ if data["v"] == 1 && data["modules"].is_a?(Hash)
454
+ @switches = data["modules"]
455
+ @flags = data["flags"].is_a?(Hash) ? data["flags"] : {}
456
+ @maintenance = data["maintenance"].is_a?(Hash) ? data["maintenance"] : { "on" => false }
457
+ # Only well-formed headers (no line breaks can get through).
458
+ @headers = (data["headers"].is_a?(Array) ? data["headers"] : []).select do |pair|
459
+ pair.is_a?(Array) && pair.size == 2 && pair[0].is_a?(String) && pair[0].match?(HEADER_NAME) &&
460
+ pair[1].is_a?(String) && !pair[1].include?("\r") && !pair[1].include?("\n") && pair[1].size <= 4000
461
+ end
462
+ @etag = res.headers["etag"].to_s
463
+ end
464
+ [MIN_SYNC, (data["sync"].is_a?(Numeric) && data["sync"].positive? ? data["sync"].to_i : RETRY)].max
465
+ end
466
+
467
+ def flags
468
+ @flags
469
+ end
470
+
471
+ def maintenance
472
+ @maintenance
473
+ end
474
+
475
+ # The security headers picked on the dashboard, when the module is on.
476
+ def security_headers
477
+ on?("headers") ? @headers.dup : []
478
+ end
479
+
480
+ # Starts fetching the switches; names are the modules the code loaded.
481
+ def start(names)
482
+ @loaded = names.map(&:to_s)
483
+ @job = Periodic.new { sync_switches } if @valid
484
+ end
485
+
486
+ def stop
487
+ @job&.stop
488
+ end
489
+
490
+ # For tests: what the dashboard said.
491
+ def switches=(value)
492
+ @switches = value
493
+ end
494
+ end
495
+
496
+ # The characters a path may carry unencoded (RFC 3986).
497
+ PATH_SAFE = %r{[^A-Za-z0-9/:@!$&'()*+,;=\-._~%]}.freeze
498
+
499
+ # The request target as the visitor sent it, query string and all, which
500
+ # is what the request rules match (sdk/protocols/firewall.md). Rack's
501
+ # PATH_INFO is not decoded, and Puma, Unicorn and WEBrick keep the target
502
+ # itself as REQUEST_URI, so it is taken as it came; an absolute-form target
503
+ # (a proxy request) loses its scheme and host.
504
+ def self.request_target(env)
505
+ raw = env["REQUEST_URI"]
506
+ if raw.is_a?(String) && !raw.empty?
507
+ target = raw.sub(%r{\A[a-zA-Z][a-zA-Z0-9+.-]*://[^/?#]*}, "")
508
+ target = "/#{target}" unless target.start_with?("/") || target.start_with?("*")
509
+ else
510
+ target = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
511
+ target = target.b.gsub(PATH_SAFE) { |c| format("%%%02X", c.ord) }
512
+ end
513
+ query = env["QUERY_STRING"].to_s
514
+ target = "#{target}?#{query}" if !query.empty? && !target.include?("?")
515
+ text(target)
516
+ end
517
+
518
+ # A path with its percent-escapes decoded once, as text (for display and
519
+ # route names, never for the rules).
520
+ def self.decode_path(path)
521
+ text(path.to_s.b.gsub(/%[0-9a-fA-F]{2}/) { |m| m[1, 2].to_i(16).chr })
522
+ end
523
+ end