hotcell-client-legacy 0.0.1 → 1.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/README.md +3 -0
- data/lib/hot_cell/client/legacy.rb +274 -0
- data/lib/hotcell-client-legacy.rb +3 -2
- metadata +11 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c542ad1f9adfdf8dad3a0c61984107ee91ec79fe55201d88bc08050a0c57ea1
|
|
4
|
+
data.tar.gz: 55e9c151471e12b47aafa3a49bf8790fc8610f4de1e04bd5c520c4d1fd5eab7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7686e7b921b05c0913dac6a24eb95987be455526c4d9439442767faa0732c3db515b49935c664c27578bca799977066e11a32a203a92e6f707ae3956af8ac902
|
|
7
|
+
data.tar.gz: e1ae1d57ed45e3620dbd71d98b171f3a3034a3e70ddb190fae0365cf6a9a9f142cca8326bec1cb5f34e4bdcde03fc7ed916a0fe3a024805bf558e6be026b30a9
|
data/README.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module HotCell
|
|
7
|
+
# hotcell-client defines Client as a class as well, so this opens it whether or not that gem is loaded.
|
|
8
|
+
class Client
|
|
9
|
+
# A client for an application that cannot run hotcell-client: one file, the standard library only, and
|
|
10
|
+
# legacy Ruby versions. It speaks the same v1 wire protocol to the same work socket.
|
|
11
|
+
#
|
|
12
|
+
# client = HotCell::Client::Legacy.new("/run/hotcell/images/work.sock", timeout: 30, group: 10001)
|
|
13
|
+
# client.perform "images.transform", [ input ], [ output ], "format" => "png"
|
|
14
|
+
#
|
|
15
|
+
# Written for Ruby 1.9.3, so it uses no keyword arguments, `&.`, `String#b` or `IO#wait_readable`.
|
|
16
|
+
class Legacy
|
|
17
|
+
VERSION = "1.1.0"
|
|
18
|
+
|
|
19
|
+
PROTOCOL_VERSION = 1
|
|
20
|
+
MAX_RESPONSE_BYTES = 65_536
|
|
21
|
+
MAX_FIELD_BYTES = 512
|
|
22
|
+
|
|
23
|
+
# The fields of a failure on the wire. Anything else a cell sends is dropped rather than passed on unscrubbed.
|
|
24
|
+
FAILURE_FIELDS = [ :code, :cause, :signal, :class, :message, :stderr ].freeze
|
|
25
|
+
CHUNK_BYTES = 4096
|
|
26
|
+
|
|
27
|
+
# The same modes hotcell-client sets, for the same reason: the cell reopens a descriptor by name as
|
|
28
|
+
# `/dev/fd/N`, which the kernel checks against the cell's uid and group rather than ours.
|
|
29
|
+
INPUT_MODE = 0o640
|
|
30
|
+
OUTPUT_MODE = 0o620
|
|
31
|
+
|
|
32
|
+
# A cell's verdict on a request that did not succeed, with the readers of hotcell-core's HotCell::Failure.
|
|
33
|
+
class Failure
|
|
34
|
+
attr_reader :code, :cause, :signal, :error_class, :message, :stderr
|
|
35
|
+
|
|
36
|
+
def initialize(fields)
|
|
37
|
+
@permanent = fields[:permanent]
|
|
38
|
+
@code = fields[:code].to_s
|
|
39
|
+
@cause = fields[:cause]
|
|
40
|
+
@signal = fields[:signal]
|
|
41
|
+
@error_class = fields[:class]
|
|
42
|
+
@message = fields[:message]
|
|
43
|
+
@stderr = fields[:stderr]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def permanent?
|
|
47
|
+
@permanent
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_h
|
|
51
|
+
{ code: code, permanent: permanent?, cause: cause, signal: signal, class: error_class, message: message,
|
|
52
|
+
stderr: stderr }.reject { |_key, value| value.nil? }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A cell that sent no code gets none in the message, rather than a leading ": ".
|
|
56
|
+
def to_s
|
|
57
|
+
[ (code unless code.empty?), cause, error_class, message ].compact.join(": ")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Included in both failure classes, so `rescue Verdict` catches either and `hot_cell_failure` reads the
|
|
62
|
+
# failure, as with hotcell-client's HotCell::Verdict. The classes share no superclass, so rescuing one by
|
|
63
|
+
# name never catches the other: a permanent failure may be written down against a file forever, and a
|
|
64
|
+
# transient one must be retried.
|
|
65
|
+
module Verdict
|
|
66
|
+
attr_reader :hot_cell_failure
|
|
67
|
+
|
|
68
|
+
def initialize(failure)
|
|
69
|
+
@hot_cell_failure = failure
|
|
70
|
+
super(failure.to_s)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class PermanentFailure < StandardError
|
|
75
|
+
include Verdict
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class TransientFailure < StandardError
|
|
79
|
+
include Verdict
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class Timeout < StandardError; end
|
|
83
|
+
private_constant :Timeout
|
|
84
|
+
|
|
85
|
+
attr_reader :socket_path, :timeout, :group
|
|
86
|
+
|
|
87
|
+
# `timeout` is one deadline in seconds across the connect, the send and the read. `group` is the cell's
|
|
88
|
+
# gid, which every input and output is given before the request goes out; leave it unset only where the
|
|
89
|
+
# application and the cell run as one user.
|
|
90
|
+
def initialize(socket_path, options = {})
|
|
91
|
+
@socket_path = socket_path
|
|
92
|
+
@timeout = options.fetch(:timeout, 30)
|
|
93
|
+
@group = options[:group]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns the operation's result, a Hash with String keys. Raises PermanentFailure or TransientFailure
|
|
97
|
+
# according to the `permanent` flag on the cell's answer; a failure with no answer at all is transient.
|
|
98
|
+
def perform(operation, inputs, outputs, payload = {})
|
|
99
|
+
inputs = [ inputs ] unless inputs.is_a?(Array)
|
|
100
|
+
outputs = [ outputs ] unless outputs.is_a?(Array)
|
|
101
|
+
|
|
102
|
+
# Outside the rescue below, because a file this process cannot chown is the caller's bug, and an
|
|
103
|
+
# application whose own transient class descends from SystemCallError would retry it forever.
|
|
104
|
+
share inputs, INPUT_MODE
|
|
105
|
+
share outputs, OUTPUT_MODE
|
|
106
|
+
|
|
107
|
+
line = JSON.generate("v" => PROTOCOL_VERSION, "op" => operation.to_s, "inputs" => inputs.size,
|
|
108
|
+
"outputs" => outputs.size, "payload" => payload) + "\n"
|
|
109
|
+
|
|
110
|
+
answer = exchange(line, inputs + outputs)
|
|
111
|
+
return answer["result"] if answer["ok"]
|
|
112
|
+
|
|
113
|
+
# Built rather than passed to raise, because Ruby 2.x reads a trailing Hash given to raise as its options
|
|
114
|
+
# and takes `cause` out of it.
|
|
115
|
+
failure = Failure.new(sanitize(answer["error"]))
|
|
116
|
+
raise (failure.permanent? ? PermanentFailure : TransientFailure).new(failure)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
def share(ios, mode)
|
|
121
|
+
return if group.nil?
|
|
122
|
+
|
|
123
|
+
ios.each do |io|
|
|
124
|
+
io.chown nil, group
|
|
125
|
+
io.chmod mode
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def exchange(line, descriptors)
|
|
130
|
+
deadline = now + timeout
|
|
131
|
+
socket = Socket.new(:UNIX, :STREAM)
|
|
132
|
+
connect socket, deadline
|
|
133
|
+
deliver socket, line, descriptors, deadline
|
|
134
|
+
response = receive(socket, deadline)
|
|
135
|
+
return unavailable("the connection closed with no response, so the cell's supervisor is gone") if response.nil?
|
|
136
|
+
|
|
137
|
+
parse response
|
|
138
|
+
rescue Timeout
|
|
139
|
+
transient "timeout", "the cell did not answer within #{timeout}s"
|
|
140
|
+
rescue SystemCallError, IOError => error
|
|
141
|
+
unavailable "#{error.class}: #{error.message}"
|
|
142
|
+
ensure
|
|
143
|
+
socket.close if socket && !socket.closed?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Non-blocking, because a blocking connect to a Unix socket whose backlog is full waits in the kernel
|
|
147
|
+
# with no deadline, and a supervisor that has stopped calling `accept` leaves it full. Linux answers a
|
|
148
|
+
# non-blocking connect to that backlog with EAGAIN and leaves the socket unconnected, so it is retried
|
|
149
|
+
# rather than waited on: an unconnected Unix socket is always writable, and `select` would not wait.
|
|
150
|
+
def connect(socket, deadline)
|
|
151
|
+
socket.connect_nonblock Socket.sockaddr_un(socket_path)
|
|
152
|
+
rescue Errno::EAGAIN
|
|
153
|
+
remaining = deadline - now
|
|
154
|
+
raise Timeout unless remaining > 0
|
|
155
|
+
|
|
156
|
+
sleep [ 0.01, remaining ].min
|
|
157
|
+
retry
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The descriptors ride the first sendmsg and the rest of the line follows as ordinary writes, because a
|
|
161
|
+
# stream socket does not promise that one sendmsg sends all of it, and ancillary data must go exactly
|
|
162
|
+
# once. A request with no descriptors sends no ancillary data at all, as hotcell-core does.
|
|
163
|
+
#
|
|
164
|
+
# A full cell answers `capacity` and closes the connection without reading the request, so the send can
|
|
165
|
+
# fail while the answer is already waiting. A failed send is ignored, and the read decides.
|
|
166
|
+
def deliver(socket, line, descriptors, deadline)
|
|
167
|
+
controls = descriptors.empty? ? [] : [ Socket::AncillaryData.unix_rights(*descriptors.map { |io| io.to_io }) ]
|
|
168
|
+
sent = waiting(socket, :writable, deadline) { socket.sendmsg_nonblock(line, 0, nil, *controls) }
|
|
169
|
+
|
|
170
|
+
until sent == line.bytesize
|
|
171
|
+
sent += waiting(socket, :writable, deadline) { socket.write_nonblock(line.byteslice(sent..-1)) }
|
|
172
|
+
end
|
|
173
|
+
rescue Errno::EPIPE, Errno::ECONNRESET
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Returns nil when the peer closed without sending anything. The deadline covers the whole line rather
|
|
178
|
+
# than each read, so a peer that sends one byte and stops cannot hold the caller past it.
|
|
179
|
+
def receive(socket, deadline)
|
|
180
|
+
buffer = String.new
|
|
181
|
+
|
|
182
|
+
until buffer.end_with?("\n")
|
|
183
|
+
chunk = begin
|
|
184
|
+
waiting(socket, :readable, deadline) { socket.read_nonblock(CHUNK_BYTES) }
|
|
185
|
+
rescue EOFError
|
|
186
|
+
return nil if buffer.empty?
|
|
187
|
+
|
|
188
|
+
raise IOError, "the cell's answer ended after #{buffer.bytesize} bytes with no newline"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
buffer << chunk
|
|
192
|
+
if buffer.bytesize > MAX_RESPONSE_BYTES
|
|
193
|
+
raise IOError, "the cell's answer passed #{MAX_RESPONSE_BYTES} bytes with no newline"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
buffer.force_encoding Encoding::UTF_8
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def waiting(socket, direction, deadline)
|
|
201
|
+
yield
|
|
202
|
+
rescue IO::WaitReadable, IO::WaitWritable
|
|
203
|
+
remaining = deadline - now
|
|
204
|
+
raise Timeout unless remaining > 0
|
|
205
|
+
|
|
206
|
+
readers, writers = direction == :readable ? [ [ socket ], nil ] : [ nil, [ socket ] ]
|
|
207
|
+
raise Timeout unless IO.select(readers, writers, nil, remaining)
|
|
208
|
+
|
|
209
|
+
retry
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# `ok` must be the boolean it says it is, because Ruby would read `"false"`, `0` and `[]` as success, and
|
|
213
|
+
# a garbled answer would become an `ok` carrying no result.
|
|
214
|
+
#
|
|
215
|
+
# String keys, because Ruby before 2.2 never frees a Symbol: symbolizing the keys a cell chooses would let a
|
|
216
|
+
# compromised cell grow this process's memory with every answer.
|
|
217
|
+
def parse(line)
|
|
218
|
+
answer = begin
|
|
219
|
+
JSON.parse(line)
|
|
220
|
+
rescue StandardError => error
|
|
221
|
+
return unavailable("the cell's answer is not JSON: #{error.class}")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
valid = answer.is_a?(Hash) &&
|
|
225
|
+
((answer["ok"] == true && answer["result"].is_a?(Hash)) ||
|
|
226
|
+
(answer["ok"] == false && answer["error"].is_a?(Hash)))
|
|
227
|
+
valid ? answer : unavailable("the cell's answer is not a v#{PROTOCOL_VERSION} response")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A failure's text comes from a process that has just parsed a hostile file, and JSON.parse is not a
|
|
231
|
+
# filter: it passes bytes that are not UTF-8 straight through, and a String holding them makes a regex
|
|
232
|
+
# raise ArgumentError and a log line raise Encoding::CompatibilityError. So every String field is capped
|
|
233
|
+
# and scrubbed, as hotcell-core's Failure does. `stderr` keeps its tail, where the fatal line is.
|
|
234
|
+
def sanitize(error)
|
|
235
|
+
sanitized = { permanent: error["permanent"] == true }
|
|
236
|
+
FAILURE_FIELDS.each do |key|
|
|
237
|
+
value = error[key.to_s]
|
|
238
|
+
sanitized[key] = scrub(value.to_s, key == :stderr) unless value.nil?
|
|
239
|
+
end
|
|
240
|
+
sanitized
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Through UTF-16, because Ruby before 2.1 has no String#scrub and skips an encode from UTF-8 to UTF-8.
|
|
244
|
+
def scrub(text, keep_tail)
|
|
245
|
+
text = text.dup.force_encoding(Encoding::UTF_8)
|
|
246
|
+
if text.bytesize > MAX_FIELD_BYTES
|
|
247
|
+
text = keep_tail ? text.byteslice(-MAX_FIELD_BYTES, MAX_FIELD_BYTES) : text.byteslice(0, MAX_FIELD_BYTES)
|
|
248
|
+
end
|
|
249
|
+
return text if text.valid_encoding?
|
|
250
|
+
|
|
251
|
+
text.encode(Encoding::UTF_16LE, invalid: :replace, undef: :replace, replace: "").encode(Encoding::UTF_8)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def unavailable(message)
|
|
255
|
+
transient "unavailable", message
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def transient(code, message)
|
|
259
|
+
{ "ok" => false, "error" => { "code" => code, "permanent" => false, "message" => message } }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Monotonic where the Ruby has it (2.1 and later), so a clock stepped by NTP cannot stretch a deadline.
|
|
263
|
+
if defined?(Process::CLOCK_MONOTONIC)
|
|
264
|
+
def now
|
|
265
|
+
Process.clock_gettime Process::CLOCK_MONOTONIC
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
def now
|
|
269
|
+
Time.now.to_f
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# Bundler auto-requires a gem named "hotcell-client-legacy" by that name, and hot_cell/ is where the HotCell
|
|
4
|
+
# constant lives.
|
|
5
|
+
require "hot_cell/client/legacy"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hotcell-client-legacy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Dalessio
|
|
@@ -9,9 +9,8 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description:
|
|
13
|
-
|
|
14
|
-
coming shortly.
|
|
12
|
+
description: Call an operation in a HotCell container from an application that cannot
|
|
13
|
+
run hotcell-client. One file, the standard library only, and legacy Ruby versions.
|
|
15
14
|
email:
|
|
16
15
|
- mike@37signals.com
|
|
17
16
|
executables: []
|
|
@@ -19,13 +18,18 @@ extensions: []
|
|
|
19
18
|
extra_rdoc_files: []
|
|
20
19
|
files:
|
|
21
20
|
- MIT-LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/hot_cell/client/legacy.rb
|
|
22
23
|
- lib/hotcell-client-legacy.rb
|
|
23
24
|
homepage: https://github.com/basecamp/hotcell
|
|
24
25
|
licenses:
|
|
25
26
|
- MIT
|
|
26
27
|
metadata:
|
|
27
28
|
homepage_uri: https://github.com/basecamp/hotcell
|
|
28
|
-
source_code_uri: https://github.com/basecamp/hotcell
|
|
29
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v1.1.0/hotcell-client-legacy
|
|
30
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v1.1.0/CHANGELOG.md
|
|
31
|
+
documentation_uri: https://basecamp.github.io/hotcell/
|
|
32
|
+
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
29
33
|
rubygems_mfa_required: 'true'
|
|
30
34
|
rdoc_options: []
|
|
31
35
|
require_paths:
|
|
@@ -41,7 +45,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
41
45
|
- !ruby/object:Gem::Version
|
|
42
46
|
version: '0'
|
|
43
47
|
requirements: []
|
|
44
|
-
rubygems_version: 4.0.
|
|
48
|
+
rubygems_version: 4.0.16
|
|
45
49
|
specification_version: 4
|
|
46
|
-
summary:
|
|
50
|
+
summary: Call a HotCell from a legacy Ruby version, with no dependencies.
|
|
47
51
|
test_files: []
|