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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecf0e58153cf254f26b232007a8d92f341e326b827374bf084e5e8fceed640ab
4
- data.tar.gz: fed4469f52bbe247d710a9dbefaaedeb785593f23770ef7dd16d5a8027828792
3
+ metadata.gz: 3c542ad1f9adfdf8dad3a0c61984107ee91ec79fe55201d88bc08050a0c57ea1
4
+ data.tar.gz: 55e9c151471e12b47aafa3a49bf8790fc8610f4de1e04bd5c520c4d1fd5eab7d
5
5
  SHA512:
6
- metadata.gz: 9235228e94081fa2a603c31fc23046e12b59a075db976f0cdaebe929dbc8998f90436f27f49641c88a3def20fec80840bf16b5b54e45a82f68038592ca2202c0
7
- data.tar.gz: 740d1271709510773941cab4c3559a2d9064a11c3a49dfac13330137e9184936e35391c1147a79fd93c41e29f44a3dec0e1008988d5b308c62eb12c1a8625069
6
+ metadata.gz: 7686e7b921b05c0913dac6a24eb95987be455526c4d9439442767faa0732c3db515b49935c664c27578bca799977066e11a32a203a92e6f707ae3956af8ac902
7
+ data.tar.gz: e1ae1d57ed45e3620dbd71d98b171f3a3034a3e70ddb190fae0365cf6a9a9f142cca8326bec1cb5f34e4bdcde03fc7ed916a0fe3a024805bf558e6be026b30a9
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # hotcell-client-legacy
2
+
3
+ Part of [HotCell](https://github.com/basecamp/hotcell). See [docs/client-legacy.md](https://github.com/basecamp/hotcell/blob/master/docs/client-legacy.md).
@@ -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
- # A placeholder that reserves the gem name. The real hotcell-client-legacy is coming shortly from
4
- # https://github.com/basecamp/hotcell.
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: 0.0.1
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: A placeholder that reserves the name. A real release of hotcell-client-legacy,
13
- a dependency-free client for calling a HotCell cell from legacy Ruby versions, is
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.6
48
+ rubygems_version: 4.0.16
45
49
  specification_version: 4
46
- summary: Placeholder for the HotCell client for legacy Ruby versions.
50
+ summary: Call a HotCell from a legacy Ruby version, with no dependencies.
47
51
  test_files: []