hotcell-server 0.0.0 → 0.1.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 +106 -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 +425 -0
- data/lib/hot_cell/timing.rb +47 -0
- data/lib/hot_cell/worker.rb +268 -0
- data/lib/hotcell-server.rb +6 -0
- metadata +59 -9
|
@@ -0,0 +1,268 @@
|
|
|
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
|
+
ENV["HOME"] = slot.home
|
|
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
|
+
# Returns [connection, queued_ms], or nil once the supervisor has retired this worker by closing the
|
|
60
|
+
# control socket. The connection arrives as a descriptor: the supervisor accepted it and never
|
|
61
|
+
# called recvmsg, so the caller's own descriptors are still queued on it and this worker's recvmsg
|
|
62
|
+
# is what installs them.
|
|
63
|
+
def await_dispatch
|
|
64
|
+
line, descriptors = control.receive_message(limit: DISPATCH_BYTES)
|
|
65
|
+
return nil if line.nil?
|
|
66
|
+
|
|
67
|
+
socket = descriptors.first
|
|
68
|
+
return nil if socket.nil?
|
|
69
|
+
|
|
70
|
+
[ Connection.new(socket), Payload.parse(line).fetch(:queued_ms, 0) ]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def serve(connection, queued_ms)
|
|
74
|
+
timing = Timing.new(queued_ms)
|
|
75
|
+
received = []
|
|
76
|
+
response = nil
|
|
77
|
+
|
|
78
|
+
# Before the request rather than at boot, and one directory rather than two. A tool reads its
|
|
79
|
+
# configuration from $HOME and that configuration is executable, so a home that outlived the request
|
|
80
|
+
# let one compromised conversion reconfigure every later one on this slot. adr/0003.
|
|
81
|
+
slot.make_home
|
|
82
|
+
|
|
83
|
+
begin
|
|
84
|
+
line, received = connection.receive_message
|
|
85
|
+
response = if line.nil?
|
|
86
|
+
# The caller closed before sending a request. Transient, so it is never written against a blob,
|
|
87
|
+
# and named rather than left nil — a nil response reported idle `"ok"`, counting a success nobody
|
|
88
|
+
# received.
|
|
89
|
+
refuse("unavailable", "the connection closed before a request arrived", timing)
|
|
90
|
+
else
|
|
91
|
+
handle(line, received, timing)
|
|
92
|
+
end
|
|
93
|
+
rescue MessageError, AccessModeError => error
|
|
94
|
+
response = refuse("invalid", error, timing)
|
|
95
|
+
# NoMemoryError and MemoryExhausted are this input driving this worker past its own memory, which is
|
|
96
|
+
# permanent. Errno::ENOMEM is deliberately not here: a fork or mmap that cannot get memory is host
|
|
97
|
+
# pressure the input did not cause, so it falls through to `failed` with EMFILE and ENOSPC and is
|
|
98
|
+
# transient. Adding it back would condemn a blob for the cell's own bad moment.
|
|
99
|
+
rescue NoMemoryError, MemoryExhausted => error
|
|
100
|
+
response = refuse(Codes::KILLED, error, timing, cause: Codes::MEMORY)
|
|
101
|
+
rescue StandardError => error
|
|
102
|
+
response = refuse("failed", error, timing)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
deliver connection, response
|
|
106
|
+
record response, timing
|
|
107
|
+
ensure
|
|
108
|
+
received.each(&:close)
|
|
109
|
+
connection.close
|
|
110
|
+
swept = slot.remove_home
|
|
111
|
+
|
|
112
|
+
# After the answer and before reporting idle, which is the only window where this costs nobody. How
|
|
113
|
+
# long it takes is chosen by whatever filled the directory, so it must not run where somebody is
|
|
114
|
+
# waiting: not in the supervisor, whose loop enforces every other request's deadline, and not during
|
|
115
|
+
# staging, where it would spend the next request's deadline on the previous request's mess. Here the
|
|
116
|
+
# caller already has its response, and `report_idle` is what makes this worker available — so the
|
|
117
|
+
# supervisor will not dispatch into a worker that is still sweeping.
|
|
118
|
+
slot.sweep
|
|
119
|
+
report_uncleaned unless swept
|
|
120
|
+
report_idle response&.failure&.code
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A removal that failed is the one thing here nobody else can see. The bytes stay on the shared tmpfs
|
|
124
|
+
# after the caller has been told the request is over, and a sibling worker can cause it by writing into
|
|
125
|
+
# the tree while remove_entry walks it. It cannot raise from an ensure, so it says so instead. One line
|
|
126
|
+
# per request, from the ensure, because that is the attempt that knows the final state.
|
|
127
|
+
def report_uncleaned
|
|
128
|
+
log.write "slot.uncleaned", pid: Process.pid, slot: slot.number, home: slot.home
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def handle(line, received, timing)
|
|
132
|
+
request = Request.parse(line)
|
|
133
|
+
|
|
134
|
+
unless request.current_version?
|
|
135
|
+
return refuse("protocol", request.version_mismatch, timing)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
operation = Registry.lookup(request.op)
|
|
139
|
+
return refuse("unsupported", "no operation named #{request.op.inspect}", timing) if operation.nil?
|
|
140
|
+
|
|
141
|
+
inputs, outputs = wrap(request, received)
|
|
142
|
+
boot operation
|
|
143
|
+
narrow operation
|
|
144
|
+
report_deadline operation
|
|
145
|
+
|
|
146
|
+
perform operation, inputs, outputs, request.payload, timing
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def perform(operation, inputs, outputs, payload, timing)
|
|
150
|
+
timing.performing
|
|
151
|
+
|
|
152
|
+
result = timing.measure(:operation_ms) { operation.new.perform(inputs, outputs, **payload) }
|
|
153
|
+
written = timing.measure(:writeback_ms) { outputs.map(&:post) }
|
|
154
|
+
|
|
155
|
+
return unwritten(outputs, written, timing) if written.any?(&:zero?)
|
|
156
|
+
|
|
157
|
+
# Read before the scratch goes, so perform_ms measures performing and not the cleanup after it.
|
|
158
|
+
Payload.validate! result, "result"
|
|
159
|
+
response = Response.ok(result: result, timing: timing.to_h)
|
|
160
|
+
|
|
161
|
+
# Before answering rather than after, so the window in which a sibling worker could read this
|
|
162
|
+
# request's bytes off the shared tmpfs closes before the caller is told anything. Files are not
|
|
163
|
+
# isolated between concurrent workers and cannot be, so the window's size is the whole control.
|
|
164
|
+
# Not reported here. The ensure below runs after every path through this method and tries again, so
|
|
165
|
+
# it is the one that knows whether the directory is still there when the request is over.
|
|
166
|
+
slot.remove_home
|
|
167
|
+
|
|
168
|
+
response
|
|
169
|
+
rescue *operation.unreadable => error
|
|
170
|
+
refuse "unreadable", error, timing
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# `post` returns what each output received and the worker used to throw all of it away, leaving the
|
|
174
|
+
# client to check the total size of the outputs it had handed over. A total hides the case the
|
|
175
|
+
# multiple-output API exists for: writing the first and skipping the second is a positive total and
|
|
176
|
+
# reads as success. This is the side that knows which one is empty, so it is the side that says so.
|
|
177
|
+
#
|
|
178
|
+
# Transient, for the reason the client's own check is: the commonest way to write nothing is a full
|
|
179
|
+
# tmpfs, and a full filesystem must never be recorded as a verdict on the document.
|
|
180
|
+
def unwritten(outputs, written, timing)
|
|
181
|
+
empty = written.each_index.select { |index| written[index].zero? }
|
|
182
|
+
|
|
183
|
+
refuse "unavailable",
|
|
184
|
+
"#{empty.size} of #{outputs.size} outputs received no bytes (#{empty.join(", ")})",
|
|
185
|
+
timing
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def wrap(request, received)
|
|
189
|
+
unless received.size == request.descriptor_count
|
|
190
|
+
raise MessageError, "#{request.op} wants #{request.descriptor_count} descriptors and " \
|
|
191
|
+
"#{received.size} arrived"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
[ received.first(request.inputs).map.with_index { |io, index| Input.new(io, scratch: scratch("input-#{index}")) },
|
|
195
|
+
received.last(request.outputs).map.with_index { |io, index| Output.new(io, scratch: scratch("output-#{index}")) } ]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A name inside this request's own `$HOME`, which `serve` has already created. Deferred rather than
|
|
199
|
+
# computed up front because a descriptor only asks when the operation reaches for a path, and an
|
|
200
|
+
# operation that reads its descriptors directly never asks at all.
|
|
201
|
+
def scratch(name)
|
|
202
|
+
-> { File.join(slot.home, name) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Tracks the last operation configured for rather than every one ever seen. Above `max_requests_per_worker: 1`
|
|
206
|
+
# a worker can serve A, then B, then A — and a set-shaped memo skipped A's hooks the second time, leaving it
|
|
207
|
+
# running under whatever B had set the shared library to. What these hooks configure is global and singular,
|
|
208
|
+
# so the question is not "has this ever run" but "is this what the library is set up for".
|
|
209
|
+
def boot(operation)
|
|
210
|
+
return if @booted == operation
|
|
211
|
+
|
|
212
|
+
operation.before_worker_boot.each(&:call)
|
|
213
|
+
@booted = operation
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def narrow(operation)
|
|
217
|
+
effective(operation).apply ceiling: configuration.limits
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The supervisor enforces the deadline and never reads a request, so it cannot know that this
|
|
221
|
+
# operation asked for less than the cell's maximum. The worker is the only thing that knows, and it
|
|
222
|
+
# says so before it touches an untrusted byte.
|
|
223
|
+
def report_deadline(operation)
|
|
224
|
+
tell deadline: effective(operation).deadline
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def report_idle(code)
|
|
228
|
+
tell idle: true, code: code || "ok"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def tell(**message)
|
|
232
|
+
control.write_line JSON.generate(message) << "\n"
|
|
233
|
+
rescue SystemCallError, IOError
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def effective(operation)
|
|
238
|
+
@effective[operation] ||= operation.limits.clamped_to(configuration.limits)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def deliver(connection, response)
|
|
242
|
+
return if response.nil?
|
|
243
|
+
|
|
244
|
+
connection.write_line line_for(response)
|
|
245
|
+
rescue SystemCallError, IOError
|
|
246
|
+
log.write "request.abandoned", pid: Process.pid, slot: slot.number
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def line_for(response)
|
|
250
|
+
response.to_line
|
|
251
|
+
rescue SerializationError, MessageError => error
|
|
252
|
+
Response.failed(Failure.for("failed", error), timing: response.timing).to_line
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def record(response, timing)
|
|
256
|
+
return if response.nil?
|
|
257
|
+
|
|
258
|
+
log.write "request", pid: Process.pid, slot: slot.number, code: response.failure&.code || "ok",
|
|
259
|
+
permanent: response.failure&.permanent?,
|
|
260
|
+
outcome: response.failure ? "failure" : "success",
|
|
261
|
+
duration_ms: timing.elapsed_ms, timing: response.timing
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def refuse(code, detail, timing, cause: nil)
|
|
265
|
+
Response.failed Failure.for(code, detail, cause: cause), timing: timing.to_h
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
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,74 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hotcell-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.1.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.1.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.1.0
|
|
26
|
+
description: |
|
|
27
|
+
The cell side of HotCell. A supervisor listens on two Unix sockets, forks a worker per request,
|
|
28
|
+
holds each worker to a wall-clock deadline and a set of resource limits, and answers for one that
|
|
29
|
+
dies without reporting.
|
|
30
|
+
|
|
31
|
+
An operation subclasses HotCell::Operation, declares its limits, and implements perform. This gem
|
|
32
|
+
deliberately depends on no application framework: nothing about it loads ActiveSupport, because a
|
|
33
|
+
sandbox should carry only what the conversion needs.
|
|
13
34
|
email:
|
|
14
35
|
- mike@37signals.com
|
|
15
|
-
executables:
|
|
36
|
+
executables:
|
|
37
|
+
- hotcell
|
|
38
|
+
- hotcell-health
|
|
16
39
|
extensions: []
|
|
17
40
|
extra_rdoc_files: []
|
|
18
|
-
files:
|
|
41
|
+
files:
|
|
42
|
+
- MIT-LICENSE
|
|
43
|
+
- README.md
|
|
44
|
+
- exe/hotcell
|
|
45
|
+
- exe/hotcell-health
|
|
46
|
+
- lib/hot_cell/configuration.rb
|
|
47
|
+
- lib/hot_cell/control.rb
|
|
48
|
+
- lib/hot_cell/counters.rb
|
|
49
|
+
- lib/hot_cell/limits.rb
|
|
50
|
+
- lib/hot_cell/log.rb
|
|
51
|
+
- lib/hot_cell/operation.rb
|
|
52
|
+
- lib/hot_cell/registry.rb
|
|
53
|
+
- lib/hot_cell/server.rb
|
|
54
|
+
- lib/hot_cell/server/errors.rb
|
|
55
|
+
- lib/hot_cell/server/version.rb
|
|
56
|
+
- lib/hot_cell/slot.rb
|
|
57
|
+
- lib/hot_cell/supervisor.rb
|
|
58
|
+
- lib/hot_cell/test_cell.rb
|
|
59
|
+
- lib/hot_cell/test_operations.rb
|
|
60
|
+
- lib/hot_cell/timing.rb
|
|
61
|
+
- lib/hot_cell/worker.rb
|
|
62
|
+
- lib/hotcell-server.rb
|
|
63
|
+
homepage: https://github.com/basecamp/hotcell
|
|
19
64
|
licenses:
|
|
20
65
|
- MIT
|
|
21
|
-
metadata:
|
|
66
|
+
metadata:
|
|
67
|
+
homepage_uri: https://github.com/basecamp/hotcell
|
|
68
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.1.0/hotcell-server
|
|
69
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.1.0/CHANGELOG.md
|
|
70
|
+
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
71
|
+
rubygems_mfa_required: 'true'
|
|
22
72
|
rdoc_options: []
|
|
23
73
|
require_paths:
|
|
24
74
|
- lib
|
|
@@ -26,7 +76,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
26
76
|
requirements:
|
|
27
77
|
- - ">="
|
|
28
78
|
- !ruby/object:Gem::Version
|
|
29
|
-
version: '
|
|
79
|
+
version: '3.3'
|
|
30
80
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
31
81
|
requirements:
|
|
32
82
|
- - ">="
|
|
@@ -35,5 +85,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
35
85
|
requirements: []
|
|
36
86
|
rubygems_version: 4.0.16
|
|
37
87
|
specification_version: 4
|
|
38
|
-
summary:
|
|
88
|
+
summary: 'Run a HotCell: the supervisor, the worker, and the operation API.'
|
|
39
89
|
test_files: []
|