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 +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +3 -0
- data/lib/hot_cell/cell.rb +172 -0
- data/lib/hot_cell/cells.rb +97 -0
- data/lib/hot_cell/client/version.rb +8 -0
- data/lib/hot_cell/client.rb +203 -0
- data/lib/hot_cell/failures.rb +24 -0
- data/lib/hot_cell/install/Dockerfile.tt +55 -0
- data/lib/hot_cell/install/Gemfile.tt +15 -0
- data/lib/hot_cell/install/config.rb.tt +6 -0
- data/lib/hot_cell/install.rb +47 -0
- data/lib/hot_cell/railtie.rb +11 -0
- data/lib/hot_cell/tasks/hotcell.rake +10 -0
- data/lib/hot_cell/transport.rb +71 -0
- data/lib/hotcell-client.rb +6 -0
- metadata +63 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6808ab8f7c6d12e3917dca07053e1c1ca88b686f43626b895f96a84e2625611
|
|
4
|
+
data.tar.gz: ee7b60efeaf1b1baed200abe2bc2756d80b75507259b98171c55e78b652ca1d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,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,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,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,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.
|
|
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
|
-
|
|
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: '
|
|
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:
|
|
94
|
+
summary: Call a HotCell from an application.
|
|
39
95
|
test_files: []
|