hotcell-server 0.3.1 → 0.4.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: 9b2d1b6a8296d8833188c1414bfeb70d7031c8601d520361b10110e832461992
4
- data.tar.gz: ec7983987f989d78eeeefee5b3fa8b5c5a860d3ae1022ce414172ea553d64938
3
+ metadata.gz: b0353cf7589212280f099039e6bec97f6da765b1d0c7442fe3183de5ba9e8077
4
+ data.tar.gz: 588b3f6a12b02dc00b096843d61335291d7bf62590337c2f52487df759f4ef05
5
5
  SHA512:
6
- metadata.gz: 112abfea26523d9592dd208455e70a824937c910f98d910990b2359e0530f8f9fd01348a57b4b067a80ee9a77cf64d36aaf7e4bf704c9cf9c88d99eb324e14f1
7
- data.tar.gz: 99fbe2141dee8d99e385b3fe825476366bb56988f7ca508a8ff961d302052b3ab656b07b28cce2513e45012e8a8903aac916f62e31931871fb4a55d4dceb2314
6
+ metadata.gz: a15cb0b8e9d399a3a37659e7008b3c717337ea14beb6a13ad44e4490cb4faedf59aed7520b9abc265b4c762fdbd7d624a32632886470f915925e183739178496
7
+ data.tar.gz: fffde7009ac88742aa9694585203de60cee1da4e637821f851eff453f67d9beaa52305049dfe1882373c396b92c933cf84bde9cf8b0fbd4850295a5ea7b3d020
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module HotCell
6
+ # What the cell does to trees it owns and did not write: a request's home after the request, the slot
7
+ # directory at boot, the scratch at boot.
8
+ module Filesystem
9
+ # **A mode is the only thing a tool needs to make its own tree unremovable, and a mode on a tree this
10
+ # uid owns is ours to put back.** `chmod 0500` on a directory a conversion wrote is enough to fail the
11
+ # recursive delete underneath it, and the delete failing used to be the end of it: one tree per
12
+ # request stayed on the tmpfs until the container ended. The repair is not a permission the process
13
+ # gains, it is one it never lost.
14
+ #
15
+ # Repair only after a failure, never before, so the common request pays for a walk of its own tree
16
+ # once rather than twice. `force:` on the chmod because a partial repair that removes most of the tree
17
+ # is better than none, and the remove that follows is what reports the outcome either way.
18
+ #
19
+ # `Dir.exist?` is not the guard, because it follows symlinks and answers false for a dangling one, and
20
+ # an entry a tool left in a directory's place is exactly what this has to remove.
21
+ def self.remove_tree(path)
22
+ return true unless File.exist?(path) || File.symlink?(path)
23
+
24
+ FileUtils.remove_entry path
25
+ true
26
+ rescue SystemCallError
27
+ repair_and_remove path
28
+ end
29
+
30
+ def self.repair_and_remove(path)
31
+ FileUtils.chmod_R 0o700, path, force: true
32
+ FileUtils.remove_entry path
33
+ true
34
+ rescue SystemCallError
35
+ false
36
+ end
37
+ private_class_method :repair_and_remove
38
+ end
39
+ end
data/lib/hot_cell/log.rb CHANGED
@@ -38,6 +38,7 @@ module HotCell
38
38
  "control.abandoned" => "WARN",
39
39
  "control.unanswerable" => "WARN",
40
40
  "slot.uncleaned" => "WARN",
41
+ "scratch.unswept" => "WARN",
41
42
  "slot.undiscarded" => "WARN",
42
43
  }.freeze
43
44
 
@@ -201,7 +201,7 @@ module HotCell
201
201
  # belongs to the image, which is configured to match the container's CPU quota. Without them
202
202
  # `unsetenv_others: true` would hand an exec'd tool the pool the image's bound was meant to take away.
203
203
  def tool_environment(overrides)
204
- { "HOME" => ENV["HOME"], "PATH" => ENV["PATH"], "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8",
204
+ { "HOME" => ENV["HOME"], "TMPDIR" => ENV["TMPDIR"], "PATH" => ENV["PATH"], "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8",
205
205
  "OMP_NUM_THREADS" => ENV["OMP_NUM_THREADS"], "OMP_THREAD_LIMIT" => ENV["OMP_THREAD_LIMIT"] }
206
206
  .merge(overrides.transform_keys(&:to_s))
207
207
  .compact
@@ -2,6 +2,6 @@
2
2
 
3
3
  module HotCell
4
4
  module Server
5
- VERSION = "0.3.1"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -8,6 +8,7 @@ require "hot_cell/limits"
8
8
  require "hot_cell/configuration"
9
9
  require "hot_cell/registry"
10
10
  require "hot_cell/operation"
11
+ require "hot_cell/filesystem"
11
12
  require "hot_cell/slot"
12
13
  require "hot_cell/log"
13
14
  require "hot_cell/timing"
data/lib/hot_cell/slot.rb CHANGED
@@ -80,7 +80,7 @@ module HotCell
80
80
  # rescues anything and a raise stops the cell with every request it holds.
81
81
  def remove_home
82
82
  return true if home.nil?
83
- return false unless remove_tree(home)
83
+ return false unless Filesystem.remove_tree(home)
84
84
 
85
85
  self.home = nil
86
86
  true
@@ -127,45 +127,18 @@ module HotCell
127
127
  # entry a tool left in the slot's place is exactly what this has to remove, and it used to survive the
128
128
  # sweep and raise from every later `make_home`.
129
129
  def prepare
130
- remove_tree directory
130
+ Filesystem.remove_tree directory
131
131
  end
132
132
 
133
133
  # Unlinks whatever discard_home renamed out of the way. Partial progress is fine: a sweep killed
134
134
  # part-way leaves fewer entries for the next one, so this converges rather than repeating.
135
135
  def sweep
136
- Dir.glob(File.join(directory, "discarded-*")).map { |path| remove_tree(path) }.all?
136
+ Dir.glob(File.join(directory, "discarded-*")).map { |path| Filesystem.remove_tree(path) }.all?
137
137
  rescue SystemCallError
138
138
  # The glob itself can fail, because the slot directory is a name a tool can replace — a symlink loop
139
139
  # in its place answers ELOOP here rather than for any one entry. This runs from the worker's ensure,
140
140
  # where a raise would replace the caller's response with a crash.
141
141
  false
142
142
  end
143
-
144
- private
145
- # **A mode is the only thing a tool needs to make its own tree unremovable, and a mode on a tree this
146
- # uid owns is ours to put back.** `chmod 0500` on a directory a conversion wrote is enough to fail the
147
- # recursive delete underneath it, and the delete failing used to be the end of it: one tree per
148
- # request stayed on the tmpfs until the container ended. The repair is not a permission the process
149
- # gains, it is one it never lost.
150
- #
151
- # Repair only after a failure, never before, so the common request pays for a walk of its own tree
152
- # once rather than twice. `force:` on the chmod because a partial repair that removes most of the tree
153
- # is better than none, and the remove that follows is what reports the outcome either way.
154
- def remove_tree(path)
155
- return true unless File.exist?(path) || File.symlink?(path)
156
-
157
- FileUtils.remove_entry path
158
- true
159
- rescue SystemCallError
160
- repair_and_remove path
161
- end
162
-
163
- def repair_and_remove(path)
164
- FileUtils.chmod_R 0o700, path, force: true
165
- FileUtils.remove_entry path
166
- true
167
- rescue SystemCallError
168
- false
169
- end
170
143
  end
171
144
  end
@@ -4,6 +4,8 @@ require "socket"
4
4
  require "fcntl"
5
5
  require "fileutils"
6
6
  require "tmpdir"
7
+ require "pathname"
8
+ require "etc"
7
9
 
8
10
  module HotCell
9
11
  # Accepts, queues, dispatches, times, kills, reaps, and cleans up. It never evaluates image data.
@@ -139,7 +141,7 @@ module HotCell
139
141
  def initialize(directory:, workspace: nil, configuration: HotCell.configuration, log: Log.new,
140
142
  ptrace_scope_path: PTRACE_SCOPE)
141
143
  @directory = directory
142
- @workspace = workspace || File.join(Dir.tmpdir, "hotcell-workspace")
144
+ @workspace = workspace || File.join(tmpdir, "hotcell-workspace")
143
145
  @configuration = configuration
144
146
  @log = log
145
147
  @ptrace_scope_path = ptrace_scope_path
@@ -154,6 +156,7 @@ module HotCell
154
156
  verify_socket_paths!
155
157
  verify_limits!
156
158
  verify_ptrace_scope!
159
+ verify_scratches!
157
160
  prepare_directories
158
161
  preload
159
162
  @work = listen "work.sock"
@@ -983,7 +986,62 @@ module HotCell
983
986
  end
984
987
  end
985
988
 
989
+ # The scratch is where a killed tool's files outlive it, so the sweep has to be sure of what it is
990
+ # sweeping. A root that is missing, or is not a directory, has been moved or replaced by something; a
991
+ # symlink this uid owns anywhere on the path is a redirect a worker could have planted, and a sweep
992
+ # through it would delete what the link points at; a relative path is whatever the working directory
993
+ # happens to be, and a `..` after a symlink names one directory to the kernel and another to string
994
+ # comparison, so the paths have to be already normalized. Symlinks the OS owns stay legal, since `/tmp`
995
+ # is one on macOS. The socket directory cannot live inside the scratch, because the sweep would have
996
+ # to skip it, and a skipped name is one a worker can fill.
997
+ #
998
+ # What this cannot check is that the root's parent is unwritable, which is what would make the root
999
+ # immovable. On the accessory `/tmp` is a mount; in development it is a directory under one the
1000
+ # developer owns, and refusing that would refuse every development layout.
1001
+ def verify_scratches!
1002
+ { "workspace" => workspace, "temporary directory" => tmpdir, "socket directory" => directory }.each do |name, path|
1003
+ next if File.absolute_path?(path) && File.expand_path(path) == path
1004
+
1005
+ raise ConfigurationError, "the #{name} #{path} is not an absolute path without `.`, `..` or a trailing slash"
1006
+ end
1007
+
1008
+ scratches.each do |scratch|
1009
+ if (link = owned_symlink_on(scratch))
1010
+ raise ConfigurationError, "#{link} is a symlink the cell's uid owns, on the path to the scratch " \
1011
+ "#{scratch}, and a boot sweeps the scratch"
1012
+ end
1013
+ unless File.stat(scratch).directory?
1014
+ raise ConfigurationError, "the scratch #{scratch} is not a directory, and a boot sweeps the scratch"
1015
+ end
1016
+ if directory == scratch || directory.start_with?("#{scratch}/")
1017
+ raise ConfigurationError, "the socket directory #{directory} is inside the scratch #{scratch}, " \
1018
+ "which a boot sweeps. Choose a directory outside it."
1019
+ end
1020
+ end
1021
+ rescue Errno::ENOENT => error
1022
+ raise ConfigurationError, "the scratch is missing: #{error.message}"
1023
+ end
1024
+
1025
+ def owned_symlink_on(path)
1026
+ Pathname.new(path).descend.map(&:to_s).find do |ancestor|
1027
+ stat = File.lstat(ancestor)
1028
+ stat.symlink? && stat.uid == Process.uid
1029
+ end
1030
+ end
1031
+
1032
+ # `Dir.tmpdir` with its fallbacks removed: it answers `.` for a `/tmp` its owner cannot write, and a
1033
+ # worker can leave `/tmp` in that state. The sweep is what puts the mode back, so it has to see the
1034
+ # directory `Dir.tmpdir` will answer once it has.
1035
+ def tmpdir
1036
+ ENV.values_at("TMPDIR", "TMP", "TEMP").compact.reject(&:empty?).first || Etc.systmpdir
1037
+ end
1038
+
1039
+ def scratches
1040
+ [ tmpdir, File.dirname(workspace) ].uniq
1041
+ end
1042
+
986
1043
  def prepare_directories
1044
+ scratches.each { |scratch| sweep_scratch scratch }
987
1045
  FileUtils.mkdir_p directory
988
1046
 
989
1047
  configuration.concurrency.times do |number|
@@ -995,6 +1053,36 @@ module HotCell
995
1053
  end
996
1054
  end
997
1055
 
1056
+ # The scratch outlives the container when it is a host mount, so what a killed tool left at its top is
1057
+ # still there at the next boot, and rebooting the accessory has to be the one command that clears it.
1058
+ # `Dir.tmpdir` is where a tool with no `TMPDIR` writes, and the workspace's parent is the scratch when
1059
+ # `HOTCELL_WORKSPACE` points elsewhere. Only what this uid owns goes, and it goes by the slot's rule:
1060
+ # remove, and put back the modes a tool changed if that fails. `lstat` so a symlink is a link to
1061
+ # unlink and never a tree to walk, and a failure is a line in the log rather than a cell that will
1062
+ # not boot.
1063
+ def sweep_scratch(scratch)
1064
+ repair_root scratch
1065
+ Dir.children(scratch).each do |name|
1066
+ path = File.join(scratch, name)
1067
+ next unless File.lstat(path).uid == Process.uid
1068
+ next if Filesystem.remove_tree(path)
1069
+
1070
+ log.write "scratch.unswept", path: path
1071
+ end
1072
+ rescue SystemCallError => error
1073
+ log.write "scratch.unswept", path: scratch, error: error.class.name, message: error.message
1074
+ end
1075
+
1076
+ # The root's mode is the one a tool can set that the sweep itself trips on: `0300` fails the listing
1077
+ # and `0500` fails every unlink beneath it. It is repaired only when it is wrong, and only when this
1078
+ # uid owns it.
1079
+ def repair_root(scratch)
1080
+ stat = File.stat(scratch)
1081
+ return unless stat.uid == Process.uid && stat.mode & 0o700 != 0o700
1082
+
1083
+ FileUtils.chmod stat.mode | 0o700, scratch
1084
+ end
1085
+
998
1086
  def preload
999
1087
  Registry.operations.each { |operation| operation.before_fork.each(&:call) }
1000
1088
  end
@@ -29,7 +29,7 @@ module HotCell
29
29
  class TestCell
30
30
  READY = "up"
31
31
 
32
- attr_reader :name, :directory, :workspace, :log_path
32
+ attr_reader :name, :directory, :workspace, :tmpdir, :log_path
33
33
 
34
34
  def self.boot(**options)
35
35
  new(**options).start.tap do |cell|
@@ -52,7 +52,10 @@ module HotCell
52
52
  @options = options
53
53
  @root = Dir.mktmpdir "hotcell-test"
54
54
  @directory = File.join(@root, name)
55
- @workspace = File.join(@root, "workspace")
55
+ # Boot empties `Dir.tmpdir` and the workspace's parent of what this uid owns. Inside the cell both are
56
+ # this directory, so the sweep reaches neither the developer's `/tmp` nor the log and sockets beside it.
57
+ @tmpdir = File.join(@root, "tmp")
58
+ @workspace = File.join(@tmpdir, "workspace")
56
59
  @log_path = File.join(@root, "cell.log")
57
60
  end
58
61
 
@@ -75,6 +78,9 @@ module HotCell
75
78
 
76
79
  HotCell.limits(**@options) unless @options.empty?
77
80
 
81
+ FileUtils.mkdir_p @tmpdir
82
+ ENV["TMPDIR"] = @tmpdir
83
+
78
84
  supervisor = Supervisor.new(directory: directory, workspace: workspace,
79
85
  log: Log.new(File.open(log_path, "w")), **@supervisor_options)
80
86
  begin
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fcntl"
5
5
  require "fileutils"
6
+ require "tmpdir"
6
7
 
7
8
  # Fixture operations, so the whole surface can be exercised in milliseconds, with no tool installed and no
8
9
  # container running.
@@ -473,6 +474,21 @@ module HotCell
473
474
  end
474
475
  end
475
476
 
477
+ # Writes a file where a library writes its scratch, `Dir.tmpdir`, and leaves it there — the way ImageMagick
478
+ # leaves its pixel cache behind on anything but a clean exit. With `seconds:` it then blocks, so the
479
+ # deadline kills it mid-request.
480
+ class Spills < HotCell::Operation
481
+ operation "test.spills"
482
+
483
+ def perform(_inputs, _outputs, seconds: 0)
484
+ spilled = File.join(Dir.tmpdir, "spill-#{Process.pid}")
485
+ File.write spilled, "x"
486
+ sleep seconds
487
+
488
+ { spilled: spilled, home: ENV["HOME"] }
489
+ end
490
+ end
491
+
476
492
  class Blocking < HotCell::Operation
477
493
  operation "test.blocking"
478
494
 
@@ -116,6 +116,9 @@ module HotCell
116
116
  # `run`, which exits the worker — after this ensure had already reported idle `"ok"`, counting a
117
117
  # success for a request that never ran.
118
118
  ENV["HOME"] = slot.make_home
119
+ # The home is removed however the request ends, and `/tmp` is swept by nobody. A library's scratch
120
+ # goes where `TMPDIR` says, so it goes here too — whatever the library, and whatever kills the worker.
121
+ ENV["TMPDIR"] = ENV["HOME"]
119
122
 
120
123
  line, received = connection.receive_message
121
124
  response = if line.nil?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotcell-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.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.3.1
18
+ version: 0.4.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.3.1
25
+ version: 0.4.0
26
26
  description: |
27
27
  Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
28
28
  and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
@@ -42,6 +42,7 @@ files:
42
42
  - lib/hot_cell/configuration.rb
43
43
  - lib/hot_cell/control.rb
44
44
  - lib/hot_cell/counters.rb
45
+ - lib/hot_cell/filesystem.rb
45
46
  - lib/hot_cell/limits.rb
46
47
  - lib/hot_cell/log.rb
47
48
  - lib/hot_cell/operation.rb
@@ -61,8 +62,8 @@ licenses:
61
62
  - MIT
62
63
  metadata:
63
64
  homepage_uri: https://github.com/basecamp/hotcell
64
- source_code_uri: https://github.com/basecamp/hotcell/tree/v0.3.1/hotcell-server
65
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.3.1/CHANGELOG.md
65
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.4.0/hotcell-server
66
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.4.0/CHANGELOG.md
66
67
  bug_tracker_uri: https://github.com/basecamp/hotcell/issues
67
68
  rubygems_mfa_required: 'true'
68
69
  rdoc_options: []