foruiman 0.1.2
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +19 -0
- data/README.md +102 -0
- data/bin/foruiman +6 -0
- data/docs/ARCHITECTURE.md +84 -0
- data/docs/ASSUMPTIONS.md +23 -0
- data/docs/COMPATIBILITY.md +27 -0
- data/docs/RAILS.md +54 -0
- data/docs/UPSTREAM.md +25 -0
- data/docs/terminal-preview.png +0 -0
- data/lib/foruiman/ansi.rb +147 -0
- data/lib/foruiman/cli.rb +83 -0
- data/lib/foruiman/diagnostics.rb +27 -0
- data/lib/foruiman/engine.rb +403 -0
- data/lib/foruiman/env.rb +35 -0
- data/lib/foruiman/log_store.rb +31 -0
- data/lib/foruiman/output.rb +76 -0
- data/lib/foruiman/plain.rb +22 -0
- data/lib/foruiman/process.rb +19 -0
- data/lib/foruiman/procfile.rb +81 -0
- data/lib/foruiman/ring.rb +53 -0
- data/lib/foruiman/tui/application.rb +105 -0
- data/lib/foruiman/tui/keyboard.rb +66 -0
- data/lib/foruiman/tui/log_formatter.rb +47 -0
- data/lib/foruiman/tui/renderer.rb +306 -0
- data/lib/foruiman/tui/state.rb +34 -0
- data/lib/foruiman/tui/terminal.rb +44 -0
- data/lib/foruiman/tui/text.rb +45 -0
- data/lib/foruiman/tui/theme.rb +71 -0
- data/lib/foruiman/tui/viewport.rb +56 -0
- data/lib/foruiman/version.rb +5 -0
- data/lib/foruiman.rb +11 -0
- metadata +116 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "process"
|
|
4
|
+
require_relative "log_store"
|
|
5
|
+
require_relative "output"
|
|
6
|
+
|
|
7
|
+
# Derived from Foreman's registration, process lookup, pipes and self-pipe signal
|
|
8
|
+
# handling. All process state and output now belong to the caller's event loop.
|
|
9
|
+
class Foruiman::Engine
|
|
10
|
+
HANDLED_SIGNALS = %i[INT TERM HUP].freeze
|
|
11
|
+
TERM_TIMEOUT = 5.0
|
|
12
|
+
READ_CHUNK = 4096
|
|
13
|
+
READ_BUDGET = 64 * 1024
|
|
14
|
+
State = Struct.new(:name, :process, :port, :pid, :pgid, :status, :exit_status,
|
|
15
|
+
:generation, :restart_pending, :deadline, :reaped, :group_gone,
|
|
16
|
+
keyword_init: true)
|
|
17
|
+
Event = Data.define(:type, :name, :pid, :status, :record, :message)
|
|
18
|
+
|
|
19
|
+
attr_reader :logs, :env, :processes, :root, :procfile_path
|
|
20
|
+
|
|
21
|
+
def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, port: 5000, log_lines: 10_000,
|
|
22
|
+
term_timeout: TERM_TIMEOUT)
|
|
23
|
+
raise Foruiman::Error, "port must be an integer in 1..65535" unless port.is_a?(Integer) && (1..65_535).cover?(port)
|
|
24
|
+
raise Foruiman::Error, "log-lines must be a positive integer" unless log_lines.is_a?(Integer) && log_lines.positive?
|
|
25
|
+
|
|
26
|
+
@root = File.expand_path(root)
|
|
27
|
+
raise Foruiman::Error, "working directory does not exist: #{@root}" unless File.directory?(@root)
|
|
28
|
+
|
|
29
|
+
@env = env.dup.freeze
|
|
30
|
+
@base_port = port
|
|
31
|
+
@log_lines = log_lines
|
|
32
|
+
@term_timeout = term_timeout
|
|
33
|
+
@processes = []
|
|
34
|
+
@names = {}
|
|
35
|
+
@running = {}
|
|
36
|
+
@readers = {}
|
|
37
|
+
@listeners = []
|
|
38
|
+
@shutdown = false
|
|
39
|
+
@explicit_shutdown = false
|
|
40
|
+
@failed = false
|
|
41
|
+
@closed = false
|
|
42
|
+
@started = false
|
|
43
|
+
@signal_requested = false
|
|
44
|
+
@self_reader, @self_writer = create_pipe
|
|
45
|
+
load_procfile(procfile) if procfile
|
|
46
|
+
rescue StandardError
|
|
47
|
+
@self_reader&.close
|
|
48
|
+
@self_writer&.close
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def register(name, command)
|
|
53
|
+
raise Foruiman::Error, "cannot register after startup" if @started
|
|
54
|
+
raise Foruiman::Error, "duplicate process: #{name}" if @names.key?(name)
|
|
55
|
+
raise Foruiman::Error, "allocated port exceeds 65535 for #{name}" if @base_port + (processes.size * 100) > 65_535
|
|
56
|
+
|
|
57
|
+
Foruiman::Procfile.new[name] = command
|
|
58
|
+
process = Foruiman::Process.new(command, cwd: root, env: env)
|
|
59
|
+
state = State.new(name: name.freeze, process: process, port: @base_port + (processes.size * 100),
|
|
60
|
+
status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true)
|
|
61
|
+
@names[name] = state
|
|
62
|
+
processes << state
|
|
63
|
+
state
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def load_procfile(filename)
|
|
67
|
+
parsed = Foruiman::Procfile.new(filename)
|
|
68
|
+
entries = parsed.entries.to_a
|
|
69
|
+
last_port = @base_port + ((processes.size + entries.size - 1) * 100)
|
|
70
|
+
raise Foruiman::Error, "allocated port #{last_port} exceeds 65535" if last_port > 65_535
|
|
71
|
+
|
|
72
|
+
entries.each { |name, command| register(name, command) }
|
|
73
|
+
@procfile_path = File.expand_path(filename).freeze
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def process_names
|
|
78
|
+
@names.keys
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def process(name)
|
|
82
|
+
@names[name]&.process
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def state(name)
|
|
86
|
+
@names.fetch(name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def select(name)
|
|
90
|
+
raise Foruiman::Error, "cannot select after startup" if @started
|
|
91
|
+
raise Foruiman::Error, "unknown process: #{name}" unless @names.key?(name)
|
|
92
|
+
|
|
93
|
+
@names.select! { |key, _entry| key == name }
|
|
94
|
+
processes.select! { |entry| entry.name == name }
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def on_event(&listener)
|
|
99
|
+
@listeners << listener
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def start(name = nil)
|
|
104
|
+
raise Foruiman::Error, "supervisor is closed" if @closed
|
|
105
|
+
return if @shutdown
|
|
106
|
+
|
|
107
|
+
targets = name ? [state(name)] : processes
|
|
108
|
+
unless @started
|
|
109
|
+
@logs = Foruiman::LogStore.new(process_names, capacity: @log_lines) do |record|
|
|
110
|
+
emit(:output, state(record.name), record: record)
|
|
111
|
+
end
|
|
112
|
+
@started = true
|
|
113
|
+
end
|
|
114
|
+
targets.each { |entry| spawn_process(entry) unless entry.pgid }
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
alias start_all start
|
|
119
|
+
|
|
120
|
+
def restart(name)
|
|
121
|
+
return if @shutdown || @closed
|
|
122
|
+
|
|
123
|
+
return start(name) unless @started
|
|
124
|
+
|
|
125
|
+
entry = state(name)
|
|
126
|
+
return if entry.restart_pending
|
|
127
|
+
|
|
128
|
+
entry.restart_pending = true
|
|
129
|
+
entry.status = :restarting
|
|
130
|
+
lifecycle(entry, :restarting, "restarting")
|
|
131
|
+
if entry.pgid
|
|
132
|
+
terminate(entry)
|
|
133
|
+
else
|
|
134
|
+
spawn_process(entry)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def stop(name)
|
|
139
|
+
entry = state(name)
|
|
140
|
+
entry.restart_pending = false
|
|
141
|
+
return unless entry.pgid
|
|
142
|
+
|
|
143
|
+
entry.status = :stopping
|
|
144
|
+
lifecycle(entry, :stopping, "stopping")
|
|
145
|
+
terminate(entry)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def shutdown(explicit: true)
|
|
149
|
+
@explicit_shutdown ||= explicit
|
|
150
|
+
return if @shutdown
|
|
151
|
+
|
|
152
|
+
@shutdown = true
|
|
153
|
+
processes.each do |entry|
|
|
154
|
+
entry.restart_pending = false
|
|
155
|
+
stop(entry.name)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def shutting_down?
|
|
160
|
+
@shutdown
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def finished?
|
|
164
|
+
@started && processes.all? { |entry| !entry.pgid && !entry.restart_pending } && @readers.empty?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def exit_code
|
|
168
|
+
@explicit_shutdown || !@failed ? 0 : 1
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def run(keep_open: false)
|
|
172
|
+
register_signal_handlers
|
|
173
|
+
start_all
|
|
174
|
+
loop do
|
|
175
|
+
tick(timeout: keep_open ? 1.0 / 30 : 0.05)
|
|
176
|
+
yield self if block_given?
|
|
177
|
+
break if finished? && (!keep_open || shutting_down?)
|
|
178
|
+
end
|
|
179
|
+
exit_code
|
|
180
|
+
ensure
|
|
181
|
+
close
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Embedders may drive this method directly; start/restart/stop are called on
|
|
185
|
+
# that same thread. A bounded round-robin read prevents output starving input.
|
|
186
|
+
def tick(timeout: 0.03)
|
|
187
|
+
return if @closed
|
|
188
|
+
|
|
189
|
+
shutdown if @signal_requested
|
|
190
|
+
reap_children
|
|
191
|
+
advance_groups
|
|
192
|
+
ready = IO.select([@self_reader, *@readers.keys], nil, nil, timeout)&.first || []
|
|
193
|
+
drain_signal_pipe if ready.delete(@self_reader)
|
|
194
|
+
shutdown if @signal_requested
|
|
195
|
+
read_output(ready)
|
|
196
|
+
reap_children
|
|
197
|
+
advance_groups
|
|
198
|
+
rescue Errno::EINTR
|
|
199
|
+
# The next tick handles the deferred signal.
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def close
|
|
203
|
+
return if @closed
|
|
204
|
+
|
|
205
|
+
# Observers can fail (e.g. a closed stdout). Cleanup must still own the loop.
|
|
206
|
+
@listeners.clear
|
|
207
|
+
shutdown(explicit: false)
|
|
208
|
+
tick(timeout: 0.01) until !@started || finished?
|
|
209
|
+
ensure
|
|
210
|
+
restore_signal_handlers
|
|
211
|
+
@self_reader.close unless @self_reader.closed?
|
|
212
|
+
@self_writer.close unless @self_writer.closed?
|
|
213
|
+
@closed = true
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def create_pipe
|
|
219
|
+
IO.pipe("BINARY").each { |io| io.close_on_exec = true }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def spawn_process(entry)
|
|
223
|
+
return if @shutdown
|
|
224
|
+
|
|
225
|
+
stdout_reader, stdout_writer = create_pipe
|
|
226
|
+
stderr_reader, stderr_writer = create_pipe
|
|
227
|
+
begin
|
|
228
|
+
pid = entry.process.run(output: stdout_writer, error: stderr_writer,
|
|
229
|
+
env: { "PORT" => entry.port.to_s, "PS" => "#{entry.name}.1" })
|
|
230
|
+
rescue SystemCallError => e
|
|
231
|
+
stdout_reader.close
|
|
232
|
+
stderr_reader.close
|
|
233
|
+
entry.status = :failed
|
|
234
|
+
entry.restart_pending = false
|
|
235
|
+
@failed = true
|
|
236
|
+
lifecycle(entry, :failed, "failed to start: #{e.message}")
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
entry.pid = entry.pgid = pid
|
|
240
|
+
entry.status = :running
|
|
241
|
+
entry.exit_status = nil
|
|
242
|
+
entry.generation += 1
|
|
243
|
+
entry.restart_pending = false
|
|
244
|
+
entry.deadline = nil
|
|
245
|
+
entry.reaped = entry.group_gone = false
|
|
246
|
+
@running[pid] = entry
|
|
247
|
+
[[stdout_reader, :stdout], [stderr_reader, :stderr]].each do |reader, stream|
|
|
248
|
+
@readers[reader] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: stream, pid: pid)]
|
|
249
|
+
end
|
|
250
|
+
lifecycle(entry, :started, "started with pid #{pid} (generation #{entry.generation})")
|
|
251
|
+
ensure
|
|
252
|
+
stdout_writer&.close
|
|
253
|
+
stderr_writer&.close
|
|
254
|
+
[stdout_reader, stderr_reader].compact.each do |reader|
|
|
255
|
+
reader.close unless reader.closed? || @readers.key?(reader)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def terminate(entry)
|
|
260
|
+
return if entry.deadline || entry.group_gone
|
|
261
|
+
|
|
262
|
+
signal_group(entry, :TERM)
|
|
263
|
+
entry.deadline = monotonic + @term_timeout
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def signal_group(entry, signal)
|
|
267
|
+
::Process.kill(signal, -entry.pgid) if entry.pgid
|
|
268
|
+
rescue Errno::ESRCH
|
|
269
|
+
entry.group_gone = true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def reap_children
|
|
273
|
+
@running.keys.each do |pid| # rubocop:disable Style/HashEachMethods -- observers can add processes
|
|
274
|
+
result = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
275
|
+
next unless result
|
|
276
|
+
|
|
277
|
+
entry = @running.delete(pid)
|
|
278
|
+
entry.reaped = true
|
|
279
|
+
entry.exit_status = result.last
|
|
280
|
+
success = entry.exit_status.success?
|
|
281
|
+
@failed ||= !success && !%i[stopping restarting].include?(entry.status)
|
|
282
|
+
entry.status = success ? :exited : :failed unless %i[stopping restarting].include?(entry.status)
|
|
283
|
+
lifecycle(entry, :exited, termination_message_for(entry.exit_status))
|
|
284
|
+
terminate(entry)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def advance_groups
|
|
289
|
+
processes.each do |entry|
|
|
290
|
+
next unless entry.pgid
|
|
291
|
+
next unless entry.reaped || entry.deadline
|
|
292
|
+
|
|
293
|
+
entry.group_gone ||= !group_alive?(entry.pgid)
|
|
294
|
+
if !entry.group_gone && entry.deadline && monotonic >= entry.deadline
|
|
295
|
+
signal_group(entry, :KILL)
|
|
296
|
+
lifecycle(entry, :killed, "sent SIGKILL after TERM timeout")
|
|
297
|
+
entry.deadline = nil
|
|
298
|
+
end
|
|
299
|
+
next unless entry.reaped && entry.group_gone
|
|
300
|
+
next if @readers.any? { |_reader, (owner, _output)| owner.equal?(entry) }
|
|
301
|
+
|
|
302
|
+
entry.pgid = nil
|
|
303
|
+
entry.deadline = nil
|
|
304
|
+
entry.status = :stopped if entry.status == :stopping
|
|
305
|
+
spawn_process(entry) if entry.restart_pending && !@shutdown
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def group_alive?(pgid)
|
|
310
|
+
::Process.kill(0, -pgid)
|
|
311
|
+
return true unless RUBY_PLATFORM.include?("linux")
|
|
312
|
+
|
|
313
|
+
# Linux containers may leave orphan zombies unreaped under PID 1. They
|
|
314
|
+
# cannot run or receive signals, and must not hold shutdown open forever.
|
|
315
|
+
Dir.glob("/proc/[0-9]*/stat").any? do |filename|
|
|
316
|
+
fields = File.read(filename).rpartition(") ").last.split
|
|
317
|
+
fields[2].to_i == pgid && !%w[Z X].include?(fields[0])
|
|
318
|
+
rescue Errno::ENOENT, Errno::ESRCH, Errno::EACCES
|
|
319
|
+
false
|
|
320
|
+
end
|
|
321
|
+
rescue Errno::ESRCH
|
|
322
|
+
false
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def read_output(ready)
|
|
326
|
+
budget = READ_BUDGET
|
|
327
|
+
ready.each do |reader|
|
|
328
|
+
break if budget <= 0
|
|
329
|
+
|
|
330
|
+
entry, output = @readers.fetch(reader)
|
|
331
|
+
bytes = reader.read_nonblock(READ_CHUNK, exception: false)
|
|
332
|
+
if bytes.nil? || (bytes == :wait_readable && entry.group_gone)
|
|
333
|
+
@readers.delete(reader)
|
|
334
|
+
reader.close
|
|
335
|
+
output.feed("", eof: true)
|
|
336
|
+
elsif bytes != :wait_readable
|
|
337
|
+
budget -= bytes.bytesize
|
|
338
|
+
output.feed(bytes)
|
|
339
|
+
# Rotate serviced readers to the back for the next select.
|
|
340
|
+
@readers[reader] = @readers.delete(reader)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
# A daemon that escaped the group may still hold a pipe open. Drain available
|
|
344
|
+
# bytes, but do not wait on it once every owned group member has finished.
|
|
345
|
+
@readers.keys.each do |reader| # rubocop:disable Style/HashEachMethods -- observers can add readers
|
|
346
|
+
entry, output = @readers.fetch(reader)
|
|
347
|
+
next unless entry.group_gone && !ready.include?(reader)
|
|
348
|
+
|
|
349
|
+
@readers.delete(reader)
|
|
350
|
+
reader.close
|
|
351
|
+
output.feed("", eof: true)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def lifecycle(entry, type, message)
|
|
356
|
+
logs&.write(name: entry.name, stream: :lifecycle, pid: entry.pid,
|
|
357
|
+
text: "--- #{message} ---", complete: true)
|
|
358
|
+
emit(type, entry, message: message)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def emit(type, entry, record: nil, message: nil)
|
|
362
|
+
event = Event.new(type: type, name: entry.name, pid: entry.pid, status: entry.status,
|
|
363
|
+
record: record, message: message&.freeze)
|
|
364
|
+
@listeners.each { |listener| listener.call(event) }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def termination_message_for(status)
|
|
368
|
+
if status.exited?
|
|
369
|
+
"exited with code #{status.exitstatus}"
|
|
370
|
+
else
|
|
371
|
+
"terminated by SIG#{Signal.list.key(status.termsig)}"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def register_signal_handlers
|
|
376
|
+
@old_handlers = {}
|
|
377
|
+
HANDLED_SIGNALS.each do |signal|
|
|
378
|
+
@old_handlers[signal] = Signal.trap(signal) do
|
|
379
|
+
@signal_requested = true
|
|
380
|
+
notice_signal
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def restore_signal_handlers
|
|
386
|
+
@old_handlers&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
387
|
+
@old_handlers = nil
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def notice_signal
|
|
391
|
+
@self_writer.write_nonblock(".", exception: false)
|
|
392
|
+
rescue Errno::EINTR
|
|
393
|
+
retry
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def drain_signal_pipe
|
|
397
|
+
@self_reader.read_nonblock(4096, exception: false)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def monotonic
|
|
401
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
402
|
+
end
|
|
403
|
+
end
|
data/lib/foruiman/env.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Foreman's assignment and quoting rules, with a non-mutating merge API.
|
|
4
|
+
class Foruiman::Env
|
|
5
|
+
def initialize(filename)
|
|
6
|
+
@entries = File.read(filename).gsub("\r\n", "\n").split("\n").each_with_object({}) do |line, result|
|
|
7
|
+
next unless (match = line.match(/\A([A-Za-z_0-9]+)=(.*)\z/))
|
|
8
|
+
|
|
9
|
+
key, value = match.captures
|
|
10
|
+
result[key] = case value
|
|
11
|
+
when /\A'(.*)'\z/ then Regexp.last_match(1)
|
|
12
|
+
when /\A"(.*)"\z/ then Regexp.last_match(1).gsub('\\n', "\n").gsub(/\\(.)/, '\1')
|
|
13
|
+
else value
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def entries(&block)
|
|
19
|
+
return @entries.each unless block
|
|
20
|
+
|
|
21
|
+
@entries.each(&block)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
@entries.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.load(root: Dir.pwd, file: nil, dotenv: true, inherited: ENV.to_h)
|
|
29
|
+
env = inherited.dup
|
|
30
|
+
default = File.join(root, ".env")
|
|
31
|
+
env.merge!(new(default).to_h) if dotenv && File.file?(default)
|
|
32
|
+
env.merge!(new(file).to_h) if file
|
|
33
|
+
env.transform_values { |value| value.dup.freeze }.freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ring"
|
|
4
|
+
|
|
5
|
+
class Foruiman::LogStore
|
|
6
|
+
Record = Data.define(:sequence, :name, :stream, :pid, :time, :text, :complete)
|
|
7
|
+
attr_reader :all
|
|
8
|
+
|
|
9
|
+
def initialize(names, capacity: 10_000, &listener)
|
|
10
|
+
@all = Foruiman::Ring.new(capacity)
|
|
11
|
+
@buffers = names.to_h { |name| [name, Foruiman::Ring.new(capacity)] }
|
|
12
|
+
@sequence = 0
|
|
13
|
+
@listener = listener
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def [](name)
|
|
17
|
+
name == "all" ? all : @buffers.fetch(name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write(name:, stream:, pid:, text:, complete:, previous: nil)
|
|
21
|
+
@sequence += 1 unless previous
|
|
22
|
+
record = Record.new(sequence: previous ? previous.sequence : @sequence, name: name,
|
|
23
|
+
stream: stream, pid: pid, time: previous ? previous.time : Time.now.freeze,
|
|
24
|
+
text: text.dup.freeze, complete: complete)
|
|
25
|
+
method = previous ? :replace : :append
|
|
26
|
+
@buffers.fetch(name).public_send(method, record)
|
|
27
|
+
all.public_send(method, record)
|
|
28
|
+
@listener&.call(record)
|
|
29
|
+
record
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
|
|
5
|
+
class Foruiman::Output
|
|
6
|
+
MAX_BYTES = 16 * 1024
|
|
7
|
+
|
|
8
|
+
def initialize(logs, name:, stream:, pid:)
|
|
9
|
+
@logs = logs
|
|
10
|
+
@metadata = { name: name, stream: stream, pid: pid }
|
|
11
|
+
@decoder = Foruiman::ANSI::Decoder.new
|
|
12
|
+
@styles = Foruiman::ANSI::Styles.new
|
|
13
|
+
@text = +""
|
|
14
|
+
@previous = nil
|
|
15
|
+
@dirty = false
|
|
16
|
+
@split_boundary = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def feed(bytes, eof: false)
|
|
20
|
+
decoded = @decoder.feed(bytes, eof: eof)
|
|
21
|
+
decoded.scan(/\e\[[0-9;:]*m|\n|[^\e\n]+/).each do |token|
|
|
22
|
+
if token == "\n"
|
|
23
|
+
if @split_boundary && !@dirty && !@previous
|
|
24
|
+
@split_boundary = false
|
|
25
|
+
else
|
|
26
|
+
publish(true)
|
|
27
|
+
end
|
|
28
|
+
elsif token.start_with?("\e[")
|
|
29
|
+
split_record if @text.bytesize + token.bytesize > MAX_BYTES
|
|
30
|
+
@text << token
|
|
31
|
+
@styles.apply(token)
|
|
32
|
+
@dirty = true
|
|
33
|
+
else
|
|
34
|
+
append_text(token)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
if eof
|
|
38
|
+
publish(true) if @dirty || @previous
|
|
39
|
+
elsif @dirty
|
|
40
|
+
publish(false)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def append_text(text)
|
|
47
|
+
until text.empty?
|
|
48
|
+
available = MAX_BYTES - @text.bytesize
|
|
49
|
+
piece = text.byteslice(0, available)
|
|
50
|
+
piece = piece.byteslice(0, piece.bytesize - 1) until piece.valid_encoding?
|
|
51
|
+
if piece.empty?
|
|
52
|
+
split_record
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
@text << piece
|
|
56
|
+
@dirty = true
|
|
57
|
+
text = text.byteslice(piece.bytesize..)
|
|
58
|
+
split_record if @text.bytesize == MAX_BYTES
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def split_record
|
|
63
|
+
publish(true)
|
|
64
|
+
@split_boundary = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def publish(complete)
|
|
68
|
+
@split_boundary = false
|
|
69
|
+
@previous = @logs.write(**@metadata, text: @text, complete: complete, previous: @previous)
|
|
70
|
+
@dirty = false
|
|
71
|
+
return unless complete
|
|
72
|
+
|
|
73
|
+
@text = +@styles.prefix
|
|
74
|
+
@previous = nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Foruiman::Plain
|
|
4
|
+
def initialize(engine, output: $stdout)
|
|
5
|
+
@engine = engine
|
|
6
|
+
@output = output
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def run
|
|
10
|
+
@engine.on_event do |event|
|
|
11
|
+
record = event.record
|
|
12
|
+
next unless record&.complete
|
|
13
|
+
|
|
14
|
+
text = record.text.gsub(Foruiman::ANSI::SGR, "")
|
|
15
|
+
@output.puts "#{record.time.strftime('%H:%M:%S')} #{record.name} [#{record.stream}] | #{text}"
|
|
16
|
+
@output.flush
|
|
17
|
+
end
|
|
18
|
+
@engine.run
|
|
19
|
+
rescue Errno::EPIPE
|
|
20
|
+
0
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from Foreman::Process. Expansion belongs to the shell, not Ruby.
|
|
4
|
+
class Foruiman::Process
|
|
5
|
+
attr_reader :command, :env, :cwd
|
|
6
|
+
|
|
7
|
+
def initialize(command, options = {})
|
|
8
|
+
@command = command.dup.freeze
|
|
9
|
+
@env = (options[:env] || ENV.to_h).dup.freeze
|
|
10
|
+
@cwd = File.expand_path(options[:cwd] || Dir.pwd)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(options = {})
|
|
14
|
+
::Process.spawn(env.merge(options.fetch(:env, {})), "/bin/sh", "-c", command,
|
|
15
|
+
chdir: cwd, in: File::NULL, out: options.fetch(:output, $stdout),
|
|
16
|
+
err: options.fetch(:error, $stderr), pgroup: true, unsetenv_others: true,
|
|
17
|
+
close_others: true)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Derived from Foreman's ordered Procfile reader/writer; see docs/UPSTREAM.md.
|
|
4
|
+
class Foruiman::Procfile
|
|
5
|
+
class ParseError < Foruiman::Error
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class EmptyFileError < ParseError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(filename = nil)
|
|
12
|
+
@entries = []
|
|
13
|
+
load(filename) if filename
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def entries(&block)
|
|
17
|
+
return @entries.each unless block
|
|
18
|
+
|
|
19
|
+
@entries.each(&block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def [](name)
|
|
23
|
+
@entries.find { |key, _command| key == name }&.last
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def []=(name, command)
|
|
27
|
+
validate_entry!(name, command, "Procfile entry")
|
|
28
|
+
delete(name)
|
|
29
|
+
@entries << [name.freeze, command.freeze].freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete(name)
|
|
33
|
+
@entries.reject! { |key, _command| key == name }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load(filename)
|
|
37
|
+
parsed = parse(filename)
|
|
38
|
+
raise EmptyFileError, "#{filename}: no processes defined" if parsed.empty?
|
|
39
|
+
|
|
40
|
+
@entries.replace(parsed)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def save(filename)
|
|
45
|
+
File.write(filename, "#{self}\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_s
|
|
49
|
+
@entries.map { |name, command| "#{name}: #{command}" }.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate_entry!(name, command, location)
|
|
55
|
+
unless name.match?(/\A[A-Za-z0-9_-]+\z/) && !command.strip.empty? && !command.include?("\0")
|
|
56
|
+
raise ParseError, "#{location}: expected NAME: command"
|
|
57
|
+
end
|
|
58
|
+
raise ParseError, "#{location}: 'all' is reserved for the aggregate tab" if name == "all"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse(filename)
|
|
62
|
+
seen = {}
|
|
63
|
+
File.read(filename, encoding: "UTF-8").lines.filter_map.with_index(1) do |line, number|
|
|
64
|
+
line = line.delete_suffix("\n").delete_suffix("\r")
|
|
65
|
+
next if line.strip.empty? || line.lstrip.start_with?("#")
|
|
66
|
+
|
|
67
|
+
location = "#{filename}:#{number}"
|
|
68
|
+
match = line.match(/\A([A-Za-z0-9_-]+):[ \t]*(.*)\z/)
|
|
69
|
+
raise ParseError, "#{location}: expected NAME: command" unless match
|
|
70
|
+
|
|
71
|
+
name, command = match.captures
|
|
72
|
+
validate_entry!(name, command, location)
|
|
73
|
+
raise ParseError, "#{location}: duplicate '#{name}' (first defined on line #{seen[name]})" if seen[name]
|
|
74
|
+
|
|
75
|
+
seen[name] = number
|
|
76
|
+
[name.freeze, command.freeze].freeze
|
|
77
|
+
end
|
|
78
|
+
rescue ArgumentError => e
|
|
79
|
+
raise ParseError, "#{filename}: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|