siding 0.0.1

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.
data/lib/siding/cli.rb ADDED
@@ -0,0 +1,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "error"
6
+ require_relative "version"
7
+ require_relative "platform"
8
+ require_relative "runtime"
9
+ require_relative "protocol"
10
+ require_relative "staleness"
11
+
12
+ module Siding
13
+ class CLI
14
+ MANAGEMENT_COMMANDS = %w[start status stop restart doctor init version help --help -h].freeze
15
+ ACCELERATED_EXECUTABLES = %w[rails rake rspec test].freeze
16
+ OUT_OF_SCOPE_RAILS_COMMANDS = %w[dev:cache].freeze
17
+ SERVER_COMMANDS = %w[server s].freeze
18
+ DAEMON_FLAGS = %w[-d --daemon].freeze
19
+
20
+ REQUEST_TIMEOUT = 5
21
+ BOOT_LOG_LINES = 20
22
+
23
+ BINSTUBS = %w[rails rake rspec].freeze
24
+ BINSTUB_MARKER = "generated by siding"
25
+
26
+ class << self
27
+ def management?(name) = MANAGEMENT_COMMANDS.include?(name)
28
+
29
+ def accelerated?(argv)
30
+ executable = argv.first
31
+ return false unless ACCELERATED_EXECUTABLES.include?(executable)
32
+ return rails_accelerated?(argv) if executable == "rails"
33
+
34
+ true
35
+ end
36
+
37
+ def rails_accelerated?(argv)
38
+ command = argv[1]
39
+ return false if OUT_OF_SCOPE_RAILS_COMMANDS.include?(command)
40
+ return false if SERVER_COMMANDS.include?(command) && argv.any? { DAEMON_FLAGS.include?(_1) }
41
+
42
+ true
43
+ end
44
+ end
45
+
46
+ attr_reader :argv, :client
47
+
48
+ def initialize(argv, client)
49
+ @argv = argv
50
+ @client = client
51
+ end
52
+
53
+ def run
54
+ command = argv.first
55
+ ret = if ["help", "--help", "-h", nil].include?(command)
56
+ help
57
+ elsif MANAGEMENT_COMMANDS.include?(command)
58
+ send(command)
59
+ else
60
+ unimplemented(command)
61
+ end
62
+ ret.is_a?(Integer) ? ret : 0
63
+ end
64
+
65
+ private
66
+
67
+ def logger = client.logger
68
+
69
+ def status
70
+ runtime = client.runtime
71
+ return report("no Rails application found here", warm: false) if runtime.nil?
72
+
73
+ info = runtime.live_server_info
74
+ if info.nil?
75
+ runtime.discard_records if runtime.server_info
76
+ return report("no warm application for #{project_label}", warm: false)
77
+ end
78
+
79
+ report_status(info, request_status(runtime))
80
+ end
81
+
82
+ def stop
83
+ runtime = client.runtime
84
+ return unless runtime && File.directory?(runtime.dir)
85
+
86
+ pid = runtime.server_pid
87
+ if pid.nil?
88
+ runtime.discard_records
89
+ return
90
+ end
91
+
92
+ request_stop(runtime)
93
+ terminate(pid)
94
+ runtime.discard_records
95
+ logger.debug("stopped server pid #{pid}")
96
+ end
97
+
98
+ def start
99
+ outcome = client.warm_up
100
+ return report_start_failure unless outcome
101
+
102
+ state = outcome == :already_warm ? "is already warm" : "is warm"
103
+ logger.notice("#{project_label} #{state}#{server_suffix}")
104
+ end
105
+
106
+ def server_suffix
107
+ pid = client.runtime&.server_pid
108
+ pid ? " -- pid #{pid}" : ""
109
+ end
110
+
111
+ def report_start_failure
112
+ reason = client.unusable_reason
113
+ if reason
114
+ logger.notice("cannot boot a warm application for #{project_label}: #{reason}")
115
+ else
116
+ logger.notice("#{project_label} did not boot within its bound; its output is in #{client.runtime.boot_log_path}")
117
+ end
118
+ 1
119
+ end
120
+
121
+ def restart
122
+ stop
123
+ return if client.warm_up
124
+
125
+ logger.notice("stopped, but nothing was booted -- the next command will boot one")
126
+ 1
127
+ end
128
+
129
+ def version
130
+ logger.notice("siding #{Siding::VERSION}")
131
+ end
132
+
133
+ def help
134
+ logger.notice(<<~TEXT.chomp)
135
+ siding #{Siding::VERSION} -- a Rails application preloader that never serves stale code
136
+
137
+ Usage:
138
+ siding <command> [args...] run a command against the warm application
139
+ siding start boot a warm application without running a command
140
+ siding status report whether a warm application exists
141
+ siding stop stop everything belonging to this project
142
+ siding restart stop, then boot fresh
143
+ siding doctor explain why an invocation was or was not accelerated
144
+ siding init generate opt-in binstubs
145
+ TEXT
146
+ 0
147
+ end
148
+
149
+ def doctor
150
+ lines = ["siding #{Siding::VERSION} -- #{project_label}"]
151
+ lines.concat(platform_lines)
152
+ lines.concat(project_lines)
153
+ lines.concat(runtime_lines)
154
+ lines.concat(acceleration_lines)
155
+ lines.concat(server_lines)
156
+ lines.concat(boot_log_lines)
157
+ logger.notice(lines.join("\n"))
158
+ acceleration_available? ? 0 : 1
159
+ end
160
+
161
+ def platform_lines
162
+ supported = Platform.supported?
163
+ lines = [" platform #{Platform.description}#{supported ? '' : ' -- unsupported'}"]
164
+ lines << " #{Platform.unsupported_reason}" unless supported
165
+ lines
166
+ end
167
+
168
+ def project_lines
169
+ root = client.app_root
170
+ return [" project no Rails application found above #{client.cwd}"] if root.nil?
171
+
172
+ [" project #{root}",
173
+ " framework #{resolved_framework_version(root)}",
174
+ " ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"]
175
+ end
176
+
177
+ def resolved_framework_version(root)
178
+ lock = File.join(root, "Gemfile.lock")
179
+ return "unknown (no Gemfile.lock; run `bundle install`)" unless File.file?(lock)
180
+
181
+ match = File.read(lock).match(/^\s{4}railties \(([^)]+)\)$/)
182
+ match ? "Rails #{match[1]} (from Gemfile.lock)" : "unknown (railties not in Gemfile.lock)"
183
+ rescue SystemCallError => e
184
+ "unknown (#{e.message})"
185
+ end
186
+
187
+ def runtime_lines
188
+ runtime = client.runtime
189
+ return [] if runtime.nil?
190
+
191
+ reason = runtime.unavailable_reason
192
+ lines = [" runtime #{runtime.dir}"]
193
+ lines << if reason
194
+ " unusable: #{reason}"
195
+ else
196
+ " #{directory_mode(runtime.dir)}, writable"
197
+ end
198
+ lines.concat(leftover_lines(runtime))
199
+ end
200
+
201
+ def directory_mode(path)
202
+ "mode #{format('%04o', File.stat(path).mode & 0o7777)}"
203
+ rescue SystemCallError
204
+ "mode unknown"
205
+ end
206
+
207
+ def leftover_lines(runtime)
208
+ return [] unless File.directory?(runtime.dir)
209
+
210
+ info = runtime.server_info
211
+ return [] if info.nil?
212
+ return [] unless runtime.live_server_info.nil?
213
+
214
+ [" leftover record naming pid #{info['pid']} (not running; the next " \
215
+ "invocation clears it)"]
216
+ end
217
+
218
+ def acceleration_lines
219
+ reason = client.unusable_reason
220
+ lines = [reason ? " active no -- #{reason}" : " active yes"]
221
+ lines << " commands accelerated: #{ACCELERATED_EXECUTABLES.join(', ')}"
222
+ lines << " passed through: rails #{OUT_OF_SCOPE_RAILS_COMMANDS.join(', rails ')}, " \
223
+ "rails server -d, and everything else"
224
+ lines
225
+ end
226
+
227
+ def acceleration_available? = client.unusable_reason.nil?
228
+
229
+ def server_lines
230
+ runtime = client.runtime
231
+ return [] if runtime.nil?
232
+
233
+ info = runtime.live_server_info
234
+ return [" warm nothing running"] + recorded_event_lines(runtime) if info.nil?
235
+
236
+ report = request_status(runtime)
237
+ if report.nil?
238
+ return [" warm pid #{info['pid']}, not answering yet"] + recorded_event_lines(runtime)
239
+ end
240
+
241
+ [" warm pid #{info['pid']}, booted #{format_time(info['booted_at'])}" \
242
+ "#{format_boot_duration(report['boot_seconds'] || info['boot_seconds'])}",
243
+ " revision #{report['revision_label'] || info['revision_label']}",
244
+ " served #{format_served(report)}",
245
+ " watch #{report['watch'] || 'unknown'}"] + event_lines(Array(report["events"]))
246
+ end
247
+
248
+ def recorded_event_lines(runtime)
249
+ events = Staleness::Events.new(path: runtime.events_path).to_a
250
+ .reject { |event| event.resolution == "fresh" }
251
+ .last(10).map(&:to_h)
252
+ events.empty? ? [] : event_lines(events)
253
+ end
254
+
255
+ def boot_log_lines
256
+ runtime = client.runtime
257
+ return [] if runtime.nil? || !runtime.live_server_info.nil?
258
+
259
+ path = runtime.boot_log_path
260
+ tail = File.file?(path) ? File.read(path).lines.last(BOOT_LOG_LINES) : []
261
+ return [] if tail.empty?
262
+
263
+ [" last boot left this behind in #{path}:"] +
264
+ tail.map { |line| " #{line.chomp}" }
265
+ end
266
+
267
+ def init
268
+ root = client.app_root
269
+ return report("no Rails application found above #{client.cwd}", warm: false) if root.nil?
270
+
271
+ written, skipped = write_binstubs(root)
272
+ logger.notice(init_report(root, written, skipped).join("\n"))
273
+ 0
274
+ end
275
+
276
+ def write_binstubs(root)
277
+ directory = File.join(root, "bin")
278
+ FileUtils.mkdir_p(directory)
279
+ written = []
280
+ skipped = []
281
+
282
+ BINSTUBS.each do |name|
283
+ path = File.join(directory, name)
284
+ # Never overwritten. `bin/rails` in a Rails application is generated, sometimes edited, and
285
+ # always committed -- replacing one without asking is precisely the "what did this put in my
286
+ # repository?" problem this command exists to avoid.
287
+ next skipped << path if File.exist?(path) && !siding_binstub?(path)
288
+
289
+ File.write(path, binstub_source(name))
290
+ File.chmod(0o755, path)
291
+ written << path
292
+ end
293
+
294
+ [written, skipped]
295
+ end
296
+
297
+ def siding_binstub?(path)
298
+ File.read(path).include?(BINSTUB_MARKER)
299
+ rescue SystemCallError
300
+ false
301
+ end
302
+
303
+ def binstub_source(name)
304
+ <<~RUBY
305
+ #!/usr/bin/env ruby
306
+ # frozen_string_literal: true
307
+ # #{BINSTUB_MARKER}
308
+ #
309
+ # Runs `#{name}` against the warm application when siding can accelerate it, and exactly as
310
+ # it would have run otherwise. Delete this file to remove it; nothing else refers to it.
311
+ exec("siding", "#{name}", *ARGV)
312
+ RUBY
313
+ end
314
+
315
+ def init_report(root, written, skipped)
316
+ lines = ["siding #{Siding::VERSION} -- #{File.basename(root)}"]
317
+ lines << (written.empty? ? " wrote nothing" : " wrote:")
318
+ lines.concat(written.map { |path| " #{relative_to(path, root)}" })
319
+ unless skipped.empty?
320
+ lines << " left alone (not written by siding -- remove them yourself to replace):"
321
+ lines.concat(skipped.map { |path| " #{relative_to(path, root)}" })
322
+ end
323
+ lines << " siding <command> keeps working with or without these."
324
+ lines
325
+ end
326
+
327
+ def relative_to(path, root)
328
+ path.start_with?("#{root}/") ? path.sub("#{root}/", "") : path
329
+ end
330
+
331
+ def project_label
332
+ key = client.project_key
333
+ key ? "#{File.basename(key.app_root)} (#{key.app_env})" : "this directory"
334
+ end
335
+
336
+ def report(message, warm:)
337
+ logger.notice(message)
338
+ warm ? 0 : 1
339
+ end
340
+
341
+ def report_status(info, report)
342
+ return report("a server for #{project_label} is running (pid #{info['pid']}) but not " \
343
+ "answering yet", warm: false) if report.nil?
344
+
345
+ logger.notice(status_lines(info, report).join("\n"))
346
+ 0
347
+ end
348
+
349
+ def status_lines(info, report)
350
+ lines = ["#{project_label} is warm -- siding #{report['tool_version'] || Siding::VERSION}"]
351
+ lines << " server pid #{info['pid']}, booted #{format_time(info['booted_at'])}" \
352
+ "#{format_boot_duration(report['boot_seconds'] || info['boot_seconds'])}"
353
+ lines << " revision #{report['revision_label'] || info['revision_label']}"
354
+ lines << " served #{format_served(report)}"
355
+ lines << " watch #{report['watch'] || 'unknown'}"
356
+ lines << " running #{format_workers(report['workers'])}"
357
+ lines << " idle exit #{format_idle_exit(report)}"
358
+ lines.concat(event_lines(Array(report["events"])))
359
+ lines
360
+ end
361
+
362
+ def event_lines(events)
363
+ return [" recent no changes have cost anything yet"] if events.empty?
364
+
365
+ [" recent"] + events.reverse.map do |event|
366
+ paths = Array(event["trigger_paths"]).first(2).join(", ")
367
+ detail = [event["reason"], paths].reject { |part| part.to_s.empty? }.join(": ")
368
+ " #{format_time(event['at'])} #{event['resolution']} #{detail}"
369
+ end
370
+ end
371
+
372
+ def format_served(report)
373
+ served = report["served"].to_i
374
+ last = report["last_activity_at"]
375
+ "#{served} invocation#{'s' unless served == 1}" \
376
+ "#{last ? ", last #{format_ago(Time.now.to_f - last.to_f)} ago" : ''}"
377
+ end
378
+
379
+ def format_workers(workers)
380
+ workers = Array(workers)
381
+ workers.empty? ? "nothing" : "#{workers.size} worker(s): #{workers.join(', ')}"
382
+ end
383
+
384
+ def format_idle_exit(report)
385
+ timeout = report["idle_timeout"].to_f
386
+ return "not scheduled" unless timeout.positive?
387
+
388
+ last = report["last_activity_at"].to_f
389
+ remaining = last.positive? ? timeout - (Time.now.to_f - last) : timeout
390
+ remaining.positive? ? "in #{format_ago(remaining)}" : "due now"
391
+ end
392
+
393
+ def format_boot_duration(seconds)
394
+ seconds.to_f.positive? ? " in #{format('%.1f', seconds)}s" : ""
395
+ end
396
+
397
+ def format_time(value)
398
+ return "unknown" if value.nil?
399
+ return value if value.is_a?(String)
400
+
401
+ Time.at(value.to_f).strftime("%Y-%m-%d %H:%M:%S")
402
+ rescue StandardError
403
+ "unknown"
404
+ end
405
+
406
+ def format_ago(seconds)
407
+ seconds = seconds.to_f
408
+ return "#{seconds.round}s" if seconds < 90
409
+
410
+ minutes = seconds / 60
411
+ minutes < 90 ? "#{minutes.round}m" : "#{(minutes / 60).round}h"
412
+ end
413
+
414
+ def request_status(runtime)
415
+ request(runtime, Protocol::STATUS) do |socket|
416
+ message = Protocol.read_message(socket)
417
+ message && message.type == Protocol::STATUS_REPORT ? message.payload : nil
418
+ end
419
+ end
420
+
421
+ def request_stop(runtime)
422
+ request(runtime, Protocol::STOP) { |socket| Protocol.read_message(socket) }
423
+ end
424
+
425
+ def request(runtime, type)
426
+ socket = UNIXSocket.new(runtime.socket_path)
427
+ socket.timeout = REQUEST_TIMEOUT if socket.respond_to?(:timeout=)
428
+ begin
429
+ Protocol.client_handshake(socket)
430
+ Protocol.write_message(socket, type)
431
+ yield socket
432
+ ensure
433
+ socket.close unless socket.closed?
434
+ end
435
+ rescue StandardError => e
436
+ logger.debug("#{type} request failed: #{e.class}: #{e.message}")
437
+ nil
438
+ end
439
+
440
+ def unimplemented(name)
441
+ logger.notice("`siding #{name}` is not implemented yet")
442
+ 1
443
+ end
444
+
445
+ def terminate(pid, grace: 5.0)
446
+ Process.kill("TERM", pid)
447
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
448
+ sleep 0.02 while alive?(pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
449
+ return unless alive?(pid)
450
+
451
+ logger.debug("server #{pid} did not leave within #{grace}s; killing it")
452
+ Process.kill("KILL", pid)
453
+ rescue Errno::ESRCH
454
+ nil
455
+ end
456
+
457
+ def alive?(pid) = Runtime.process_alive?(pid)
458
+ end
459
+ end