railwatch-server 0.1.0-x86_64-linux

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,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Server
5
+ # Sockets the proxy has taken from Puma, moved by one thread per app
6
+ # process with IO.select instead of a Puma thread (or two threads) per
7
+ # connection. Two kinds:
8
+ #
9
+ # - live: the live channel (the dashboard's websocket, one per open tab),
10
+ # spliced both ways until either side closes (#add).
11
+ # - proxied: every other dashboard request (#proxy). The request, which
12
+ # Puma has read in full, is written to the server; the response is
13
+ # written back to the browser as it arrives, byte for byte except its
14
+ # head (Connection: close, the app's own Set-Cookie added: ProxyHandoff), and then
15
+ # both sockets close. A slow page waits here, not on a Puma thread.
16
+ #
17
+ # Each kind has its own cap (Configuration#max_live_connections,
18
+ # #max_proxied_requests). Each direction buffers at most MAX_BUFFER bytes
19
+ # for a slow reader before its source stops being read.
20
+ #
21
+ # A proxied request the server has not started answering within
22
+ # first_byte_timeout gets a 504, one it stops answering before sending a
23
+ # byte a 502, both written here; one that goes quiet for idle_timeout
24
+ # after its first byte is cut off.
25
+ class Relay
26
+ CHUNK = 16 * 1024
27
+ MAX_BUFFER = 1024 * 1024
28
+ MAX_HEAD = 64 * 1024
29
+ HOP_BY_HOP = %w[connection keep-alive proxy-connection].freeze
30
+ # The server's own HSTS (sent behind a trusted proxy with an https
31
+ # url): the host is the app's, and so is its policy (ProxyHandoff
32
+ # passes the app's on).
33
+ APP_OWNED = %w[strict-transport-security].freeze
34
+ REASONS = { 502 => "Bad Gateway", 504 => "Gateway Timeout" }.freeze
35
+
36
+ # input: what this side's peer is sent instead of what is read from io
37
+ # (a proxied request's body, from Puma's tempfile).
38
+ Side = Struct.new(:io, :peer, :pending, :eof, :input, :pair)
39
+ # head: the proxied response's head while it arrives; nil once it has
40
+ # been passed on (or the relay answered instead).
41
+ Pair = Struct.new(:kind, :left, :right, :started_at, :active_at, :head, :extra_headers)
42
+
43
+ class << self
44
+ # One per process: a forked Puma worker starts its own.
45
+ def current
46
+ @mutex ||= Mutex.new
47
+ @mutex.synchronize do
48
+ @current = nil unless @pid == ::Process.pid
49
+ @pid = ::Process.pid
50
+ config = Railwatch::Server.config
51
+ @current ||= new(max_live: config.max_live_connections, max_proxied: config.max_proxied_requests,
52
+ first_byte_timeout: config.proxy_first_byte_timeout, idle_timeout: config.proxy_idle_timeout)
53
+ end
54
+ end
55
+ end
56
+
57
+ def initialize(max_live: 64, max_proxied: 64, first_byte_timeout: 120, idle_timeout: 60)
58
+ @max = { live: max_live, proxied: max_proxied }
59
+ @first_byte_timeout = first_byte_timeout
60
+ @idle_timeout = idle_timeout
61
+ @sides = {}
62
+ @pairs = []
63
+ @mutex = Mutex.new
64
+ @wake_r, @wake_w = IO.pipe
65
+ @thread = nil
66
+ end
67
+
68
+ def max(kind = :live) = @max.fetch(kind)
69
+
70
+ def size(kind = :live) = @mutex.synchronize { @pairs.count { _1.kind == kind } }
71
+
72
+ def full?(kind) = size(kind) >= @max.fetch(kind)
73
+
74
+ # Splices `a` and `b` until either closes. False when the live cap is
75
+ # reached; the caller still owns both sockets then.
76
+ def add(a, b)
77
+ @mutex.synchronize do
78
+ return false if @pairs.count { _1.kind == :live } >= @max[:live]
79
+
80
+ register(:live, a, b)
81
+ end
82
+ wake
83
+ true
84
+ end
85
+
86
+ # One proxied request: `request` (its head, and its body when small) and
87
+ # then `body` (an IO, read to its end) go to the server on `upstream`;
88
+ # the server's response goes to `client` with `headers` ([name, value]
89
+ # pairs: the app's Set-Cookie) added to its head; then all three close. The relay owns them
90
+ # from here. Never refuses: the proxy checks #full? first, so the cap
91
+ # can be passed by as many requests as are checked at the same moment.
92
+ def proxy(client, upstream, request:, body: nil, headers: [])
93
+ @mutex.synchronize do
94
+ pair = register(:proxied, client, upstream)
95
+ pair.head = +"".b
96
+ pair.extra_headers = headers
97
+ pair.right.pending << request.b
98
+ pair.left.eof = body.nil?
99
+ if body
100
+ pair.left.input = body
101
+ @sides[body] = pair.left
102
+ end
103
+ end
104
+ wake
105
+ true
106
+ end
107
+
108
+ private
109
+
110
+ def register(kind, a, b)
111
+ left = Side.new(a, nil, +"".b, false)
112
+ right = Side.new(b, left, +"".b, false)
113
+ left.peer = right
114
+ now = monotonic
115
+ pair = Pair.new(kind, left, right, now, now)
116
+ left.pair = right.pair = pair
117
+ @sides[a] = left
118
+ @sides[b] = right
119
+ @pairs << pair
120
+ start_thread
121
+ pair
122
+ end
123
+
124
+ def start_thread
125
+ return if @thread&.alive?
126
+
127
+ @thread = Thread.new { run }
128
+ @thread.name = "railwatch-server-relay"
129
+ @thread.report_on_exception = false
130
+ end
131
+
132
+ def wake
133
+ @wake_w.write_nonblock(".", exception: false)
134
+ end
135
+
136
+ def run
137
+ loop do
138
+ readers, writers, timed = @mutex.synchronize do
139
+ sides = @pairs.flat_map { [ _1.left, _1.right ] }
140
+ [ sides.select { |s| !s.eof && s.peer.pending.bytesize < MAX_BUFFER }.map { |s| s.input || s.io },
141
+ sides.reject { |s| s.pending.empty? }.map(&:io),
142
+ @pairs.any? { _1.kind == :proxied } ]
143
+ end
144
+ readable, writable, = IO.select(readers + [ @wake_r ], writers, nil, timed ? 0.5 : 30)
145
+ if readable
146
+ @wake_r.read_nonblock(1024, exception: false) if readable.delete(@wake_r)
147
+ writable.each { |io| flush(io) }
148
+ readable.each { |io| pull(io) }
149
+ end
150
+ expire if timed
151
+ end
152
+ rescue StandardError => e
153
+ warn("[railwatch-server] relay stopped: #{e.class}: #{e.message}")
154
+ @mutex.synchronize do
155
+ @sides.each_key { |io| io.close rescue nil }.clear
156
+ @pairs.clear
157
+ end
158
+ end
159
+
160
+ def pull(io)
161
+ side = @mutex.synchronize { @sides[io] }
162
+ return unless side
163
+
164
+ pair = side.pair
165
+ data = io.read_nonblock(CHUNK, exception: false)
166
+ case data
167
+ when :wait_readable, :wait_writable then nil
168
+ when nil
169
+ side.eof = true
170
+ if side.input
171
+ @mutex.synchronize { @sides.delete(io) }
172
+ io.close
173
+ side.input = nil
174
+ end
175
+ return fail_pair(pair, 502, "closed the connection without answering") if side.equal?(pair.right) && pair.head
176
+
177
+ close_if_done(side)
178
+ else
179
+ pair.active_at = monotonic
180
+ if side.equal?(pair.right) && pair.head
181
+ pair.head << data
182
+ split = pair.head.index("\r\n\r\n")
183
+ unless split
184
+ fail_pair(pair, 502, "sent a response head over #{MAX_HEAD} bytes") if pair.head.bytesize > MAX_HEAD
185
+ return
186
+ end
187
+ data = response_head(pair.head.byteslice(0, split), pair.extra_headers) + pair.head.byteslice((split + 4)..)
188
+ pair.head = nil
189
+ end
190
+ side.peer.pending << data
191
+ flush(side.peer.io)
192
+ end
193
+ rescue IOError, SystemCallError => e
194
+ side.equal?(pair.right) ? fail_pair(pair, 502, "dropped the connection (#{e.class})") : close_pair(pair)
195
+ end
196
+
197
+ def flush(io)
198
+ side = @mutex.synchronize { @sides[io] }
199
+ return unless side
200
+ return if side.pending.empty?
201
+
202
+ written = io.write_nonblock(side.pending, exception: false)
203
+ if written.is_a?(Integer)
204
+ side.pending = side.pending.byteslice(written..)
205
+ side.pair.active_at = monotonic
206
+ end
207
+ close_if_done(side)
208
+ rescue IOError, SystemCallError => e
209
+ side.equal?(side.pair.right) ? fail_pair(side.pair, 502, "dropped the connection (#{e.class})") : close_pair(side.pair)
210
+ end
211
+
212
+ # Live: one side closed, what it sent is delivered to the other, then
213
+ # both go (a websocket's close frame is data, so nothing is cut short).
214
+ # Proxied: the server has finished its response and the browser has all
215
+ # of it.
216
+ def close_if_done(side)
217
+ pair = side.pair
218
+ done = if pair.kind == :live
219
+ (side.eof && side.peer.pending.empty?) || (side.peer.eof && side.pending.empty?)
220
+ else
221
+ pair.right.eof && pair.left.pending.empty?
222
+ end
223
+ close_pair(pair) if done
224
+ end
225
+
226
+ # The server's response head, for a connection that closes after it:
227
+ # the body is passed on as sent (its Content-Length or chunked framing
228
+ # included), so only the connection headers change.
229
+ def response_head(head, extra)
230
+ status, *fields = head.split("\r\n")
231
+ fields.reject! { |field| (HOP_BY_HOP + APP_OWNED).include?(field[/\A[^:]*/].strip.downcase) }
232
+ fields.concat(extra.map { |name, value| "#{name}: #{value}" })
233
+ "#{status}\r\n#{fields.map { "#{_1}\r\n" }.join}connection: close\r\n\r\n".b
234
+ end
235
+
236
+ # A proxied request the server failed: before the response started, the
237
+ # browser gets `status` with why, then the connection closes; after, it
238
+ # is cut short (a browser sees a truncated body as a failed load).
239
+ def fail_pair(pair, status, why)
240
+ return close_pair(pair) unless pair.kind == :proxied && pair.head && @mutex.synchronize { @pairs.include?(pair) }
241
+
242
+ message = "The Railwatch server #{why}. Its log is in the app's storage/railwatch/<env>/server.log.\n"
243
+ warn("[railwatch-server] dashboard request: #{status}: the server #{why}")
244
+ pair.head = nil
245
+ pair.active_at = monotonic
246
+ pair.right.eof = true
247
+ pair.right.pending.clear
248
+ pair.left.eof = true
249
+ if (input = pair.left.input)
250
+ @mutex.synchronize { @sides.delete(input) }
251
+ input.close rescue nil
252
+ pair.left.input = nil
253
+ end
254
+ pair.left.pending << "HTTP/1.1 #{status} #{REASONS[status]}\r\nContent-Type: text/plain; charset=utf-8\r\n" \
255
+ "Content-Length: #{message.bytesize}\r\nConnection: close\r\n\r\n#{message}"
256
+ flush(pair.left.io)
257
+ end
258
+
259
+ def expire
260
+ now = monotonic
261
+ @mutex.synchronize { @pairs.select { _1.kind == :proxied } }.each do |pair|
262
+ if pair.head && now - pair.started_at > @first_byte_timeout
263
+ fail_pair(pair, 504, "did not answer within #{@first_byte_timeout} s (proxy_first_byte_timeout)")
264
+ elsif !pair.head && now - pair.active_at > @idle_timeout
265
+ warn("[railwatch-server] dashboard request: nothing moved for #{@idle_timeout} s (proxy_idle_timeout); closing it")
266
+ close_pair(pair)
267
+ end
268
+ end
269
+ end
270
+
271
+ def close_pair(pair)
272
+ @mutex.synchronize do
273
+ @pairs.delete(pair)
274
+ [ pair.left, pair.right ].each do |side|
275
+ [ side.io, side.input ].compact.each do |io|
276
+ @sides.delete(io)
277
+ io.close rescue nil
278
+ end
279
+ end
280
+ end
281
+ end
282
+
283
+ def monotonic = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Railwatch
6
+ module Server
7
+ # The server's own settings that come from the app: SMTP for alert and
8
+ # digest email, S3 backups, Google sign-in, retention, whether alerts are
9
+ # sent. Written to `<data dir>/server.env` (0600, replaced atomically on
10
+ # every start) and handed to the server as RAILWATCH_ENV_FILE, so secrets
11
+ # are neither on the server's command line nor in its environment, and
12
+ # never in a log: only the names of what was set are logged.
13
+ #
14
+ # Each comes from Railwatch::Server.config first, then from the app:
15
+ #
16
+ # smtp config.action_mailer.smtp_settings, when delivery_method is :smtp
17
+ # mail_from ActionMailer::Base.default[:from]
18
+ # backup credentials.railwatch_server.backup
19
+ # google credentials.railwatch_server.google
20
+ #
21
+ # Anything the server reads that has no setting here goes in
22
+ # config.server_env ("RAILWATCH_..." => value). The server's own process
23
+ # environment still wins over this file (its rule for env files).
24
+ class Settings
25
+ Invalid = Class.new(StandardError)
26
+
27
+ def initialize(config, rails: (defined?(::Rails) && ::Rails.respond_to?(:application) ? ::Rails.application : nil))
28
+ @config = config
29
+ @rails = rails
30
+ end
31
+
32
+ # { "RAILWATCH_SMTP_ADDRESS" => "smtp.example.com", ... }, and which
33
+ # groups were set, for the log.
34
+ def env
35
+ out = {}
36
+ if (smtp = smtp_settings)
37
+ out.merge!(smtp_env(smtp))
38
+ end
39
+ if (from = mail_from)
40
+ out["RAILWATCH_SMTP_FROM"] = from
41
+ end
42
+ if (backup = backup_settings)
43
+ out.merge!(backup_env(backup))
44
+ end
45
+ if (google = google_settings)
46
+ out.merge!(google_env(google))
47
+ end
48
+ if (r = given(@config.retention))
49
+ out["RAILWATCH_DETAIL_RETENTION_DAYS"] = r[:detail_days] if r[:detail_days]
50
+ out["RAILWATCH_CORE_RETENTION_DAYS_MAX"] = r[:core_days_max] if r[:core_days_max]
51
+ end
52
+ out["RAILWATCH_ALERT_DELIVERY"] = @config.alert_delivery ? "on" : "off" unless @config.alert_delivery.nil?
53
+ out.merge!(@config.server_env.to_h.transform_keys(&:to_s))
54
+ out.transform_values { |v| v.to_s }.reject { |_, v| v.empty? }
55
+ end
56
+
57
+ def groups(env = self.env)
58
+ {
59
+ "smtp" => env.key?("RAILWATCH_SMTP_ADDRESS"),
60
+ "backup" => env.key?("RAILWATCH_BACKUP_S3_BUCKET"),
61
+ "google" => env.key?("RAILWATCH_GOOGLE_CLIENT_ID"),
62
+ "retention" => env.keys.any? { _1.include?("RETENTION") },
63
+ "alert_delivery" => env.key?("RAILWATCH_ALERT_DELIVERY")
64
+ }.select { |_, v| v }.keys + (@config.server_env.to_h.keys.map(&:to_s) - KNOWN)
65
+ end
66
+
67
+ # Writes the file the server reads; returns its path.
68
+ def write!(path)
69
+ body = +"# Written by railwatch-server on every start from the app's settings. Do not edit.\n"
70
+ env.each do |key, value|
71
+ raise Invalid, "#{key}: not a variable name" unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
72
+ raise Invalid, "#{key}: a value cannot contain a line break" if value.match?(/[\r\n]/)
73
+
74
+ # The server strips one pair of matching quotes and keeps the rest
75
+ # verbatim (no escapes), so quoting keeps edge spaces and '#'.
76
+ body << "#{key}=\"#{value}\"\n"
77
+ end
78
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
79
+ tmp = "#{path}.tmp"
80
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(body) }
81
+ File.chmod(0o600, tmp)
82
+ File.rename(tmp, path)
83
+ path
84
+ end
85
+
86
+ private
87
+
88
+ KNOWN = %w[
89
+ RAILWATCH_SMTP_ADDRESS RAILWATCH_SMTP_PORT RAILWATCH_SMTP_SECURITY RAILWATCH_SMTP_USERNAME RAILWATCH_SMTP_PASSWORD
90
+ RAILWATCH_SMTP_FROM RAILWATCH_BACKUP_S3_ENDPOINT RAILWATCH_BACKUP_S3_BUCKET RAILWATCH_BACKUP_S3_ACCESS_KEY_ID
91
+ RAILWATCH_BACKUP_S3_SECRET_ACCESS_KEY RAILWATCH_BACKUP_S3_REGION RAILWATCH_BACKUP_S3_PREFIX
92
+ RAILWATCH_BACKUP_INTERVAL_SECONDS RAILWATCH_GOOGLE_CLIENT_ID RAILWATCH_GOOGLE_CLIENT_SECRET
93
+ RAILWATCH_DETAIL_RETENTION_DAYS RAILWATCH_CORE_RETENTION_DAYS_MAX RAILWATCH_ALERT_DELIVERY
94
+ ].freeze
95
+
96
+ # nil: from the app. false or {}: off. A hash: that.
97
+ def given(value) = value.nil? ? nil : (value.respond_to?(:to_h) && value.to_h.any? ? symbolize(value) : false)
98
+
99
+ def smtp_settings
100
+ return given(@config.smtp) || nil unless @config.smtp.nil?
101
+ return unless @rails && defined?(::ActionMailer::Base)
102
+ return unless ::ActionMailer::Base.delivery_method == :smtp
103
+
104
+ settings = symbolize(::ActionMailer::Base.smtp_settings)
105
+ settings[:address] ? settings : nil
106
+ end
107
+
108
+ def mail_from
109
+ return @config.mail_from if @config.mail_from
110
+ return unless @rails && defined?(::ActionMailer::Base)
111
+
112
+ from = ::ActionMailer::Base.default[:from]
113
+ from.is_a?(String) ? from : nil
114
+ end
115
+
116
+ def backup_settings
117
+ @config.backup.nil? ? credential(:backup) : given(@config.backup) || nil
118
+ end
119
+
120
+ def google_settings
121
+ @config.google.nil? ? credential(:google) : given(@config.google) || nil
122
+ end
123
+
124
+ def credential(name)
125
+ value = @rails&.credentials&.dig(:railwatch_server, name)
126
+ value ? symbolize(value) : nil
127
+ rescue StandardError
128
+ # Unreadable credentials (no master key on this machine) are the app's
129
+ # problem to report; the server runs without these settings.
130
+ nil
131
+ end
132
+
133
+ # Action Mailer's names, as the mail gem reads them.
134
+ def smtp_env(s)
135
+ security = if s[:tls] || s[:ssl]
136
+ "tls"
137
+ elsif s[:enable_starttls] == false && s[:enable_starttls_auto] == false
138
+ "none"
139
+ else
140
+ "starttls"
141
+ end
142
+ {
143
+ "RAILWATCH_SMTP_ADDRESS" => s[:address],
144
+ "RAILWATCH_SMTP_PORT" => s[:port] || 25,
145
+ "RAILWATCH_SMTP_SECURITY" => security,
146
+ "RAILWATCH_SMTP_USERNAME" => s[:user_name],
147
+ "RAILWATCH_SMTP_PASSWORD" => s[:password]
148
+ }.compact
149
+ end
150
+
151
+ def backup_env(b)
152
+ missing = %i[endpoint bucket access_key_id secret_access_key].reject { b[_1] }
153
+ raise Invalid, "backup needs #{missing.join(', ')} (Railwatch::Server.config.backup or credentials.railwatch_server.backup)" if missing.any?
154
+
155
+ {
156
+ "RAILWATCH_BACKUP_S3_ENDPOINT" => b[:endpoint],
157
+ "RAILWATCH_BACKUP_S3_BUCKET" => b[:bucket],
158
+ "RAILWATCH_BACKUP_S3_ACCESS_KEY_ID" => b[:access_key_id],
159
+ "RAILWATCH_BACKUP_S3_SECRET_ACCESS_KEY" => b[:secret_access_key],
160
+ "RAILWATCH_BACKUP_S3_REGION" => b[:region],
161
+ "RAILWATCH_BACKUP_S3_PREFIX" => b[:prefix],
162
+ "RAILWATCH_BACKUP_INTERVAL_SECONDS" => b[:interval_seconds]
163
+ }.compact
164
+ end
165
+
166
+ def google_env(g)
167
+ raise Invalid, "google needs client_id and client_secret" unless g[:client_id] && g[:client_secret]
168
+
169
+ { "RAILWATCH_GOOGLE_CLIENT_ID" => g[:client_id], "RAILWATCH_GOOGLE_CLIENT_SECRET" => g[:client_secret] }
170
+ end
171
+
172
+ def symbolize(hash) = hash.to_h.transform_keys(&:to_sym)
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Server
5
+ VERSION = "0.1.0"
6
+ # The published railwatch-rs commit the bundled binaries are built from
7
+ # (README, "The server side"); the Corresponding Source under AGPL-3.0.
8
+ RAILWATCH_RS = "d4c842c2f02e1cd56aeedb1e9ad1403313f0aed8"
9
+ end
10
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "railwatch"
5
+ require_relative "server/version"
6
+ require_relative "server/configuration"
7
+ require_relative "server/binary"
8
+ require_relative "server/proxy_auth"
9
+ require_relative "server/data_dir"
10
+ require_relative "server/settings"
11
+ require_relative "server/process"
12
+ require_relative "server/relay"
13
+ require_relative "server/proxy_handoff"
14
+
15
+ module Railwatch
16
+ # The Railwatch server (the AGPL Rust binary, bundled) run next to this
17
+ # app: started by Puma's master (`plugin :railwatch_server`) or
18
+ # `bin/rails railwatch_server:start`, never inside a Puma worker; the
19
+ # dashboard proxied at /railwatch behind the app's own gate; the railwatch
20
+ # agent pointed at it.
21
+ module Server
22
+ class Error < StandardError; end
23
+
24
+ class << self
25
+ def config
26
+ @config ||= Configuration.new
27
+ end
28
+
29
+ def configure
30
+ yield config
31
+ config
32
+ end
33
+
34
+ def data_dir
35
+ DataDir.new(config.data_path)
36
+ end
37
+
38
+ def enabled?
39
+ config.enabled
40
+ end
41
+
42
+ # Whether the app reports somewhere of its own choosing, in which case
43
+ # the agent is left alone: a token set (RAILWATCH_TOKEN or the
44
+ # initializer), or the embedded store (`transport = :local`).
45
+ def agent_configured_elsewhere?
46
+ return true if Railwatch.config.local?
47
+ return false if Railwatch.config.ingest_url == config.local_url
48
+
49
+ Railwatch.config.token.to_s.strip != ""
50
+ end
51
+
52
+ # Whether this process starts the server, the only kind that may create
53
+ # the data dir: Puma with `plugin :railwatch_server` (its master and
54
+ # workers; under a bare `puma` the plugin is loaded before the app),
55
+ # `bin/rails server` (which loads the app before Puma reads its config),
56
+ # and `bin/rails railwatch_server:start`. Every other process (a job
57
+ # worker, a console, `assets:precompile` in a Dockerfile) only reads
58
+ # what the server's process wrote: provisioning in an asset build put
59
+ # the token and the dashboard's HMAC key in the image, shared by every
60
+ # volume made from it.
61
+ def serving?
62
+ return true if defined?(::Rails::Server)
63
+ return true if $LOADED_FEATURES.any? { _1.end_with?("/puma/plugin/railwatch_server.rb") }
64
+
65
+ rake_tasks.include?("railwatch_server:start")
66
+ end
67
+
68
+ # An image build (`SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile`),
69
+ # or the database step a container runs before its server (Rails'
70
+ # docker-entrypoint: `bin/rails db:prepare`, then `rails server`, which
71
+ # provisions), where an unprovisioned data dir is expected and not
72
+ # worth a warning: on a fresh volume it was logged right before the
73
+ # server's process provisioned it.
74
+ def building?
75
+ ENV["SECRET_KEY_BASE_DUMMY"].to_s.strip != "" || rake_tasks.any? { _1.start_with?("assets:", "db:") }
76
+ end
77
+
78
+ def rake_tasks
79
+ defined?(::Rake.application) ? ::Rake.application.top_level_tasks : []
80
+ end
81
+
82
+ # Why this process could not provision the data dir (the dashboard
83
+ # says so), or nil.
84
+ attr_reader :provision_error
85
+
86
+ # Once per data dir, before the first server starts: `railwatch setup`
87
+ # and the proxy secret. Cheap after the first time (two files exist).
88
+ # Only from a process that starts the server (serving?).
89
+ #
90
+ # Tried once per process: under `bin/rails server` the Railtie and then
91
+ # the Puma plugin both get here before Puma listens, and a setup that
92
+ # failed or hit setup_timeout must not hold up the boot twice. The next
93
+ # start tries again.
94
+ def provision!(log: ->(m) { warn("[railwatch-server] #{m}") })
95
+ dir = data_dir
96
+ return dir if dir.provisioned?
97
+ raise @provision_error if @provision_error
98
+
99
+ app = defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : "App"
100
+ env = config.environment
101
+ # setup needs none of the app's settings (server.env is written at
102
+ # the first start, after this).
103
+ server_env = Process.new(config: config, data_dir: dir, binary: Binary.path, log: log).env.merge("RAILWATCH_ENV_FILE" => File::NULL)
104
+ done = dir.provision!(binary: Binary.path, env: server_env, app_name: app, env_name: env, timeout: config.setup_timeout)
105
+ log.call("provisioned #{dir.path}: #{done.join(', ')}") if done.any?
106
+ dir
107
+ rescue Error => e
108
+ @provision_error = e
109
+ raise
110
+ end
111
+
112
+ # The railwatch agent's settings for the bundled server. Called from
113
+ # the Railtie after the app's initializers have run.
114
+ def wire_agent!
115
+ return :disabled unless enabled? && config.wire_agent
116
+ return :configured_elsewhere if agent_configured_elsewhere?
117
+
118
+ dir = serving? ? provision! : data_dir
119
+ return building? ? :building : :not_provisioned unless dir.provisioned?
120
+
121
+ held = dir.environment
122
+ if held && held != config.environment
123
+ raise Error, "#{dir.path} holds the #{held} environment, and this is #{config.environment}: the agent is not wired, " \
124
+ "so #{config.environment}'s telemetry does not land in #{held}'s. One data dir per environment: " \
125
+ "unset RAILWATCH_SERVER_DATA_DIR (the default is storage/railwatch/<env>) or point it elsewhere"
126
+ end
127
+ token = dir.token
128
+ Railwatch.config.token = token
129
+ Railwatch.config.ingest_url = config.local_url
130
+ :wired
131
+ end
132
+
133
+ # The foreground supervisor for setups without Puma (Procfile,
134
+ # systemd, a container's second process): runs until TERM/INT.
135
+ def run!(log: ->(m) { $stdout.puts("[railwatch-server] #{m}"); $stdout.flush })
136
+ provision!(log: log)
137
+ server = Process.new(config: config, data_dir: data_dir, binary: Binary.path, log: log)
138
+ stopping = false
139
+ %w[TERM INT].each { |sig| trap(sig) { stopping = true } }
140
+ begin
141
+ loop do
142
+ sleep 0.5
143
+ # Checked after the sleep: a TERM during it must not start a
144
+ # server on the way out.
145
+ break if stopping
146
+
147
+ server.ensure_running
148
+ end
149
+ ensure
150
+ server.stop
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ require_relative "server/engine" if defined?(Rails::Engine)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :railwatch_server do
4
+ desc "Run the bundled Railwatch server in the foreground (for a Procfile or a supervisor; Puma users add `plugin :railwatch_server`)"
5
+ task start: :environment do
6
+ Railwatch::Server.run!
7
+ end
8
+
9
+ desc "Whether the bundled Railwatch server is running and the agent reports to it"
10
+ task status: :environment do
11
+ config = Railwatch::Server.config
12
+ dir = Railwatch::Server.data_dir
13
+ up = begin
14
+ Net::HTTP.get_response(URI("#{config.local_url}/up")).is_a?(Net::HTTPSuccess)
15
+ rescue SystemCallError, IOError
16
+ false
17
+ end
18
+ pid = dir.path.join("server.pid")
19
+ binary = begin
20
+ Railwatch::Server::Binary.path
21
+ rescue Railwatch::Server::Binary::NotFound => e
22
+ "MISSING: #{e.message.lines.first.strip}"
23
+ end
24
+ puts "binary #{binary}"
25
+ puts "data dir #{dir.path}#{' (not provisioned)' unless dir.provisioned?}"
26
+ puts "server #{up ? 'UP' : 'DOWN'} on #{config.local_url}#{" (pid #{pid.read.split.first})" if pid.exist?}"
27
+ puts "dashboard #{config.normalized_mount_path}"
28
+ puts "agent " + (Railwatch::Server.agent_configured_elsewhere? ? "configured elsewhere (#{Railwatch.config.local? ? 'transport :local' : Railwatch.config.ingest_url})" :
29
+ "reports to #{Railwatch.config.ingest_url}")
30
+ puts "log #{dir.log_file}"
31
+ exit(up ? 0 : 1)
32
+ end
33
+ end