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 +4 -4
- data/lib/hot_cell/filesystem.rb +39 -0
- data/lib/hot_cell/log.rb +1 -0
- data/lib/hot_cell/operation.rb +1 -1
- data/lib/hot_cell/server/version.rb +1 -1
- data/lib/hot_cell/server.rb +1 -0
- data/lib/hot_cell/slot.rb +3 -30
- data/lib/hot_cell/supervisor.rb +89 -1
- data/lib/hot_cell/test_cell.rb +8 -2
- data/lib/hot_cell/test_operations.rb +16 -0
- data/lib/hot_cell/worker.rb +3 -0
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0353cf7589212280f099039e6bec97f6da765b1d0c7442fe3183de5ba9e8077
|
|
4
|
+
data.tar.gz: 588b3f6a12b02dc00b096843d61335291d7bf62590337c2f52487df759f4ef05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/lib/hot_cell/operation.rb
CHANGED
|
@@ -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
|
data/lib/hot_cell/server.rb
CHANGED
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
|
data/lib/hot_cell/supervisor.rb
CHANGED
|
@@ -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(
|
|
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
|
data/lib/hot_cell/test_cell.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/hot_cell/worker.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
65
|
-
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.
|
|
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: []
|