hotcell-client 0.1.0 → 0.3.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/lib/hot_cell/cell.rb +47 -22
- data/lib/hot_cell/cells.rb +0 -7
- data/lib/hot_cell/client/version.rb +1 -1
- data/lib/hot_cell/client.rb +0 -5
- data/lib/hot_cell/install/Dockerfile.tt +33 -0
- data/lib/hot_cell/install.rb +22 -3
- metadata +8 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c907b155be1d28cc8ca37320ac22f71689f581390e6aa45e77a5a7e31bce04f3
|
|
4
|
+
data.tar.gz: cb676888f838068d7b6671df95fc869a9bd5736d04556e0f2b0c9a8352e7e300
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d436910383ab67af5a826efd3854441c1c90d28bd792657bd6598c27940e7fffa80b1eb50c421eaba737152b4c93a1dd2d2f29243bb9529762ba25bf699bb26b
|
|
7
|
+
data.tar.gz: e3e71a17d0f2e152fb8a504c6966a9fcb1addf63dc3e383cfa021e402d9c322221c02545bfea92fa9f467952dd492e0bc8b3bc945bc787853df38c491b2b3998
|
data/lib/hot_cell/cell.rb
CHANGED
|
@@ -28,6 +28,7 @@ module HotCell
|
|
|
28
28
|
@on_contract_skew = on_contract_skew
|
|
29
29
|
@transport = transport
|
|
30
30
|
|
|
31
|
+
verify_bounds!
|
|
31
32
|
verify_classification!
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -65,25 +66,32 @@ module HotCell
|
|
|
65
66
|
@on_contract_skew&.call error, self
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
# Static, and called once at boot.
|
|
69
|
-
# carry the operation it wants, which is otherwise an `unsupported` on the first real request.
|
|
69
|
+
# Static, and called once at boot.
|
|
70
70
|
#
|
|
71
71
|
# Boot must not fail when a cell does not answer. A cell that is down at app boot is a degraded
|
|
72
72
|
# deployment rather than a broken one, and an application that refuses to start because its thumbnail
|
|
73
73
|
# cell is restarting is worse than one that serves placeholders. So this warns and carries on.
|
|
74
|
+
#
|
|
75
|
+
# Nor when a cell answers something this client cannot read. The two warnings below reach into the
|
|
76
|
+
# description without checking types, so a cell that sends the wrong ones raises here — and the README
|
|
77
|
+
# calls `describe_cells` from `after_initialize`, where that is not a failed check but an application
|
|
78
|
+
# that does not boot. The process that wrote the description is the one that runs untrusted content.
|
|
79
|
+
# So rescue: returning nil is what an unreachable cell already returns, and every caller handles it.
|
|
74
80
|
def describe
|
|
75
81
|
return nil unless enabled?
|
|
76
82
|
|
|
77
83
|
response = control(DESCRIBE)
|
|
78
|
-
unless response.ok?
|
|
79
|
-
HotCell.logger.warn "hotcell #{name}: #{unreachable_because response.failure}"
|
|
80
|
-
return nil
|
|
81
|
-
end
|
|
84
|
+
return warn_unreachable(response.failure) unless response.ok?
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
response.result.tap do |described|
|
|
87
|
+
warn_about_timeout described
|
|
88
|
+
warn_about_group_skew described
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError => error
|
|
91
|
+
HotCell.logger.warn "hotcell #{name}: this cell's description could not be read and is being " \
|
|
92
|
+
"ignored (#{error.class}: #{Failure.one_line error.message}). Boot continues; " \
|
|
93
|
+
"nothing it carries is assumed."
|
|
94
|
+
nil
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
def metrics
|
|
@@ -95,13 +103,18 @@ module HotCell
|
|
|
95
103
|
# that admits a caller. EACCES therefore means one thing, and it is worth saying rather than leaving an
|
|
96
104
|
# operator to read "could not describe the cell" as "the cell is down". Every other failure reads that
|
|
97
105
|
# way correctly, because a restarting accessory is the common one.
|
|
106
|
+
def warn_unreachable(failure)
|
|
107
|
+
HotCell.logger.warn "hotcell #{name}: #{unreachable_because failure}"
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
98
111
|
def unreachable_because(failure)
|
|
99
112
|
if failure.error_class == "Errno::EACCES"
|
|
100
113
|
"this process may not open the cell's socket. Both sides share a group, and this one is in " \
|
|
101
114
|
"#{Process.groups.sort.inspect}. Add the cell's gid to this container (Kamal: `group-add` under " \
|
|
102
115
|
"the role's `options:`)."
|
|
103
116
|
else
|
|
104
|
-
"could not describe the cell (#{failure})"
|
|
117
|
+
"could not describe the cell (#{Failure.one_line failure})"
|
|
105
118
|
end
|
|
106
119
|
end
|
|
107
120
|
|
|
@@ -124,10 +137,9 @@ module HotCell
|
|
|
124
137
|
return if needed.nil? || timeout.nil? || timeout >= needed
|
|
125
138
|
|
|
126
139
|
HotCell.logger.warn "hotcell #{name}: this client waits #{seconds timeout} and the cell says it may " \
|
|
127
|
-
"take #{seconds needed} to answer
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"verdict. Deliberate on a synchronous path; a mistake for a background job."
|
|
140
|
+
"take #{seconds needed} to answer, so a saturated cell will arrive here as a " \
|
|
141
|
+
"transport failure rather than as its own verdict. Deliberate on a synchronous " \
|
|
142
|
+
"path; a mistake for a background job."
|
|
131
143
|
end
|
|
132
144
|
|
|
133
145
|
# The cell reports seconds as floats, and "41.0s" is a worse sentence than "41s".
|
|
@@ -150,14 +162,27 @@ module HotCell
|
|
|
150
162
|
"every operation that gives a tool a filename will fail with EACCES."
|
|
151
163
|
end
|
|
152
164
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
165
|
+
# A `nil` timeout reaches `Transport::Socket#receive` as `deadline: nil`, and reading with no deadline
|
|
166
|
+
# blocks: a cell that accepts the connection and then never answers holds this caller for good. At
|
|
167
|
+
# boot that is an application that never finishes starting, with no exception and nothing to rescue.
|
|
168
|
+
#
|
|
169
|
+
# Refused at registration rather than defaulted, so that a `timeout:` read from an unset environment
|
|
170
|
+
# variable says so instead of quietly becoming 30 seconds. Same reason as `verify_classification!`.
|
|
171
|
+
def verify_bounds!
|
|
172
|
+
@timeout = bounded!(:timeout, timeout)
|
|
173
|
+
@control_timeout = bounded!(:control_timeout, control_timeout)
|
|
174
|
+
end
|
|
156
175
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
# Stored as a Float, because the only thing done with the number is `Clock.now + timeout`. That is
|
|
177
|
+
# also why `finite?` is part of the test: `10**400` is a positive Integer, and adding it to a clock
|
|
178
|
+
# gives `Infinity` — a deadline that never passes, which is the wait this refuses.
|
|
179
|
+
def bounded!(name, value)
|
|
180
|
+
seconds = Float(value, exception: false)
|
|
181
|
+
return seconds if seconds&.finite? && seconds.positive?
|
|
182
|
+
|
|
183
|
+
raise ConfigurationError,
|
|
184
|
+
"#{name}: #{value.inspect[0, 60]} does not bound anything, so a cell that never answers " \
|
|
185
|
+
"would hold this call for as long as it liked. It must be a positive number of seconds."
|
|
161
186
|
end
|
|
162
187
|
|
|
163
188
|
def verify_classification!
|
data/lib/hot_cell/cells.rb
CHANGED
|
@@ -60,13 +60,6 @@ module HotCell
|
|
|
60
60
|
cells.key?(name.to_s)
|
|
61
61
|
end
|
|
62
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
63
|
# Call once at boot, after registering. Warns and carries on; see Cell#describe.
|
|
71
64
|
def describe_cells
|
|
72
65
|
warn_about_group
|
data/lib/hot_cell/client.rb
CHANGED
|
@@ -4,9 +4,21 @@
|
|
|
4
4
|
# A cell carries a Ruby runtime and every gem in its loaded graph, all inside the blast radius, so treat
|
|
5
5
|
# this file as a budget rather than an inventory. Which tools it holds is what decides its blast radius.
|
|
6
6
|
|
|
7
|
+
# The base image. This tag is mutable: `ruby:3.4-slim` moves as Debian and Ruby publish patches, so a
|
|
8
|
+
# clean build is only as reproducible and as patched as the tag on the day it runs. Build with
|
|
9
|
+
# `docker build --pull` to always take the newest patched base. If you also need to name the exact image
|
|
10
|
+
# you shipped, pin a digest (`FROM ruby:${RUBY_VERSION}-slim@sha256:…`) and refresh it on a schedule; the
|
|
11
|
+
# image scan in CI is what catches a base that has drifted into known-vulnerable either way.
|
|
7
12
|
ARG RUBY_VERSION=3.4
|
|
8
13
|
FROM ruby:${RUBY_VERSION}-slim
|
|
9
14
|
|
|
15
|
+
# Apply Debian's pending security patches. `--pull` takes the newest base tag, and the tag can still be
|
|
16
|
+
# behind an advisory that is already in `trixie-security`, because only a rebuild by docker-library/ruby
|
|
17
|
+
# moves it. Upgrading here makes the image's patch level its own rather than upstream's release cadence,
|
|
18
|
+
# and it is what keeps the image scan in CI passing on a fixable vulnerability the base has not picked up
|
|
19
|
+
# yet. It costs a layer and a little build time on every build.
|
|
20
|
+
RUN apt-get update && apt-get upgrade -y --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
|
21
|
+
|
|
10
22
|
# Install the tools and libraries your operations run — and nothing else. For example:
|
|
11
23
|
#
|
|
12
24
|
# RUN apt-get update && \
|
|
@@ -25,14 +37,28 @@ RUN mkdir -p /run/hotcell/cell /hotcell/operations && chown -R hotcell:hotcell /
|
|
|
25
37
|
|
|
26
38
|
# HOME is /tmp because the cell's user has no home directory, and bundler wants one. A worker replaces
|
|
27
39
|
# it with its slot's home before it serves anything.
|
|
40
|
+
#
|
|
41
|
+
# OpenMP sizes its thread pool from the host's core count, and a `cpus:` quota does not lower it, so an
|
|
42
|
+
# unbounded libvips or ImageMagick asks a 98-core host for 98 threads, each with an 8MB stack.
|
|
43
|
+
# `RLIMIT_DATA` charges those stacks, and libgomp calls `exit(1)` on the first `pthread_create` it cannot satisfy. Match
|
|
44
|
+
# OMP_NUM_THREADS to the container's `cpus:`: the number follows the allocation rather than being 2.
|
|
45
|
+
# OMP_THREAD_LIMIT bounds a library that raises the count itself, which is what ImageMagick does.
|
|
28
46
|
ENV HOME=/tmp \
|
|
29
47
|
BUNDLE_PATH=/hotcell/bundle \
|
|
30
48
|
BUNDLE_WITHOUT=development \
|
|
49
|
+
OMP_NUM_THREADS=2 \
|
|
50
|
+
OMP_THREAD_LIMIT=8 \
|
|
31
51
|
HOTCELL_OPERATIONS=/hotcell/operations \
|
|
32
52
|
HOTCELL_DIR=/run/hotcell/cell
|
|
33
53
|
|
|
34
54
|
WORKDIR /hotcell
|
|
35
55
|
|
|
56
|
+
# For a reproducible dependency graph, commit a Gemfile.lock next to the Gemfile so a clean build resolves
|
|
57
|
+
# the exact gems the lockfile names rather than whatever the sources offer that day. Generate it for the
|
|
58
|
+
# platform you deploy on — e.g. from a macOS checkout, `bundle lock --add-platform x86_64-linux` so the
|
|
59
|
+
# Linux image accepts it — and build frozen (`bundle install --frozen`, or set `BUNDLE_FROZEN=true`) to
|
|
60
|
+
# refuse a Gemfile that has drifted from the lockfile. Left off by default so a freshly installed scaffold
|
|
61
|
+
# builds before you have generated a lockfile.
|
|
36
62
|
COPY --chown=hotcell:hotcell Gemfile* ./
|
|
37
63
|
USER hotcell
|
|
38
64
|
RUN bundle install
|
|
@@ -46,6 +72,13 @@ COPY operations/ /hotcell/operations/
|
|
|
46
72
|
# root once the build is done. `read-only: true` covers this as well, and this holds without it.
|
|
47
73
|
USER root
|
|
48
74
|
RUN chown -R root:root /hotcell && chmod -R go-w /hotcell
|
|
75
|
+
|
|
76
|
+
# Strip the setuid and setgid bits off every binary the image carries (mount, umount, su, … from the base,
|
|
77
|
+
# plus anything a RUN above installed). The cell runs as an unprivileged user under `--cap-drop ALL` and
|
|
78
|
+
# `--security-opt no-new-privileges`, which already make these gadgets inert; removing the bits keeps a
|
|
79
|
+
# security image from shipping escalation tools it never uses. As the last root step it covers everything
|
|
80
|
+
# built above it. `-xdev` stays on the image's own filesystem; `chmod a-s` clears both bits.
|
|
81
|
+
RUN find / -xdev -type f -perm /06000 -exec chmod a-s {} +
|
|
49
82
|
USER hotcell
|
|
50
83
|
|
|
51
84
|
# Probes the supervisor's control socket from inside the container, where network: none does not apply.
|
data/lib/hot_cell/install.rb
CHANGED
|
@@ -18,8 +18,12 @@ module HotCell
|
|
|
18
18
|
def call(root, out: $stdout)
|
|
19
19
|
templates.each do |template|
|
|
20
20
|
relative = template.delete_prefix("#{TEMPLATES}/").delete_suffix(".tt")
|
|
21
|
-
|
|
21
|
+
write File.join(root, "hotcell", relative), "hotcell/#{relative}", out do
|
|
22
|
+
render template
|
|
23
|
+
end
|
|
22
24
|
end
|
|
25
|
+
|
|
26
|
+
keep_operations root, out
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
private
|
|
@@ -27,12 +31,27 @@ module HotCell
|
|
|
27
31
|
Dir.glob("#{TEMPLATES}/**/*.tt", File::FNM_DOTMATCH).sort
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
# **The directory the generated Dockerfile copies, made here rather than shipped.**
|
|
35
|
+
#
|
|
36
|
+
# It used to be a template, `install/operations/.keep.tt`, and a dotfile is exactly what the
|
|
37
|
+
# gemspec's `Dir["lib/**/*"]` does not match — so the published 0.1.0 carried the directory's only
|
|
38
|
+
# file nowhere, `COPY operations/` had nothing to copy, and the advertised scaffold could not build.
|
|
39
|
+
# The checkout's own install test could not see it, because in a checkout the file is simply there.
|
|
40
|
+
#
|
|
41
|
+
# An empty file rather than an empty directory, because the application's git is what has to keep
|
|
42
|
+
# this after a fresh clone, and git tracks no empty directories.
|
|
43
|
+
def keep_operations(root, out)
|
|
44
|
+
write File.join(root, "hotcell", "operations", ".keep"), "hotcell/operations/.keep", out do
|
|
45
|
+
""
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def write(destination, label, out)
|
|
31
50
|
if File.exist?(destination)
|
|
32
51
|
out.puts " skip #{label} (already exists)"
|
|
33
52
|
else
|
|
34
53
|
FileUtils.mkdir_p File.dirname(destination)
|
|
35
|
-
File.write destination,
|
|
54
|
+
File.write destination, yield
|
|
36
55
|
out.puts " create #{label}"
|
|
37
56
|
end
|
|
38
57
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hotcell-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Dalessio
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - '='
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.3.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - '='
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.3.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: activesupport
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -38,13 +38,9 @@ dependencies:
|
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '7.1'
|
|
40
40
|
description: |
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
41
|
+
Call operations that run in a HotCell container. Register a cell, subclass HotCell::Client to name
|
|
42
|
+
an operation, and call it with open file descriptors and a payload. Failures arrive classified as
|
|
43
|
+
permanent or transient, as exception classes your application supplies.
|
|
48
44
|
email:
|
|
49
45
|
- mike@37signals.com
|
|
50
46
|
executables: []
|
|
@@ -71,8 +67,8 @@ licenses:
|
|
|
71
67
|
- MIT
|
|
72
68
|
metadata:
|
|
73
69
|
homepage_uri: https://github.com/basecamp/hotcell
|
|
74
|
-
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.
|
|
75
|
-
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.
|
|
70
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.3.0/hotcell-client
|
|
71
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.3.0/CHANGELOG.md
|
|
76
72
|
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
77
73
|
rubygems_mfa_required: 'true'
|
|
78
74
|
rdoc_options: []
|