hotcell-server 0.0.0 → 0.2.0
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 +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +3 -0
- data/exe/hotcell +11 -0
- data/exe/hotcell-health +38 -0
- data/lib/hot_cell/configuration.rb +124 -0
- data/lib/hot_cell/control.rb +62 -0
- data/lib/hot_cell/counters.rb +50 -0
- data/lib/hot_cell/limits.rb +142 -0
- data/lib/hot_cell/log.rb +120 -0
- data/lib/hot_cell/operation.rb +205 -0
- data/lib/hot_cell/registry.rb +51 -0
- data/lib/hot_cell/server/errors.rb +16 -0
- data/lib/hot_cell/server/version.rb +7 -0
- data/lib/hot_cell/server.rb +45 -0
- data/lib/hot_cell/slot.rb +171 -0
- data/lib/hot_cell/supervisor.rb +884 -0
- data/lib/hot_cell/test_cell.rb +156 -0
- data/lib/hot_cell/test_operations.rb +465 -0
- data/lib/hot_cell/timing.rb +47 -0
- data/lib/hot_cell/worker.rb +324 -0
- data/lib/hotcell-server.rb +6 -0
- metadata +55 -9
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HotCell
|
|
4
|
+
# Every untrusted byte is touched here and nowhere else.
|
|
5
|
+
#
|
|
6
|
+
# The worker applies the cell's limits before it touches the socket, narrows to the operation's limits
|
|
7
|
+
# before it reads an untrusted byte, and calls exit! on the way out so that no finalizer and no library
|
|
8
|
+
# teardown ever runs. Limits go on in two passes because the worker has to parse the request before it
|
|
9
|
+
# can know which operation's limits to use, and parsing is the first thing it does with
|
|
10
|
+
# attacker-influenced bytes — so the cell's maximums go on at a point that needs no parsing at all.
|
|
11
|
+
#
|
|
12
|
+
# There is deliberately no shutdown hook. The exit! is the point, and a hook would invite cleanup code
|
|
13
|
+
# that is then skipped.
|
|
14
|
+
class Worker
|
|
15
|
+
DISPATCH_BYTES = 1024
|
|
16
|
+
|
|
17
|
+
def initialize(slot:, configuration:, control:, log:)
|
|
18
|
+
@slot = slot
|
|
19
|
+
@configuration = configuration
|
|
20
|
+
@control = control
|
|
21
|
+
@log = log
|
|
22
|
+
@booted = nil
|
|
23
|
+
@effective = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# exit! rather than exit, so that no finalizer and no library teardown ever runs. There is deliberately no
|
|
27
|
+
# shutdown hook: the exit! is the point, and a hook would invite cleanup code that is then skipped.
|
|
28
|
+
#
|
|
29
|
+
# A non-zero status for anything unexpected, because the supervisor holds the connection and is the only
|
|
30
|
+
# thing that can answer for a worker that died mid-request. Exiting zero here would leave a caller reading
|
|
31
|
+
# a closed socket with no verdict at all.
|
|
32
|
+
#
|
|
33
|
+
# **The one deliberate `rescue Exception` in this repository.** Not for the exit status — Ruby's own handler
|
|
34
|
+
# would also exit non-zero — but for Failure.sanitize. Left to Ruby, a NoMemoryError or a SystemStackError
|
|
35
|
+
# prints its message and backtrace to stderr unsanitized, and in this process that message can carry bytes
|
|
36
|
+
# derived from a hostile file. sanitize forces UTF-8, scrubs invalid sequences and truncates; stderr is the
|
|
37
|
+
# one path out of a cell that would otherwise skip it, which is how an unscrubbed byte sequence reaches a
|
|
38
|
+
# log row and poisons it.
|
|
39
|
+
#
|
|
40
|
+
# It swallows nothing: `exit! 1` runs whatever was caught.
|
|
41
|
+
def run
|
|
42
|
+
configuration.limits.apply
|
|
43
|
+
disarm_file_size_signal
|
|
44
|
+
|
|
45
|
+
while (dispatch = await_dispatch)
|
|
46
|
+
serve(*dispatch)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
exit! 0
|
|
50
|
+
rescue Exception => error
|
|
51
|
+
log.write "worker.crashed", pid: Process.pid, slot: slot.number, error: error.class.name,
|
|
52
|
+
message: Failure.sanitize(error.message)
|
|
53
|
+
exit! 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
attr_reader :slot, :configuration, :control, :log
|
|
58
|
+
|
|
59
|
+
# **The handler carries nothing, and that is the whole point.**
|
|
60
|
+
#
|
|
61
|
+
# RLIMIT_FSIZE is enforced by SIGXFSZ, and the supervisor used to read that signal off a wait status
|
|
62
|
+
# and answer `fsize`, permanently, against whatever input the worker was holding. Workers share a uid,
|
|
63
|
+
# so a sibling sends SIGXFSZ as easily as the kernel does and a wait status cannot tell them apart —
|
|
64
|
+
# nor can a handler, because Ruby hands one only the signal number and no siginfo, so SI_KERNEL and
|
|
65
|
+
# SI_USER are not reachable from here.
|
|
66
|
+
#
|
|
67
|
+
# Catching it makes the kernel fail the offending write with EFBIG rather than killing the process,
|
|
68
|
+
# and that error return is what a signal is not: it is raised by a write this process made, and no
|
|
69
|
+
# signal any sibling sends produces one. So the verdict below keys on Errno::EFBIG and this handler
|
|
70
|
+
# does nothing at all. A handler that set so much as a flag the verdict consulted would hand the
|
|
71
|
+
# forgery straight back.
|
|
72
|
+
#
|
|
73
|
+
# EFBIG is evidence of this request's own write and not proof of which limit stopped it. A filesystem
|
|
74
|
+
# maximum, or the caller's own file, answers the same errno — so this says the bytes did not go, by
|
|
75
|
+
# something this request did, rather than naming RLIMIT_FSIZE. Narrowing it further means checking the
|
|
76
|
+
# written file against the effective limit at each write site, which is not what this changes.
|
|
77
|
+
#
|
|
78
|
+
# A block rather than "IGNORE", because an ignored disposition survives execve and a handled one does
|
|
79
|
+
# not. A tool must keep dying on its own RLIMIT_FSIZE rather than writing past it, and a tool that
|
|
80
|
+
# died by signal is `crashed` and transient — its wait status is no more trustworthy than a worker's,
|
|
81
|
+
# since a sibling can signal a tool too.
|
|
82
|
+
def disarm_file_size_signal
|
|
83
|
+
Signal.trap("XFSZ") { nil }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns [connection, queued_ms], or nil once the supervisor has retired this worker by closing the
|
|
87
|
+
# control socket. The connection arrives as a descriptor: the supervisor accepted it and never
|
|
88
|
+
# called recvmsg, so the caller's own descriptors are still queued on it and this worker's recvmsg
|
|
89
|
+
# is what installs them.
|
|
90
|
+
def await_dispatch
|
|
91
|
+
line, descriptors = control.receive_message(limit: DISPATCH_BYTES)
|
|
92
|
+
return nil if line.nil?
|
|
93
|
+
|
|
94
|
+
socket = descriptors.first
|
|
95
|
+
return nil if socket.nil?
|
|
96
|
+
|
|
97
|
+
[ Connection.new(socket), Payload.parse(line).fetch(:queued_ms, 0) ]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def serve(connection, queued_ms)
|
|
101
|
+
timing = Timing.new(queued_ms)
|
|
102
|
+
received = []
|
|
103
|
+
response = nil
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
# Before the request rather than at boot, and one directory rather than two. A tool reads its
|
|
107
|
+
# configuration from $HOME and that configuration is executable, so a home that outlived the
|
|
108
|
+
# request let one compromised conversion reconfigure every later one on this slot. adr/0003.
|
|
109
|
+
#
|
|
110
|
+
# Inside the begin, because a home that cannot be created is a broken deployment and answers
|
|
111
|
+
# `failed`, which is transient. Outside it the raise skipped every response path and reached
|
|
112
|
+
# `run`, which exits the worker — after this ensure had already reported idle `"ok"`, counting a
|
|
113
|
+
# success for a request that never ran.
|
|
114
|
+
ENV["HOME"] = slot.make_home
|
|
115
|
+
|
|
116
|
+
line, received = connection.receive_message
|
|
117
|
+
response = if line.nil?
|
|
118
|
+
# The caller closed before sending a request. Transient, so it is never written against a blob,
|
|
119
|
+
# and named rather than left nil — a nil response reported idle `"ok"`, counting a success nobody
|
|
120
|
+
# received.
|
|
121
|
+
refuse("unavailable", "the connection closed before a request arrived", timing)
|
|
122
|
+
else
|
|
123
|
+
handle(line, received, timing)
|
|
124
|
+
end
|
|
125
|
+
rescue MessageError, AccessModeError => error
|
|
126
|
+
response = refuse("invalid", error, timing)
|
|
127
|
+
# NoMemoryError and MemoryExhausted are this input driving this worker past its own memory, which is
|
|
128
|
+
# permanent. Errno::ENOMEM is deliberately not here: a fork or mmap that cannot get memory is host
|
|
129
|
+
# pressure the input did not cause, so it falls through to `failed` with EMFILE and ENOSPC and is
|
|
130
|
+
# transient. Adding it back would condemn a blob for the cell's own bad moment.
|
|
131
|
+
rescue NoMemoryError, MemoryExhausted => error
|
|
132
|
+
response = refuse(Codes::KILLED, error, timing, cause: Codes::MEMORY)
|
|
133
|
+
# The one place a file-size verdict can be earned. EFBIG comes back from a write this worker made
|
|
134
|
+
# past its own RLIMIT_FSIZE, so unlike the signal it cannot arrive from anywhere else.
|
|
135
|
+
rescue Errno::EFBIG => error
|
|
136
|
+
response = refuse(Codes::KILLED, error, timing, cause: Codes::FSIZE)
|
|
137
|
+
rescue StandardError => error
|
|
138
|
+
response = refuse("failed", error, timing)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
deliver connection, response
|
|
142
|
+
record response, timing
|
|
143
|
+
ensure
|
|
144
|
+
received.each(&:close)
|
|
145
|
+
connection.close
|
|
146
|
+
home = slot.home
|
|
147
|
+
swept = slot.remove_home
|
|
148
|
+
|
|
149
|
+
# After the answer and before reporting idle, which is the only window where this costs nobody. How
|
|
150
|
+
# long it takes is chosen by whatever filled the directory, so it must not run where somebody is
|
|
151
|
+
# waiting: not in the supervisor, whose loop enforces every other request's deadline, and not during
|
|
152
|
+
# staging, where it would spend the next request's deadline on the previous request's mess. Here the
|
|
153
|
+
# caller already has its response, and `report_idle` is what makes this worker available — so the
|
|
154
|
+
# supervisor will not dispatch into a worker that is still sweeping.
|
|
155
|
+
report_uncleaned home unless swept
|
|
156
|
+
report_unswept unless slot.sweep
|
|
157
|
+
report_idle response&.failure
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A removal that failed is the one thing here nobody else can see. The bytes stay on the shared tmpfs
|
|
161
|
+
# after the caller has been told the request is over, and a sibling worker can cause it by writing into
|
|
162
|
+
# the tree while remove_entry walks it. It cannot raise from an ensure, so it says so instead. One line
|
|
163
|
+
# per request, from the ensure, because that is the attempt that knows the final state.
|
|
164
|
+
def report_uncleaned(home)
|
|
165
|
+
log.write "slot.uncleaned", pid: Process.pid, slot: slot.number, home: home
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The other half of the same fact. A sweep removes what the supervisor renamed out of the way after a
|
|
169
|
+
# killed request, and its failure was the one cleanup outcome nobody said anything about — so a slot
|
|
170
|
+
# accumulating one tree per request looked exactly like a slot that was clean.
|
|
171
|
+
def report_unswept
|
|
172
|
+
log.write "slot.unswept", pid: Process.pid, slot: slot.number, home: slot.directory
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def handle(line, received, timing)
|
|
176
|
+
request = Request.parse(line)
|
|
177
|
+
|
|
178
|
+
unless request.current_version?
|
|
179
|
+
return refuse("protocol", request.version_mismatch, timing)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
operation = Registry.lookup(request.op)
|
|
183
|
+
return refuse("unsupported", "no operation named #{request.op.inspect}", timing) if operation.nil?
|
|
184
|
+
|
|
185
|
+
inputs, outputs = wrap(request, received)
|
|
186
|
+
boot operation
|
|
187
|
+
narrow operation
|
|
188
|
+
report_deadline operation
|
|
189
|
+
|
|
190
|
+
perform operation, inputs, outputs, request.payload, timing
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def perform(operation, inputs, outputs, payload, timing)
|
|
194
|
+
timing.performing
|
|
195
|
+
|
|
196
|
+
result = timing.measure(:operation_ms) { operation.new.perform(inputs, outputs, **payload) }
|
|
197
|
+
written = timing.measure(:writeback_ms) { outputs.map(&:post) }
|
|
198
|
+
|
|
199
|
+
return unwritten(outputs, written, timing) if written.any?(&:zero?)
|
|
200
|
+
|
|
201
|
+
# Read before the scratch goes, so perform_ms measures performing and not the cleanup after it.
|
|
202
|
+
Payload.validate! result, "result"
|
|
203
|
+
response = Response.ok(result: result, timing: timing.to_h)
|
|
204
|
+
|
|
205
|
+
# Before answering rather than after, so the window in which a sibling worker could read this
|
|
206
|
+
# request's bytes off the shared tmpfs closes before the caller is told anything. Files are not
|
|
207
|
+
# isolated between concurrent workers and cannot be, so the window's size is the whole control.
|
|
208
|
+
# Not reported here. The ensure below runs after every path through this method and tries again, so
|
|
209
|
+
# it is the one that knows whether the directory is still there when the request is over.
|
|
210
|
+
slot.remove_home
|
|
211
|
+
|
|
212
|
+
response
|
|
213
|
+
rescue *operation.unreadable => error
|
|
214
|
+
refuse "unreadable", error, timing
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# `post` returns what each output received and the worker used to throw all of it away, leaving the
|
|
218
|
+
# client to check the total size of the outputs it had handed over. A total hides the case the
|
|
219
|
+
# multiple-output API exists for: writing the first and skipping the second is a positive total and
|
|
220
|
+
# reads as success. This is the side that knows which one is empty, so it is the side that says so.
|
|
221
|
+
#
|
|
222
|
+
# Transient, for the reason the client's own check is: the commonest way to write nothing is a full
|
|
223
|
+
# tmpfs, and a full filesystem must never be recorded as a verdict on the document.
|
|
224
|
+
def unwritten(outputs, written, timing)
|
|
225
|
+
empty = written.each_index.select { |index| written[index].zero? }
|
|
226
|
+
|
|
227
|
+
refuse "unavailable",
|
|
228
|
+
"#{empty.size} of #{outputs.size} outputs received no bytes (#{empty.join(", ")})",
|
|
229
|
+
timing
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def wrap(request, received)
|
|
233
|
+
unless received.size == request.descriptor_count
|
|
234
|
+
raise MessageError, "#{request.op} wants #{request.descriptor_count} descriptors and " \
|
|
235
|
+
"#{received.size} arrived"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
[ received.first(request.inputs).map.with_index { |io, index| Input.new(io, scratch: scratch("input-#{index}")) },
|
|
239
|
+
received.last(request.outputs).map.with_index { |io, index| Output.new(io, scratch: scratch("output-#{index}")) } ]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A name inside this request's own `$HOME`, which `serve` has already created. Deferred rather than
|
|
243
|
+
# computed up front because a descriptor only asks when the operation reaches for a path, and an
|
|
244
|
+
# operation that reads its descriptors directly never asks at all.
|
|
245
|
+
def scratch(name)
|
|
246
|
+
-> { File.join(slot.home, name) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Tracks the last operation configured for rather than every one ever seen. Above `max_requests_per_worker: 1`
|
|
250
|
+
# a worker can serve A, then B, then A — and a set-shaped memo skipped A's hooks the second time, leaving it
|
|
251
|
+
# running under whatever B had set the shared library to. What these hooks configure is global and singular,
|
|
252
|
+
# so the question is not "has this ever run" but "is this what the library is set up for".
|
|
253
|
+
def boot(operation)
|
|
254
|
+
return if @booted == operation
|
|
255
|
+
|
|
256
|
+
operation.before_worker_boot.each(&:call)
|
|
257
|
+
@booted = operation
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def narrow(operation)
|
|
261
|
+
effective(operation).apply ceiling: configuration.limits
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The supervisor enforces the deadline and never reads a request, so it cannot know that this
|
|
265
|
+
# operation asked for less than the cell's maximum. The worker is the only thing that knows, and it
|
|
266
|
+
# says so before it touches an untrusted byte.
|
|
267
|
+
def report_deadline(operation)
|
|
268
|
+
tell deadline: effective(operation).deadline
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The cause travels with the code so the supervisor can still count a kill by cause. It used to read
|
|
272
|
+
# that off a wait status; it reads the worker's own report now, and only when there is a cause to send,
|
|
273
|
+
# so an ordinary idle report is the two keys it always was.
|
|
274
|
+
#
|
|
275
|
+
# **This is a metric and not a verdict.** The verdict went to the caller on the work connection before
|
|
276
|
+
# this line runs. A compromised worker can report a cause its request never had, or withhold one it
|
|
277
|
+
# did, so `killed_by` is what workers said rather than what happened — which is why the supervisor
|
|
278
|
+
# checks the value against the known causes before interning it, and why nothing downstream may treat
|
|
279
|
+
# it as evidence about a blob.
|
|
280
|
+
def report_idle(failure)
|
|
281
|
+
return tell(idle: true, code: "ok") if failure.nil?
|
|
282
|
+
return tell(idle: true, code: failure.code) if failure.cause.nil?
|
|
283
|
+
|
|
284
|
+
tell idle: true, code: failure.code, cause: failure.cause
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def tell(**message)
|
|
288
|
+
control.write_line JSON.generate(message) << "\n"
|
|
289
|
+
rescue SystemCallError, IOError
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def effective(operation)
|
|
294
|
+
@effective[operation] ||= operation.limits.clamped_to(configuration.limits)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def deliver(connection, response)
|
|
298
|
+
return if response.nil?
|
|
299
|
+
|
|
300
|
+
connection.write_line line_for(response)
|
|
301
|
+
rescue SystemCallError, IOError
|
|
302
|
+
log.write "request.abandoned", pid: Process.pid, slot: slot.number
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def line_for(response)
|
|
306
|
+
response.to_line
|
|
307
|
+
rescue SerializationError, MessageError => error
|
|
308
|
+
Response.failed(Failure.for("failed", error), timing: response.timing).to_line
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def record(response, timing)
|
|
312
|
+
return if response.nil?
|
|
313
|
+
|
|
314
|
+
log.write "request", pid: Process.pid, slot: slot.number, code: response.failure&.code || "ok",
|
|
315
|
+
permanent: response.failure&.permanent?,
|
|
316
|
+
outcome: response.failure ? "failure" : "success",
|
|
317
|
+
duration_ms: timing.elapsed_ms, timing: response.timing
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def refuse(code, detail, timing, cause: nil)
|
|
321
|
+
Response.failed Failure.for(code, detail, cause: cause), timing: timing.to_h
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler auto-requires a gem named "hotcell-server" as "hotcell-server", then as "hotcell/server". This
|
|
4
|
+
# gem uses neither path, because hot_cell/ is what yields the HotCell constant under the default
|
|
5
|
+
# inflection. Without this file, `gem "hotcell-server"` in a Gemfile silently loads nothing at all.
|
|
6
|
+
require "hot_cell/server"
|
metadata
CHANGED
|
@@ -1,24 +1,70 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hotcell-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Dalessio
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: hotcell-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.2.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.2.0
|
|
26
|
+
description: |
|
|
27
|
+
Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
|
|
28
|
+
and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
|
|
29
|
+
HotCell::Operation.
|
|
13
30
|
email:
|
|
14
31
|
- mike@37signals.com
|
|
15
|
-
executables:
|
|
32
|
+
executables:
|
|
33
|
+
- hotcell
|
|
34
|
+
- hotcell-health
|
|
16
35
|
extensions: []
|
|
17
36
|
extra_rdoc_files: []
|
|
18
|
-
files:
|
|
37
|
+
files:
|
|
38
|
+
- MIT-LICENSE
|
|
39
|
+
- README.md
|
|
40
|
+
- exe/hotcell
|
|
41
|
+
- exe/hotcell-health
|
|
42
|
+
- lib/hot_cell/configuration.rb
|
|
43
|
+
- lib/hot_cell/control.rb
|
|
44
|
+
- lib/hot_cell/counters.rb
|
|
45
|
+
- lib/hot_cell/limits.rb
|
|
46
|
+
- lib/hot_cell/log.rb
|
|
47
|
+
- lib/hot_cell/operation.rb
|
|
48
|
+
- lib/hot_cell/registry.rb
|
|
49
|
+
- lib/hot_cell/server.rb
|
|
50
|
+
- lib/hot_cell/server/errors.rb
|
|
51
|
+
- lib/hot_cell/server/version.rb
|
|
52
|
+
- lib/hot_cell/slot.rb
|
|
53
|
+
- lib/hot_cell/supervisor.rb
|
|
54
|
+
- lib/hot_cell/test_cell.rb
|
|
55
|
+
- lib/hot_cell/test_operations.rb
|
|
56
|
+
- lib/hot_cell/timing.rb
|
|
57
|
+
- lib/hot_cell/worker.rb
|
|
58
|
+
- lib/hotcell-server.rb
|
|
59
|
+
homepage: https://github.com/basecamp/hotcell
|
|
19
60
|
licenses:
|
|
20
61
|
- MIT
|
|
21
|
-
metadata:
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://github.com/basecamp/hotcell
|
|
64
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.2.0/hotcell-server
|
|
65
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.2.0/CHANGELOG.md
|
|
66
|
+
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
67
|
+
rubygems_mfa_required: 'true'
|
|
22
68
|
rdoc_options: []
|
|
23
69
|
require_paths:
|
|
24
70
|
- lib
|
|
@@ -26,7 +72,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
26
72
|
requirements:
|
|
27
73
|
- - ">="
|
|
28
74
|
- !ruby/object:Gem::Version
|
|
29
|
-
version: '
|
|
75
|
+
version: '3.3'
|
|
30
76
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
31
77
|
requirements:
|
|
32
78
|
- - ">="
|
|
@@ -35,5 +81,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
35
81
|
requirements: []
|
|
36
82
|
rubygems_version: 4.0.16
|
|
37
83
|
specification_version: 4
|
|
38
|
-
summary:
|
|
84
|
+
summary: 'Run a HotCell: the supervisor, the worker, and the operation API.'
|
|
39
85
|
test_files: []
|