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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6808ab8f7c6d12e3917dca07053e1c1ca88b686f43626b895f96a84e2625611
4
- data.tar.gz: ee7b60efeaf1b1baed200abe2bc2756d80b75507259b98171c55e78b652ca1d8
3
+ metadata.gz: c907b155be1d28cc8ca37320ac22f71689f581390e6aa45e77a5a7e31bce04f3
4
+ data.tar.gz: cb676888f838068d7b6671df95fc869a9bd5736d04556e0f2b0c9a8352e7e300
5
5
  SHA512:
6
- metadata.gz: 0135bebaa226693ef7c11f5e34ab1562bf6c58694ffbfb0af7aa3666acdeca05930a814696ed0b7097126deb1b8b3683033e3dead4134cecb12edfce65b20d3f
7
- data.tar.gz: 1998e3e48ded09c5a5975574ce4f961652c8f0e6f635ab875dafc8cc2eb0553301c9dc0271386657f6348cc22d80358e29f0961d046b417a1cfbcae0931b86da
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. 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.
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
- warn_about_timeout response.result
84
- warn_about_missing_operations response.result
85
- warn_about_group_skew response.result
86
- response.result
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 (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."
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
- def warn_about_missing_operations(described)
154
- carried = Array(described[:operations])
155
- wanted = HotCell.clients.select { |client| client.hotcell == name }
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
- 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
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!
@@ -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
@@ -3,6 +3,6 @@
3
3
  module HotCell
4
4
  # A class and not a module: this file loads before hot_cell/client.rb opens the same name.
5
5
  class Client
6
- VERSION = "0.1.0"
6
+ VERSION = "0.3.0"
7
7
  end
8
8
  end
@@ -40,11 +40,6 @@ module HotCell
40
40
  class << self
41
41
  include Declarations
42
42
 
43
- def inherited(subclass)
44
- super
45
- HotCell.clients << subclass
46
- end
47
-
48
43
  def hotcell(name = nil)
49
44
  return inherited_value(:@cell_name) if name.nil?
50
45
 
@@ -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.
@@ -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
- install template, File.join(root, "hotcell", relative), "hotcell/#{relative}", out
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
- def install(template, destination, label, out)
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, render(template)
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.1.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.1.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.1.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
- 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.
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.1.0/hotcell-client
75
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.1.0/CHANGELOG.md
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: []