hotcell-client 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a30252d4ffced6434f939fc313543c682d36abfdcb34d5b70431b9dc5002561a
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: d6808ab8f7c6d12e3917dca07053e1c1ca88b686f43626b895f96a84e2625611
4
+ data.tar.gz: ee7b60efeaf1b1baed200abe2bc2756d80b75507259b98171c55e78b652ca1d8
5
5
  SHA512:
6
- metadata.gz: f81150fc7be64160288e5ba3433d7561e9d500de3b5d248721da66ba03a75110f9692d5c40f04f5e86e7e6c9b2a45174092ddbea0a7adc381a36d99643697940
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: 0135bebaa226693ef7c11f5e34ab1562bf6c58694ffbfb0af7aa3666acdeca05930a814696ed0b7097126deb1b8b3683033e3dead4134cecb12edfce65b20d3f
7
+ data.tar.gz: 1998e3e48ded09c5a5975574ce4f961652c8f0e6f635ab875dafc8cc2eb0553301c9dc0271386657f6348cc22d80358e29f0961d046b417a1cfbcae0931b86da
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 37signals, LLC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # hotcell-client
2
+
3
+ Part of [HotCell](https://github.com/basecamp/hotcell). See the repository README.
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # One registered cell: where its sockets are, how long this application will wait, and which of its own
5
+ # exception classes to raise for each side of the permanent split.
6
+ #
7
+ # Both socket paths are derived from one directory, so the volume mounts are mechanical rather than
8
+ # something to remember and the two sockets cannot end up apart.
9
+ class Cell
10
+ attr_reader :name, :timeout, :control_timeout, :permanent, :transient, :transport
11
+
12
+ # `timeout` covers work, so it is sized to clear the cell's `answer_within` and a saturated cell reports
13
+ # its own verdict rather than a transport failure. `control_timeout` covers `describe` and `metrics`,
14
+ # which the supervisor answers inline with no fork or queue — so it is short on purpose. Sharing one
15
+ # number would give the call whose job is to say "this cell is down" the patience of a video transcode.
16
+ #
17
+ # Both bound the answer rather than the whole call: connecting is not covered. Transport::Socket says
18
+ # why that is left alone.
19
+ def initialize(name, dir: nil, timeout: 30, control_timeout: 5,
20
+ permanent: PermanentFailure, transient: TransientFailure,
21
+ on_contract_skew: nil, transport: Transport::Socket.new)
22
+ @name = name.to_s
23
+ @dir = dir
24
+ @timeout = timeout
25
+ @control_timeout = control_timeout
26
+ @permanent = permanent
27
+ @transient = transient
28
+ @on_contract_skew = on_contract_skew
29
+ @transport = transport
30
+
31
+ verify_classification!
32
+ end
33
+
34
+ # Resolved on every call rather than at registration, which is what makes turning a path on a
35
+ # configuration change instead of a release. A directory consulted once in HotCell.register would make
36
+ # every flip a deploy, and reverting one too.
37
+ def directory
38
+ return @dir.call if @dir.respond_to?(:call)
39
+
40
+ @dir || (HotCell.root && File.join(HotCell.root, name))
41
+ end
42
+
43
+ # Unset means this path is off, and the caller runs in process exactly as it did before.
44
+ def enabled?
45
+ !directory.nil?
46
+ end
47
+
48
+ def work_socket
49
+ File.join directory, "work.sock"
50
+ end
51
+
52
+ def control_socket
53
+ File.join directory, "control.sock"
54
+ end
55
+
56
+ def exception_for(failure)
57
+ failure.permanent? ? permanent : transient
58
+ end
59
+
60
+ # Contract skew needs its own reporting hook because applications rescue broadly around
61
+ # representations, so "raise" is indistinguishable from "placeholder" and the skew is otherwise
62
+ # invisible. An application running several clients against several independently-booted cells needs to
63
+ # know which one skewed.
64
+ def report_contract_skew(error)
65
+ @on_contract_skew&.call error, self
66
+ end
67
+
68
+ # Static, and called once at boot. The cheapest way to catch a client pointed at a cell that does not
69
+ # carry the operation it wants, which is otherwise an `unsupported` on the first real request.
70
+ #
71
+ # Boot must not fail when a cell does not answer. A cell that is down at app boot is a degraded
72
+ # deployment rather than a broken one, and an application that refuses to start because its thumbnail
73
+ # cell is restarting is worse than one that serves placeholders. So this warns and carries on.
74
+ def describe
75
+ return nil unless enabled?
76
+
77
+ response = control(DESCRIBE)
78
+ unless response.ok?
79
+ HotCell.logger.warn "hotcell #{name}: #{unreachable_because response.failure}"
80
+ return nil
81
+ end
82
+
83
+ warn_about_timeout response.result
84
+ warn_about_missing_operations response.result
85
+ warn_about_group_skew response.result
86
+ response.result
87
+ end
88
+
89
+ def metrics
90
+ enabled? ? control(METRICS) : nil
91
+ end
92
+
93
+ private
94
+ # A cell's sockets are `0660`, so the group that lets a worker re-open a descriptor is also the group
95
+ # that admits a caller. EACCES therefore means one thing, and it is worth saying rather than leaving an
96
+ # operator to read "could not describe the cell" as "the cell is down". Every other failure reads that
97
+ # way correctly, because a restarting accessory is the common one.
98
+ def unreachable_because(failure)
99
+ if failure.error_class == "Errno::EACCES"
100
+ "this process may not open the cell's socket. Both sides share a group, and this one is in " \
101
+ "#{Process.groups.sort.inspect}. Add the cell's gid to this container (Kamal: `group-add` under " \
102
+ "the role's `options:`)."
103
+ else
104
+ "could not describe the cell (#{failure})"
105
+ end
106
+ end
107
+
108
+ def control(op)
109
+ transport.call self, Request.new(op: op).to_line, [], socket: control_socket, timeout: control_timeout
110
+ end
111
+
112
+ # The client's timeout is a sum rather than a comparison: a request may wait queue_wait in the queue
113
+ # and then run for deadline, and only then does the cell get to say what happened.
114
+ #
115
+ # Being bound tighter than that is defensible and it is a choice, not a mistake. A synchronous
116
+ # representation request wants it tighter, because a thread held for sixty seconds is a thread not
117
+ # serving traffic. A background job wants it looser, so it receives `capacity` or `killed` and can act
118
+ # on them rather than guessing from a socket error. Both outcomes are transient, so neither is
119
+ # misclassified — which is the only reason this is safe.
120
+ # The cell states this; adding it up here would mean guessing at stages only the supervisor knows about.
121
+ # A cell too old to report it says nothing, which is the right answer for a number we cannot know.
122
+ def warn_about_timeout(described)
123
+ needed = described[:answer_within]
124
+ return if needed.nil? || timeout.nil? || timeout >= needed
125
+
126
+ HotCell.logger.warn "hotcell #{name}: this client waits #{seconds timeout} and the cell says it may " \
127
+ "take #{seconds needed} to answer (queue_wait #{seconds described[:queue_wait]} + " \
128
+ "deadline #{seconds described[:deadline]} + the time to kill and reply), so a " \
129
+ "saturated cell will arrive here as a transport failure rather than as its own " \
130
+ "verdict. Deliberate on a synchronous path; a mistake for a background job."
131
+ end
132
+
133
+ # The cell reports seconds as floats, and "41.0s" is a worse sentence than "41s".
134
+ def seconds(value)
135
+ "#{format("%g", value)}s"
136
+ end
137
+
138
+ # HotCell.group is a number in this application's deploy file, and the cell's gid is baked into an
139
+ # image built somewhere else. Nothing else compares them, so a cell image that changed its gid would
140
+ # be an EACCES on every conversion — with a probe that was green the day before.
141
+ #
142
+ # A cell too old to report its groups says nothing, which is the right answer for a number we cannot
143
+ # know. So is a client with no group configured, which is the one-user case.
144
+ def warn_about_group_skew(described)
145
+ carried = described[:groups]
146
+ return if HotCell.group.nil? || carried.nil? || carried.include?(HotCell.group)
147
+
148
+ HotCell.logger.warn "hotcell #{name}: HotCell.group is #{HotCell.group} and this cell runs in " \
149
+ "#{carried.inspect}, so it cannot open a file this application hands it and " \
150
+ "every operation that gives a tool a filename will fail with EACCES."
151
+ end
152
+
153
+ def warn_about_missing_operations(described)
154
+ carried = Array(described[:operations])
155
+ wanted = HotCell.clients.select { |client| client.hotcell == name }
156
+
157
+ wanted.reject { |client| carried.include?(client.operation) }.each do |client|
158
+ HotCell.logger.warn "hotcell #{name}: #{client} wants #{client.operation.inspect} and this cell " \
159
+ "carries #{carried.inspect}"
160
+ end
161
+ end
162
+
163
+ def verify_classification!
164
+ return if permanent.nil? || transient.nil?
165
+ return unless transient <= permanent
166
+
167
+ raise ConfigurationError,
168
+ "transient: #{transient} descends from permanent: #{permanent}, so every retryable failure " \
169
+ "would be recorded as a permanent one. The inheritance graph is the classification."
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module HotCell
6
+ class << self
7
+ # The directory a cell's name resolves under, holding one subdirectory per cell. Unset means no cell is
8
+ # reachable at all, which is the off position of the whole rollout.
9
+ attr_accessor :root
10
+
11
+ # The group both sides hold, so a cell can open a caller's file by name. An operation that hands a tool
12
+ # a filename re-opens the descriptor as `/dev/fd/N`, and the kernel rechecks that open against the
13
+ # cell's own credentials rather than the caller's — so a file only this application can read fails with
14
+ # EACCES however the descriptor was passed. Set this to the cell's gid and put the application in that
15
+ # group; the client then narrows each descriptor's mode on the way out. See Client#wrap.
16
+ #
17
+ # Leave it unset where both sides already run as one user, which is how development runs.
18
+ attr_reader :group
19
+
20
+ # Takes the String an environment variable holds, so the initializer is one assignment with no
21
+ # coercion. `to_i` would read garbage as gid 0 — root's group — so a value that is not a number
22
+ # raises instead.
23
+ def group=(value)
24
+ @group = value.nil? ? nil : Integer(value)
25
+ rescue ArgumentError, TypeError
26
+ raise ConfigurationError, "HotCell.group is #{value.inspect} and must be a numeric gid"
27
+ end
28
+
29
+ attr_writer :logger
30
+
31
+ def logger
32
+ @logger ||= Logger.new($stderr)
33
+ end
34
+
35
+ # Cells are registered once.
36
+ #
37
+ # HotCell.root = ENV.fetch("HOTCELL_ROOT", "/run/hotcell")
38
+ #
39
+ # HotCell.register "active_storage",
40
+ # permanent: ActiveStorage::PreviewError,
41
+ # transient: MyApp::ConversionTemporarilyUnavailable
42
+ #
43
+ # HotCell.register "archiver", dir: -> { ENV["HOTCELL_ARCHIVER_DIR"] }, timeout: 300
44
+ def register(name, **options)
45
+ Cell.new(name, **options).tap { |cell| cells[cell.name] = cell }
46
+ end
47
+
48
+ def cells
49
+ @cells ||= {}
50
+ end
51
+
52
+ def cell(name)
53
+ cells.fetch(name.to_s) do
54
+ raise UnregisteredCell, "no cell named #{name.to_s.inspect} is registered (#{cells.keys.inspect})"
55
+ end
56
+ end
57
+
58
+ # Whether a name is registered, for a caller with a fallback rather than a requirement.
59
+ def cell?(name)
60
+ cells.key?(name.to_s)
61
+ end
62
+
63
+ # Every HotCell::Client subclass, so a boot check can tell which cell is expected to carry what. This
64
+ # records what the process has loaded rather than what it has configured, so resetting registrations
65
+ # leaves it alone: the classes are still defined either way.
66
+ def clients
67
+ @clients ||= []
68
+ end
69
+
70
+ # Call once at boot, after registering. Warns and carries on; see Cell#describe.
71
+ def describe_cells
72
+ warn_about_group
73
+ cells.each_value.to_h { |cell| [ cell.name, cell.describe ] }
74
+ end
75
+
76
+ # A group this process does not hold cannot be given to a file, so without this the first conversion
77
+ # fails as EPERM from the client's own chown — after the deployment is live and carrying traffic. The
78
+ # check is local and needs no cell, so it reports a missing `group-add` even when every cell is down.
79
+ def warn_about_group
80
+ return if group.nil? || group == Process.gid || group == Process.egid
81
+ return if Process.groups.include?(group)
82
+
83
+ logger.warn "hotcell: HotCell.group is #{group} and this process is in #{Process.groups.sort.inspect}, " \
84
+ "so it cannot put a descriptor in that group and every conversion will fail with EPERM. " \
85
+ "Add the group to this container (Kamal: `group-add` under the role's `options:`), or " \
86
+ "unset HotCell.group where both sides run as one user."
87
+ end
88
+
89
+ # Test support. Named apart from the server gem's own reset, because both gems open this module and a
90
+ # shared name would mean whichever loaded last silently won.
91
+ def reset_registrations!
92
+ @cells = nil
93
+ @root = nil
94
+ @group = nil
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # A class and not a module: this file loads before hot_cell/client.rb opens the same name.
5
+ class Client
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hot_cell/core"
4
+
5
+ # Notifications reaches for IsolatedExecutionState the moment something is actually subscribed, and not
6
+ # before — so requiring only notifications works until the first subscriber appears and then raises
7
+ # NameError in production.
8
+ require "active_support/isolated_execution_state"
9
+ require "active_support/notifications"
10
+
11
+ require "hot_cell/client/version"
12
+ require "hot_cell/failures"
13
+ require "hot_cell/transport"
14
+ require "hot_cell/cell"
15
+ require "hot_cell/cells"
16
+
17
+ require "hot_cell/railtie" if defined?(::Rails::Railtie)
18
+
19
+ module HotCell
20
+ # The application side. A client class names the cell that serves it, and the call carries the same
21
+ # three things an operation receives on the other side — with the payload Hash arriving there as
22
+ # keyword arguments.
23
+ #
24
+ # class ArchiveFolder < HotCell::Client
25
+ # hotcell "archiver"
26
+ # end
27
+ #
28
+ # ArchiveFolder.perform_in_hotcell(inputs, outputs, payload)
29
+ #
30
+ # Routing is a class-level declaration rather than a call-site argument, so call sites carry no deployment
31
+ # detail and several clients may name the same cell.
32
+ class Client
33
+ # What a shared group is narrowed to on the way out, and the reason the group is safe to give away. A
34
+ # cell may read an input and never write one; it may write an output and never read one. The kernel
35
+ # applies these on every re-open by name, and the cell cannot widen either — changing a mode needs
36
+ # ownership, the caller owns these files, and `cap-drop ALL` leaves no capability that overrides it.
37
+ INPUT_MODE = 0o640
38
+ OUTPUT_MODE = 0o620
39
+
40
+ class << self
41
+ include Declarations
42
+
43
+ def inherited(subclass)
44
+ super
45
+ HotCell.clients << subclass
46
+ end
47
+
48
+ def hotcell(name = nil)
49
+ return inherited_value(:@cell_name) if name.nil?
50
+
51
+ @cell_name = name.to_s
52
+ end
53
+
54
+ def operation(name = nil)
55
+ return @operation_name || Naming.default_operation_name(self) if name.nil?
56
+
57
+ @operation_name = name.to_s
58
+ end
59
+
60
+ def cell
61
+ name = hotcell
62
+ raise ConfigurationError, "#{self} must name its cell with `hotcell \"a_name\"`" if name.nil?
63
+
64
+ HotCell.cell name
65
+ end
66
+
67
+ # Whether this client's named cell is registered, for a boot-time hook that must not raise on an
68
+ # application that has bundled the gem and not yet written the initializer.
69
+ def registered?
70
+ !hotcell.nil? && HotCell.cell?(hotcell)
71
+ end
72
+
73
+ # Whether this path is turned on. A caller that finds it off runs in process exactly as it did before,
74
+ # which is the whole rollout mechanism.
75
+ def enabled?
76
+ cell.enabled?
77
+ end
78
+
79
+ def perform_in_hotcell(inputs, outputs, payload = {})
80
+ new.perform_in_hotcell inputs, outputs, payload
81
+ end
82
+ end
83
+
84
+ def perform_in_hotcell(inputs, outputs, payload = {})
85
+ # Explicit wrapping rather than Array(): an IO is Enumerable, so Array(io) reads the stream line by
86
+ # line instead of wrapping it.
87
+ inputs = [ inputs ] unless inputs.is_a?(Array)
88
+ outputs = [ outputs ] unless outputs.is_a?(Array)
89
+
90
+ cell = self.class.cell
91
+
92
+ unless cell.enabled?
93
+ raise CellNotConfigured, "cell #{cell.name.inspect} has no socket directory, so this path is off"
94
+ end
95
+
96
+ # Everything up to here raises for itself, above the transport's rescue and on purpose. A payload
97
+ # value JSON cannot carry, a descriptor with the wrong access mode, or a request over the byte limit
98
+ # are this caller's bugs. An application whose transient class descends from IOError would otherwise
99
+ # have its own bad call reclassified as a socket failure and retried forever.
100
+ descriptors = wrap(inputs, outputs)
101
+ line = request_line(inputs, outputs, payload)
102
+
103
+ response = nil
104
+ ActiveSupport::Notifications.instrument "perform.hot_cell" do |event|
105
+ response = verify_output(cell.transport.call(cell, line, descriptors), outputs)
106
+ publish event, cell, response, inputs, outputs
107
+ end
108
+
109
+ raise_for response, cell
110
+ response.result
111
+ end
112
+
113
+ private
114
+ def wrap(inputs, outputs)
115
+ inputs.map { |io| Input.new(shared(io, INPUT_MODE)) } +
116
+ outputs.map { |io| Output.new(shared(io, OUTPUT_MODE)) }
117
+ end
118
+
119
+ # Through the descriptor rather than the path, so this names no file: fchown and fchmod take the open
120
+ # file the caller already gave us. Both need ownership, which the caller has and the cell does not.
121
+ #
122
+ # **Accepted risk.** These are set and not put back, so the caller's file keeps this group and this
123
+ # mode after the request — a `0600` file of the caller's own returns readable by the cell's group. The
124
+ # premise is that restoring is worse than carrying it: the cell may still hold the descriptor, undoing
125
+ # it mid-request adds a failure path to the answer, and a caller that could not share the file could
126
+ # not use this at all. Active Storage hands over tempfiles it then unlinks, so nothing survives there.
127
+ # docs/DEPLOYMENT.md tells anyone writing their own client to pass files they are willing to share.
128
+ def shared(io, mode)
129
+ return io if HotCell.group.nil?
130
+
131
+ io.chown nil, HotCell.group
132
+ io.chmod mode
133
+ io
134
+ end
135
+
136
+ def request_line(inputs, outputs, payload)
137
+ Request.new(op: self.class.operation, inputs: inputs.size, outputs: outputs.size,
138
+ payload: payload).to_line
139
+ end
140
+
141
+ # `ok` with zero bytes written is a failure, and the client is where it has to be caught. The worker
142
+ # flushes before reporting success, so this should not happen — which is precisely why it must be
143
+ # handled rather than assumed away. A full tmpfs on the cell arrives this way, as ENOSPC from the copy
144
+ # rather than from the socket, and a full filesystem must never be recorded as "this document is
145
+ # unprocessable". So it is transient, not a valid empty image.
146
+ # Only a measured zero, never a stat that failed. `byte_count` used to answer 0 for both, which turned
147
+ # a descriptor this process could no longer stat into "the cell wrote nothing" — a transient failure
148
+ # manufactured on a request that had succeeded, and a variant thrown away for it.
149
+ def verify_output(response, outputs)
150
+ return response if !response.ok? || outputs.empty?
151
+ return response unless byte_count(outputs)&.zero?
152
+
153
+ Response.failed Failure.new(code: "unavailable",
154
+ message: "the cell reported success and wrote no bytes"),
155
+ timing: response.timing
156
+ end
157
+
158
+ # `code` belongs on the event rather than only on an exception. A caller configured to treat
159
+ # `unreadable` as data would otherwise make those requests invisible, and unreadable rates are exactly
160
+ # what you want to watch after a library upgrade. `capacity` matters for the same reason: without its
161
+ # rate you cannot size a worker pool.
162
+ #
163
+ # The cause, signal and verdict go with it, because the code alone cannot classify a kill: `killed`
164
+ # is permanent for fsize and memory and transient for deadline and crashed. A subscriber with only
165
+ # the code filed every fsize kill as transient. `permanent` is the Failure's own answer, so no
166
+ # subscriber re-derives it.
167
+ #
168
+ # A subscriber's own duration minus perform_ms is transport plus queueing, and those want separate
169
+ # metrics: a rising perform_ms means the work got more expensive, and a rising difference means the
170
+ # cell is saturated.
171
+ def publish(event, cell, response, inputs, outputs)
172
+ failure = response.failure
173
+
174
+ event[:operation] = self.class.operation
175
+ event[:cell] = cell.name
176
+ event[:code] = failure&.code
177
+ event[:cause] = failure&.cause
178
+ event[:signal] = failure&.signal
179
+ event[:permanent] = failure&.permanent?
180
+ event[:bytes_in] = byte_count(inputs)
181
+ event[:bytes_out] = byte_count(outputs)
182
+ event[:perform_ms] = response.timing[:perform_ms]
183
+ event[:timing] = response.timing
184
+ end
185
+
186
+ # nil means "could not measure", which is not the same fact as zero and must not be confused with it.
187
+ def byte_count(ios)
188
+ ios.sum { |io| io.stat.size }
189
+ rescue SystemCallError, IOError
190
+ nil
191
+ end
192
+
193
+ def raise_for(response, cell)
194
+ return if response.ok?
195
+
196
+ failure = response.failure
197
+ error = cell.exception_for(failure).new(failure.to_s)
198
+ cell.report_contract_skew error if failure.code == "protocol"
199
+
200
+ raise error
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # The gem cannot pick an application's exception classes, because two applications already do
5
+ # irreconcilable things with the same one. One puts ActiveStorage::PreviewError in an
6
+ # UNPROCESSABLE_ERRORS list and writes metadata["unprocessable"] = true, which no code path anywhere in
7
+ # that application ever un-writes. Another never marks at representation time and splits on the cache
8
+ # header instead, serving a file-icon placeholder with a hundred-year expiry for a permanent failure and
9
+ # no-store for anything else.
10
+ #
11
+ # So a gem that raised PreviewError for a capacity refusal would, in the first, permanently destroy the
12
+ # thumbnail of every blob viewed during a cell restart, with no recovery short of a hand-written backfill.
13
+ # `permanent` on the wire says which class to raise; injection is what stops the gem from guessing.
14
+ #
15
+ # These two are the fallback for a registration that injected neither. They are deliberately classes no
16
+ # application already rescues, so an unclassified failure surfaces as a loud 500 rather than as a silent
17
+ # permanent mark. Loud is the right way round.
18
+ class PermanentFailure < StandardError; end
19
+
20
+ # Must not descend from PermanentFailure, and that is not a stylistic point: the inheritance graph is the
21
+ # classification, so a later tidying pass that gives both a common ancestor silently turns every retryable
22
+ # failure into a permanent one.
23
+ class TransientFailure < StandardError; end
24
+ end
@@ -0,0 +1,55 @@
1
+ # The cell's image. HotCell installed this file once; it is yours to customize, and nothing inherits
2
+ # from a published base image — this is the whole recipe.
3
+ #
4
+ # A cell carries a Ruby runtime and every gem in its loaded graph, all inside the blast radius, so treat
5
+ # this file as a budget rather than an inventory. Which tools it holds is what decides its blast radius.
6
+
7
+ ARG RUBY_VERSION=3.4
8
+ FROM ruby:${RUBY_VERSION}-slim
9
+
10
+ # Install the tools and libraries your operations run — and nothing else. For example:
11
+ #
12
+ # RUN apt-get update && \
13
+ # apt-get install -y --no-install-recommends libvips42 mupdf-tools ffmpeg && \
14
+ # rm -rf /var/lib/apt/lists/*
15
+
16
+ # The cell's user: no home directory and no shell. Pick a uid your host does not also use for a person —
17
+ # without user namespace remapping this uid is the host's uid, and Debian's own range runs to 60000, so
18
+ # 10001 is not outside it. What isolates the cell is the container, not the number.
19
+ RUN groupadd --gid 10001 hotcell && \
20
+ useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin hotcell
21
+
22
+ # The socket volume's mount point must exist here, owned by the cell's user, because a new named volume
23
+ # takes its ownership from the directory it covers. /hotcell is not: see the chown back to root below.
24
+ RUN mkdir -p /run/hotcell/cell /hotcell/operations && chown -R hotcell:hotcell /run/hotcell /hotcell
25
+
26
+ # HOME is /tmp because the cell's user has no home directory, and bundler wants one. A worker replaces
27
+ # it with its slot's home before it serves anything.
28
+ ENV HOME=/tmp \
29
+ BUNDLE_PATH=/hotcell/bundle \
30
+ BUNDLE_WITHOUT=development \
31
+ HOTCELL_OPERATIONS=/hotcell/operations \
32
+ HOTCELL_DIR=/run/hotcell/cell
33
+
34
+ WORKDIR /hotcell
35
+
36
+ COPY --chown=hotcell:hotcell Gemfile* ./
37
+ USER hotcell
38
+ RUN bundle install
39
+
40
+ COPY config.rb /hotcell/config.rb
41
+ COPY operations/ /hotcell/operations/
42
+
43
+ # The cell reads its own code and must not be able to write it. `HotCell.load!` requires config.rb and
44
+ # every operation file at boot, so a worker that could write here would choose what the next supervisor
45
+ # runs. Bundler needed to write during the build and nothing needs to afterwards, so hand the tree back to
46
+ # root once the build is done. `read-only: true` covers this as well, and this holds without it.
47
+ USER root
48
+ RUN chown -R root:root /hotcell && chmod -R go-w /hotcell
49
+ USER hotcell
50
+
51
+ # Probes the supervisor's control socket from inside the container, where network: none does not apply.
52
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
53
+ CMD ["bundle", "exec", "hotcell-health"]
54
+
55
+ CMD ["bundle", "exec", "hotcell"]
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The cell's Gemfile. Keep it short: every gem loaded here is inside the blast radius and paid for on
4
+ # every request. hotcell-client and rails do not belong here.
5
+
6
+ source "https://rubygems.org"
7
+
8
+ # Pinned to the client that installed it: the two are halves of one wire contract, and a skew between
9
+ # them answers `protocol` on every request.
10
+ gem "hotcell-server", "<%= HotCell::Client::VERSION %>"
11
+
12
+ # Gems your operations need go here. For example:
13
+ #
14
+ # gem "image_processing"
15
+ # gem "ruby-vips"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded when the cell boots, before any operation.
4
+ #
5
+ # deadline and queue_wait are seconds; memory and file_size are bytes.
6
+ HotCell.limits concurrency: 4, queue_size: 8, deadline: 60, memory: 1536 * 1024**2
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+
6
+ require "hot_cell/client/version"
7
+
8
+ module HotCell
9
+ # Writes the hotcell/ directory into an application: a complete Dockerfile to customize, the cell's
10
+ # Gemfile, and an operations/ directory the cell loads at boot. Everything installs inline rather than
11
+ # deriving from a published base image, so the whole cell is the application's to read and change.
12
+ #
13
+ # A file that already exists is left exactly as it is, so running this again after customizing is safe.
14
+ module Install
15
+ TEMPLATES = File.expand_path("install", __dir__)
16
+
17
+ class << self
18
+ def call(root, out: $stdout)
19
+ templates.each do |template|
20
+ relative = template.delete_prefix("#{TEMPLATES}/").delete_suffix(".tt")
21
+ install template, File.join(root, "hotcell", relative), "hotcell/#{relative}", out
22
+ end
23
+ end
24
+
25
+ private
26
+ def templates
27
+ Dir.glob("#{TEMPLATES}/**/*.tt", File::FNM_DOTMATCH).sort
28
+ end
29
+
30
+ def install(template, destination, label, out)
31
+ if File.exist?(destination)
32
+ out.puts " skip #{label} (already exists)"
33
+ else
34
+ FileUtils.mkdir_p File.dirname(destination)
35
+ File.write destination, render(template)
36
+ out.puts " create #{label}"
37
+ end
38
+ end
39
+
40
+ # A .tt is ERB, so a template can name something only the installing gem knows. The Gemfile uses it
41
+ # to pin the cell to this client's version, which is the one number the two sides must agree on.
42
+ def render(template)
43
+ ERB.new(File.read(template), trim_mode: "-").result(binding)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # Loads the hotcell:install task into a Rails application. The gem works without Rails, so this file
5
+ # is required only when Rails::Railtie is already defined.
6
+ class Railtie < ::Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("tasks/hotcell.rake", __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :hotcell do
4
+ desc "Install the hotcell/ directory: a Dockerfile to customize, the cell's Gemfile, and operations/"
5
+ task :install do
6
+ require "hot_cell/install"
7
+
8
+ HotCell::Install.call Rails.root
9
+ end
10
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module HotCell
6
+ # The transport is a seam, and the socket one is the only implementation used outside tests.
7
+ #
8
+ # It never reconnects and never retries. One request per connection invites exactly that on
9
+ # ECONNREFUSED, and a silent retry doubles a cell's load at the moment it is least able to take it.
10
+ # Retry belongs in the job layer, which is the whole reason the transient class exists.
11
+ module Transport
12
+ class Socket
13
+ # **Accepted risk.** `timeout` covers the answer and not the connection. `UNIXSocket.new` blocks, and
14
+ # `connect` to a Unix socket blocks while the listener's backlog is full — a supervisor that is alive
15
+ # and no longer calling `accept`. A caller can be held there with no bound, on a path an application
16
+ # may be calling from a web request.
17
+ #
18
+ # The premise is that the state is nearly unreachable rather than tolerable. A supervisor that dies
19
+ # gives ECONNREFUSED, not a hang, so this needs one that lives and stops accepting — and the loop is
20
+ # built to make that not happen: `Log#emit` writes non-blocking so a stalled container log pipe cannot
21
+ # park it, and a recursive delete is renamed out of the loop rather than performed inside it. Bounding
22
+ # it means `connect_nonblock` plus `wait_writable` against the deadline `receive` already builds, which
23
+ # is worth doing the day this is observed and not before.
24
+ def call(cell, line, descriptors, socket: cell.work_socket, timeout: cell.timeout)
25
+ connection = Connection.new(UNIXSocket.new(socket))
26
+ connection.send_message line, descriptors: descriptors
27
+ receive connection, timeout
28
+ rescue SystemCallError, IOError => error
29
+ # A socket that does not exist, a cell that is restarting, an accessory not yet booted. These are
30
+ # the most likely failures in production and they produce no wire response at all, so they belong
31
+ # in the taxonomy rather than outside it — otherwise the code on the instrumentation event is blank
32
+ # for exactly the outage you most want to see.
33
+ unavailable error
34
+ ensure
35
+ connection&.close
36
+ end
37
+
38
+ private
39
+ # One absolute deadline across the whole response, not a wait for the first byte. Waiting for
40
+ # readability and then calling a blocking read bounded nothing: a peer that sent one byte inside the
41
+ # timeout and then stopped held this caller until the cell's own deadline, and a peer that never
42
+ # closed held it forever — on a path an application may well be calling from a web request.
43
+ def receive(connection, timeout)
44
+ line = connection.read_line(deadline: timeout && Clock.now + timeout)
45
+ return supervisor_gone if line.nil?
46
+
47
+ Response.parse line
48
+ rescue ReadTimeout
49
+ failed "timeout", "the cell did not answer within #{timeout}s"
50
+ rescue MessageError => error
51
+ unavailable "the cell's answer could not be read: #{error.message}"
52
+ end
53
+
54
+ # A live supervisor answers `killed` for a worker that died, which is the whole reason it holds a
55
+ # copy of the connection. So a connection that closes with no response at all means the supervisor
56
+ # itself is gone.
57
+ def supervisor_gone
58
+ unavailable "the connection closed with no response, so the cell's supervisor is gone"
59
+ end
60
+
61
+ # Takes a String or an Exception; Failure.for splits an Exception into its two wire fields.
62
+ def unavailable(detail)
63
+ failed "unavailable", detail
64
+ end
65
+
66
+ def failed(code, detail)
67
+ Response.failed Failure.for(code, detail)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler auto-requires a gem named "hotcell-client" as "hotcell-client", then as "hotcell/client". This
4
+ # gem uses neither path, because hot_cell/ is what yields the HotCell constant under the default inflection.
5
+ # Without this file, `gem "hotcell-client"` in a Gemfile silently loads nothing at all.
6
+ require "hot_cell/client"
metadata CHANGED
@@ -1,24 +1,80 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotcell-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: To be released soon, secure sidecar for Rails
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
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: |
41
+ The application side of HotCell. Register the cells a deployment runs, subclass HotCell::Client to
42
+ name one, and call it with descriptors and a payload.
43
+
44
+ The client owns everything a caller needs to respond correctly to a cell that is saturated,
45
+ restarting, or absent: the classification of every error into permanent and transient, the
46
+ exception classes an application injects for each, and instrumentation through
47
+ ActiveSupport::Notifications.
13
48
  email:
14
49
  - mike@37signals.com
15
50
  executables: []
16
51
  extensions: []
17
52
  extra_rdoc_files: []
18
- files: []
53
+ files:
54
+ - MIT-LICENSE
55
+ - README.md
56
+ - lib/hot_cell/cell.rb
57
+ - lib/hot_cell/cells.rb
58
+ - lib/hot_cell/client.rb
59
+ - lib/hot_cell/client/version.rb
60
+ - lib/hot_cell/failures.rb
61
+ - lib/hot_cell/install.rb
62
+ - lib/hot_cell/install/Dockerfile.tt
63
+ - lib/hot_cell/install/Gemfile.tt
64
+ - lib/hot_cell/install/config.rb.tt
65
+ - lib/hot_cell/railtie.rb
66
+ - lib/hot_cell/tasks/hotcell.rake
67
+ - lib/hot_cell/transport.rb
68
+ - lib/hotcell-client.rb
69
+ homepage: https://github.com/basecamp/hotcell
19
70
  licenses:
20
71
  - MIT
21
- metadata: {}
72
+ metadata:
73
+ homepage_uri: https://github.com/basecamp/hotcell
74
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.1.0/hotcell-client
75
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.1.0/CHANGELOG.md
76
+ bug_tracker_uri: https://github.com/basecamp/hotcell/issues
77
+ rubygems_mfa_required: 'true'
22
78
  rdoc_options: []
23
79
  require_paths:
24
80
  - lib
@@ -26,7 +82,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
26
82
  requirements:
27
83
  - - ">="
28
84
  - !ruby/object:Gem::Version
29
- version: '0'
85
+ version: '3.3'
30
86
  required_rubygems_version: !ruby/object:Gem::Requirement
31
87
  requirements:
32
88
  - - ">="
@@ -35,5 +91,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
35
91
  requirements: []
36
92
  rubygems_version: 4.0.16
37
93
  specification_version: 4
38
- summary: To be released soon, secure sidecar for Rails
94
+ summary: Call a HotCell from an application.
39
95
  test_files: []