terret-sandbox-docker 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 +7 -0
- data/lib/terret/sandbox/docker.rb +408 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7fcb304146e127a21be986fd9982cb3024cb8ac410c86f9462b97fabf1a8a448
|
|
4
|
+
data.tar.gz: 624bba84f98483ae4e5968affc41070de430a71b5ac16733c420bef9dca388d0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 859852ea781a7ad25a0c4091561f2c005f2395f60c7232e42b081bd5808f8bacf089272f88e922ffb92d88c0a97957a21c272e68ba13b7126ef4316f13868543
|
|
7
|
+
data.tar.gz: 3e6be925fcc6f489a5c18930090646ec1c1a2d1469256ac296b12a99261ff816fa16180302a84067f7276cb40d89aed62559c00670252e9a1880dfa98accc6c1
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "terret"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
require_relative "../../../../terret-core/lib/terret" # monorepo path source
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Terret
|
|
10
|
+
module Sandbox
|
|
11
|
+
# There is no container to run anything in: the daemon is not there, the
|
|
12
|
+
# image cannot be had, or `docker run` refused. Raised rather than
|
|
13
|
+
# returned, because every other outcome on this seam describes a command
|
|
14
|
+
# that actually ran somewhere.
|
|
15
|
+
ContainerUnavailable = Class.new(Terret::Tools::Failure)
|
|
16
|
+
|
|
17
|
+
# A cwd outside the granted workspace. The name deliberately echoes
|
|
18
|
+
# ctx[:fs]'s Denied, and for the same reason: the workspace is the only
|
|
19
|
+
# thing that exists inside the container, so a path outside it is refused
|
|
20
|
+
# rather than quietly relocated to somewhere the caller did not ask for.
|
|
21
|
+
Denied = Class.new(Terret::Tools::Failure)
|
|
22
|
+
|
|
23
|
+
# ctx[:sandbox] — the container provider (plan §12). This is the row that
|
|
24
|
+
# makes the M7 claim true: swap `SandboxNone` for this plugin in one patch
|
|
25
|
+
# row and Bash, Read, Write, Grep and the PTY tools all start running
|
|
26
|
+
# inside a container, with no change to any tool. Nothing here knows what
|
|
27
|
+
# a tool is; it only turns an argv into a `docker exec` argv, and the
|
|
28
|
+
# ctx[:subprocess] seam does the rest.
|
|
29
|
+
#
|
|
30
|
+
# ONE WORLD. Each granted workspace directory is bind-mounted at its own
|
|
31
|
+
# absolute path, so `/ws/a.rb` on the host is `/ws/a.rb` in the container.
|
|
32
|
+
# That is what lets the file tools stay on the host (ctx[:fs] writes
|
|
33
|
+
# through the mount) while the process tools run inside, without either
|
|
34
|
+
# side translating paths. The mounted path is the REALPATH, resolved
|
|
35
|
+
# exactly the way ctx[:fs] resolves its own roots — on macOS a tmpdir is
|
|
36
|
+
# handed out under /var and /var is a symlink to /private/var, so a
|
|
37
|
+
# provider that mounted the un-resolved name would disagree with every
|
|
38
|
+
# path fs produces.
|
|
39
|
+
#
|
|
40
|
+
# IT SHELLS OUT TO DOCKER DIRECTLY, with plain Process.spawn/IO.popen,
|
|
41
|
+
# never through ctx[:subprocess]. That is not an oversight: this service
|
|
42
|
+
# sits BENEATH the seam subprocess consults, so routing its own `docker
|
|
43
|
+
# run` through subprocess would send it back through #wrap and try to
|
|
44
|
+
# start the container inside the container it is starting.
|
|
45
|
+
#
|
|
46
|
+
# WHAT IT DOES NOT ISOLATE, stated rather than implied. The workspace is
|
|
47
|
+
# shared read-write with the host by design, so a command in the container
|
|
48
|
+
# can still rewrite anything ctx[:fs] could. The isolation this buys is the
|
|
49
|
+
# rest of the host — the filesystem outside the workspace, the process
|
|
50
|
+
# table, and (at `network: "none"`) the network.
|
|
51
|
+
#
|
|
52
|
+
# CANCELLATION DOES NOT CROSS THE BOUNDARY, and this one is a real loss
|
|
53
|
+
# rather than a footnote. Every kill in the harness — #spawn's timeout
|
|
54
|
+
# escalation, a terminal's close, ctx[:shell]'s sweep — signals a pid on
|
|
55
|
+
# the HOST, and under this provider that pid is the `docker exec` CLI, not
|
|
56
|
+
# the process it started. Killing the CLI detaches; the command inside the
|
|
57
|
+
# container keeps running. So a timed-out command is reported as timed out
|
|
58
|
+
# and truthfully was abandoned, but it goes on burning the container's CPU
|
|
59
|
+
# until something stops the container itself, and ctx[:shell]'s `set +m`
|
|
60
|
+
# guarantee — that one signal to the session's process group ends every
|
|
61
|
+
# child it started — is void here, because that process group lives in
|
|
62
|
+
# another pid namespace. Ending the container (#stop, or #restart!) is the
|
|
63
|
+
# only cancellation that reaches inside it. Closing this properly means
|
|
64
|
+
# `docker exec` growing a way to signal what it started, or the provider
|
|
65
|
+
# tracking in-container pids itself; neither belongs in M7.
|
|
66
|
+
#
|
|
67
|
+
# ENV DOES NOT CROSS. `Subprocess#spawn(env:)` applies to the docker CLI
|
|
68
|
+
# process on the host, not to the process inside the container: variables
|
|
69
|
+
# set that way configure `docker`, and the command never sees them. In-
|
|
70
|
+
# container environment comes from the image and from what the command
|
|
71
|
+
# itself exports (ctx[:shell]'s session keeps `export`s across calls, which
|
|
72
|
+
# is the ordinary way an agent sets one). Propagating selected variables
|
|
73
|
+
# with `docker exec -e` is a plausible M8 knob and is deliberately not
|
|
74
|
+
# built here — a sandbox that silently forwarded the host's environment
|
|
75
|
+
# would be a hole, not a feature.
|
|
76
|
+
class Docker < Hames::Service
|
|
77
|
+
service_key :sandbox
|
|
78
|
+
config_schema image: { type: String, default: "ruby:slim", doc: "container image argv runs in" },
|
|
79
|
+
network: { type: String, default: "none",
|
|
80
|
+
doc: "docker --network mode (none, bridge, host, or a network name)" },
|
|
81
|
+
workspace: { type: [String, Array],
|
|
82
|
+
doc: "host directory root(s) mounted into the container" },
|
|
83
|
+
user: { type: String,
|
|
84
|
+
doc: "container user (default: the host uid:gid); nil runs as root" },
|
|
85
|
+
docker_bin: { type: String, doc: "path to the docker binary (default: resolved from PATH)" },
|
|
86
|
+
memory: { type: String,
|
|
87
|
+
doc: "docker --memory limit (e.g. 256m, 1g); unset means no limit" },
|
|
88
|
+
cpus: { type: [String, Numeric],
|
|
89
|
+
doc: "docker --cpus limit (e.g. 1.5); unset means no limit" },
|
|
90
|
+
pids: { type: Integer,
|
|
91
|
+
doc: "docker --pids-limit, the container's max process count; unset means no limit" }
|
|
92
|
+
|
|
93
|
+
# Debian-based, and chosen for what it carries rather than for Ruby:
|
|
94
|
+
# ctx[:shell] spawns `bash` (the sentinel protocol is a bash protocol),
|
|
95
|
+
# so an image without bash breaks the Bash tool the moment this row is
|
|
96
|
+
# mounted. It also carries getent, which is how the network-denial test
|
|
97
|
+
# asks a question that fails fast instead of timing out.
|
|
98
|
+
DEFAULT_IMAGE = "ruby:slim"
|
|
99
|
+
|
|
100
|
+
# Denied by default. A profile that wants the agent on the network says
|
|
101
|
+
# so in the row; it is not something an isolation provider grants
|
|
102
|
+
# quietly.
|
|
103
|
+
DEFAULT_NETWORK = "none"
|
|
104
|
+
|
|
105
|
+
# Every container started here carries this label, so one that outlives
|
|
106
|
+
# its process — a crashed harness, a killed test run — is identifiable
|
|
107
|
+
# with `docker ps --filter label=terret-sandbox` rather than by guessing
|
|
108
|
+
# which `sleep infinity` was ours.
|
|
109
|
+
LABEL = "terret-sandbox"
|
|
110
|
+
|
|
111
|
+
# nil until the container exists. Public because an operator (and the
|
|
112
|
+
# suite) needs to be able to ask which container an agent is living in
|
|
113
|
+
# without having to run a command to find out.
|
|
114
|
+
attr_reader :container
|
|
115
|
+
|
|
116
|
+
def start(_ctx)
|
|
117
|
+
@workspace = resolve_workspace(config[:workspace])
|
|
118
|
+
@container = nil
|
|
119
|
+
@lock = Mutex.new
|
|
120
|
+
@docker_bin = resolve_docker_bin(config[:docker_bin])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def isolated? = true
|
|
124
|
+
|
|
125
|
+
# The absolute path every docker invocation here runs through. Public so
|
|
126
|
+
# a test (and an operator) can see which binary the row resolved to.
|
|
127
|
+
def docker_bin = @docker_bin
|
|
128
|
+
|
|
129
|
+
# The wrapped argv. `workspace_ready!` is called from HERE, not by
|
|
130
|
+
# ctx[:subprocess], because the seam's contract is only `wrap` — the
|
|
131
|
+
# caller hands over an argv and gets back one that runs somewhere else,
|
|
132
|
+
# and whether that somewhere had to be created first is this service's
|
|
133
|
+
# business. It also means the container is started lazily: mounting the
|
|
134
|
+
# row costs nothing until an agent actually runs something.
|
|
135
|
+
#
|
|
136
|
+
# `-i` is always present. Without it `docker exec` does not attach stdin
|
|
137
|
+
# at all (measured: a wrapped `cat` fed a payload returns nothing, and a
|
|
138
|
+
# wrapped bash gets EOF before it can answer its handshake), which would
|
|
139
|
+
# break both ctx[:shell]'s protocol and every #spawn that writes stdin.
|
|
140
|
+
#
|
|
141
|
+
# `-t` is added only when the caller asks, and the asymmetry is forced by
|
|
142
|
+
# docker rather than chosen: `docker exec -i -t` REFUSES to run when the
|
|
143
|
+
# CLI's own stdin is a pipe ("cannot attach stdin to a TTY-enabled
|
|
144
|
+
# container because stdin is not a terminal", exit 1), so a `-t` on
|
|
145
|
+
# every call would fail every ctx[:subprocess]#spawn. The flag is a
|
|
146
|
+
# per-call fact — this argv is going to a pty — and only the caller
|
|
147
|
+
# knows it.
|
|
148
|
+
#
|
|
149
|
+
# It matters more than a cosmetic terminal. Over a pty WITHOUT `-t`, the
|
|
150
|
+
# container gives bash a pipe: bash goes non-interactive and the
|
|
151
|
+
# handshake's `stty -echo` has no terminal to quiet, while the host pty
|
|
152
|
+
# PTY.spawn created is still echoing every byte written to it. The
|
|
153
|
+
# request line comes back inside the command's output — and that line
|
|
154
|
+
# contains the session sentinel, the one value ctx[:shell]'s forgery
|
|
155
|
+
# resistance rests on. With `-t` the docker CLI puts the host pty in raw
|
|
156
|
+
# mode and gives the container a real terminal, `stty -echo` lands, and
|
|
157
|
+
# stdout is exactly the command's output as the seam promises.
|
|
158
|
+
#
|
|
159
|
+
# The limitation that leaves: a caller who reaches a pty through a path
|
|
160
|
+
# that cannot pass `tty: true` gets a working but echoing session. Since
|
|
161
|
+
# ctx[:subprocess]#pty_spawn is the only way to a pty in the harness,
|
|
162
|
+
# that is the one call site that has to say so.
|
|
163
|
+
def wrap(argv, cwd:, tty: false)
|
|
164
|
+
workspace_ready!
|
|
165
|
+
[docker_bin, "exec", "-i", *(tty ? ["-t"] : []), "-w", workdir(cwd), @container, *argv]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Idempotent, and the lock is what makes that true rather than nearly
|
|
169
|
+
# true. A bare `@container ||= run_container!` reads, PARKS (the `docker
|
|
170
|
+
# run` capture is blocking IO, which under a fiber scheduler yields), and
|
|
171
|
+
# only then assigns — so N agents reaching their first wrap together each
|
|
172
|
+
# see nil, each start a container, and only the last to assign is
|
|
173
|
+
# remembered. The others are orphans in the worst sense: never stopped,
|
|
174
|
+
# `--rm` never fires because `sleep infinity` never exits, and nothing
|
|
175
|
+
# left in the process knows their ids. That is the ordinary case, not a
|
|
176
|
+
# pathological one, because ctx[:subprocess] parks the fiber rather than
|
|
177
|
+
# the thread precisely so one reactor can serve many agents at once.
|
|
178
|
+
#
|
|
179
|
+
# Mutex is fiber-aware under a scheduler, so a waiter parks instead of
|
|
180
|
+
# blocking the reactor, and it is stdlib — this gem still has no runtime
|
|
181
|
+
# dependency. The `docker run` is held INSIDE the lock deliberately: the
|
|
182
|
+
# window being closed is exactly the one that spans it.
|
|
183
|
+
#
|
|
184
|
+
# There is still deliberately NO liveness check here — it would cost a
|
|
185
|
+
# docker round-trip on every single wrap, which is every spawn in the
|
|
186
|
+
# harness. A container that died underneath us surfaces as the exec's own
|
|
187
|
+
# failure; #restart! is how a caller recovers from it.
|
|
188
|
+
def workspace_ready!
|
|
189
|
+
@lock.synchronize { @container ||= run_container! }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Recovery for a container that left without us: an operator's `docker
|
|
193
|
+
# rm`, an OOM kill, a Docker daemon restart. Nothing in this gem can
|
|
194
|
+
# notice that on its own — the provider only builds an argv, and the exec
|
|
195
|
+
# that would have reported "No such container" is run by
|
|
196
|
+
# ctx[:subprocess], which has no path back to the seam to say so. So
|
|
197
|
+
# recovery is explicit: drop the id being held, remove the container if
|
|
198
|
+
# it somehow is still there, and let the next #wrap build a fresh one.
|
|
199
|
+
# Without it a dead container stays dead for the life of the process and
|
|
200
|
+
# only a remount brings the agent back.
|
|
201
|
+
#
|
|
202
|
+
# What a restart costs is the same either way: everything the old
|
|
203
|
+
# container held is gone with it — ctx[:shell]'s sessions, open
|
|
204
|
+
# terminals, anything a command exported. A fresh one starts as fresh as
|
|
205
|
+
# the first one did.
|
|
206
|
+
def restart! = discard!
|
|
207
|
+
|
|
208
|
+
# The loader's unload hook, and the only thing standing between a
|
|
209
|
+
# crashed agent and a `sleep infinity` holding a bind mount forever. The
|
|
210
|
+
# default argument is what lets a caller that is not the loader — a test,
|
|
211
|
+
# an operator — end the container without inventing a context.
|
|
212
|
+
def stop(_ctx = nil) = discard!
|
|
213
|
+
|
|
214
|
+
# A live image, network or workspace swap is a remount, and saying so is
|
|
215
|
+
# more honest than pretending. The container is already running on the
|
|
216
|
+
# old image with the old mounts; applying a new row would mean replacing
|
|
217
|
+
# it, which would drop every ctx[:shell] session and every open terminal
|
|
218
|
+
# living inside it — a good deal more than a config change should do
|
|
219
|
+
# without the mounting profile asking for it.
|
|
220
|
+
def reconfigure(_config)
|
|
221
|
+
warn "terret-sandbox-docker: the container is already running on the mounted image, " \
|
|
222
|
+
"network and bind mounts; remount the row to apply a new one"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Under the same lock as #workspace_ready!, so a disposal that lands
|
|
228
|
+
# while another fiber is mid-`docker run` cannot clear an id that is
|
|
229
|
+
# about to be assigned and leave the new container orphaned — the mirror
|
|
230
|
+
# image of the race the lock is there to close.
|
|
231
|
+
#
|
|
232
|
+
# Tolerant of a container that is already gone, which is the ordinary
|
|
233
|
+
# case for #restart!: `--rm` means the daemon may have removed it first,
|
|
234
|
+
# and a disposal path that raised on an already-clean state would turn
|
|
235
|
+
# tidy-up into a second failure.
|
|
236
|
+
def discard!
|
|
237
|
+
@lock.synchronize do
|
|
238
|
+
id = @container or next
|
|
239
|
+
# Removal is attempted FIRST, and the id is cleared only once it is
|
|
240
|
+
# gone: a genuine `docker rm -f` failure means `sleep infinity` may
|
|
241
|
+
# still be up holding its bind mount, so dropping the id here would
|
|
242
|
+
# strand a container nothing can name. A daemon that already removed
|
|
243
|
+
# the container (the ordinary `--rm` case) answers "No such
|
|
244
|
+
# container", which is success for our purposes, not a failure.
|
|
245
|
+
status, out = remove_container(id)
|
|
246
|
+
if status&.zero? || already_gone?(out)
|
|
247
|
+
@container = nil
|
|
248
|
+
else
|
|
249
|
+
warn "terret-sandbox-docker: docker rm -f #{id} failed (status #{status.inspect}); " \
|
|
250
|
+
"the container may still be running: #{out.strip}"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Split out so #discard! can distinguish a removal that FAILED from one
|
|
257
|
+
# that found the container already gone, and so a test can drive the
|
|
258
|
+
# failure path without a daemon. Both streams are merged the way #capture
|
|
259
|
+
# does, because the message is what makes a failed removal visible.
|
|
260
|
+
def remove_container(id)
|
|
261
|
+
out = IO.popen([docker_bin, "rm", "-f", id], err: [:child, :out], &:read)
|
|
262
|
+
[$?&.exitstatus, out.to_s]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def already_gone?(out) = out.to_s.match?(/no such container/i)
|
|
266
|
+
|
|
267
|
+
# Resolved to an ABSOLUTE path once, at start, so the bare name never
|
|
268
|
+
# reaches an exec that would resolve it against PATH — a workspace
|
|
269
|
+
# directory sitting on PATH could otherwise shadow `docker` with an
|
|
270
|
+
# argv[0] the agent controls. An explicit `docker_bin:` row wins
|
|
271
|
+
# (expanded to absolute); otherwise the first executable `docker` on PATH
|
|
272
|
+
# is taken, and if none is found the bare name is kept so a missing docker
|
|
273
|
+
# fails loudly rather than resolving somewhere unexpected.
|
|
274
|
+
def resolve_docker_bin(configured)
|
|
275
|
+
return File.expand_path(configured.to_s) if configured && !configured.to_s.empty?
|
|
276
|
+
|
|
277
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |dir|
|
|
278
|
+
next if dir.empty?
|
|
279
|
+
|
|
280
|
+
candidate = File.join(dir, "docker")
|
|
281
|
+
candidate if File.file?(candidate) && File.executable?(candidate)
|
|
282
|
+
end.first || "docker"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def image = config[:image] || DEFAULT_IMAGE
|
|
286
|
+
def network = config[:network] || DEFAULT_NETWORK
|
|
287
|
+
|
|
288
|
+
# Defaults to the HOST's uid:gid, and on Linux that default is
|
|
289
|
+
# load-bearing rather than a nicety. The workspace is bind-mounted
|
|
290
|
+
# read-write, so a container running as root writes root-owned files into
|
|
291
|
+
# it — and ctx[:fs], which runs as the host user, then cannot Write or
|
|
292
|
+
# Edit what the container just created. One world stops being one world.
|
|
293
|
+
# macOS hides this (Docker Desktop remaps ownership on the mount), which
|
|
294
|
+
# is exactly what makes it worth defaulting: silently fine on a
|
|
295
|
+
# developer's laptop, silently broken in Linux CI, which is where the
|
|
296
|
+
# acceptance run and the soak actually happen.
|
|
297
|
+
#
|
|
298
|
+
# The cost, measured rather than guessed: the host uid has no /etc/passwd
|
|
299
|
+
# entry inside the image, so `whoami` fails with "cannot find name for
|
|
300
|
+
# user ID 501", $HOME is `/`, and an interactive bash greets as "I have
|
|
301
|
+
# no name!". Harmless under `--norc --noprofile`, and the sentinel
|
|
302
|
+
# protocol is unaffected — the suite proves that rather than assuming it.
|
|
303
|
+
# A profile that needs root inside the container — to apt-get, or to
|
|
304
|
+
# install into the image — sets `user: nil` explicitly and takes the
|
|
305
|
+
# ownership problem back with it.
|
|
306
|
+
def user = config.fetch(:user) { "#{Process.uid}:#{Process.gid}" }
|
|
307
|
+
|
|
308
|
+
# `sleep infinity` rather than the image's own entrypoint: this container
|
|
309
|
+
# is a place to exec into, not a service, so it has to stay up and do
|
|
310
|
+
# nothing. `--rm` so a container whose process ends is not left behind as
|
|
311
|
+
# a stopped row for someone to sweep by hand.
|
|
312
|
+
#
|
|
313
|
+
# This blocks the calling thread, and the cost is worth stating: on the
|
|
314
|
+
# first wrap of a machine that does not have the image yet, that is a
|
|
315
|
+
# pull — minutes, during which the reactor is not running anyone else's
|
|
316
|
+
# agent. Pre-pulling the image is the answer; making this seam async is
|
|
317
|
+
# not, because everything downstream of it already assumes the container
|
|
318
|
+
# exists before an argv is wrapped.
|
|
319
|
+
def run_container!
|
|
320
|
+
if @workspace.empty?
|
|
321
|
+
raise ContainerUnavailable,
|
|
322
|
+
"no workspace directories were granted, so the container would have nothing " \
|
|
323
|
+
"mounted and every wrapped path would be missing inside it"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
argv = [docker_bin, "run", "-d", "--rm", "--label", LABEL, "--network", network,
|
|
327
|
+
*resource_limits, *(user ? ["--user", user] : []), *mounts, image, "sleep", "infinity"]
|
|
328
|
+
status, out = capture(argv)
|
|
329
|
+
raise ContainerUnavailable, "docker run failed (status #{status.inspect}): #{out}" unless status&.zero?
|
|
330
|
+
|
|
331
|
+
container_id(out) or
|
|
332
|
+
raise ContainerUnavailable, "docker run reported success but printed no container id: #{out}"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# A workspace path containing a colon would confuse `-v`'s own
|
|
336
|
+
# source:target:options syntax. Left as a known limitation rather than
|
|
337
|
+
# worked around, because the alternative (`--mount`) trades a colon
|
|
338
|
+
# problem for a comma problem.
|
|
339
|
+
def mounts = @workspace.flat_map { |dir| ["-v", "#{dir}:#{dir}"] }
|
|
340
|
+
|
|
341
|
+
# Optional caps on what a container may consume, each mapping to the docker
|
|
342
|
+
# run flag of the same intent and each absent by default — an unset key
|
|
343
|
+
# adds no flag, so the container runs unconstrained unless a profile asks
|
|
344
|
+
# otherwise. This bounds the blast radius a `sandbox: none` profile has
|
|
345
|
+
# none of (docs/security.md): a wedged or hostile process in the container
|
|
346
|
+
# is held to the memory, CPU and pid budget the row granted it.
|
|
347
|
+
def resource_limits
|
|
348
|
+
flags = []
|
|
349
|
+
flags.push("--memory", config[:memory].to_s) if config[:memory]
|
|
350
|
+
flags.push("--cpus", config[:cpus].to_s) if config[:cpus]
|
|
351
|
+
flags.push("--pids-limit", config[:pids].to_s) if config[:pids]
|
|
352
|
+
flags
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# `docker run -d` prints the id on stdout, but a run that had to pull
|
|
356
|
+
# first prints progress too, and this capture merges the streams. Picking
|
|
357
|
+
# the last full-length id out of the output is what survives that
|
|
358
|
+
# interleaving; matching the shape is what keeps a progress line from
|
|
359
|
+
# being mistaken for an id.
|
|
360
|
+
def container_id(out) = out.lines.map(&:strip).reverse.find { |line| line.match?(/\A\h{64}\z/) }
|
|
361
|
+
|
|
362
|
+
# Where the wrapped command runs. A nil cwd means the caller had no
|
|
363
|
+
# opinion (ctx[:subprocess] defaults it to the host's Dir.pwd, which is
|
|
364
|
+
# an opinion nobody formed) and gets the workspace root.
|
|
365
|
+
#
|
|
366
|
+
# Anything outside the workspace is refused, because it does not exist in
|
|
367
|
+
# the container: `docker exec -w` on an unmounted path fails with the OCI
|
|
368
|
+
# runtime's "chdir to cwd ... no such file or directory" as exit 127,
|
|
369
|
+
# which tells the reader nothing about workspaces. Failing here names the
|
|
370
|
+
# actual problem, and a profile that mounted this row without pointing
|
|
371
|
+
# ctx[:shell] at the workspace learns it from the first command instead
|
|
372
|
+
# of from a stack of 127s.
|
|
373
|
+
def workdir(cwd)
|
|
374
|
+
return @workspace.first if cwd.nil?
|
|
375
|
+
|
|
376
|
+
resolved = File.exist?(cwd) ? File.realpath(cwd) : File.expand_path(cwd)
|
|
377
|
+
unless contained?(resolved)
|
|
378
|
+
raise Denied, "#{cwd} is outside the granted workspace, so it does not exist in the container"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
resolved
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# The same trailing-separator guard ctx[:fs] uses: a workspace granted at
|
|
385
|
+
# `/ws` admits `/ws` and anything under `/ws/`, never `/ws-evil`.
|
|
386
|
+
def contained?(path)
|
|
387
|
+
@workspace.any? { |root| path == root || path.start_with?("#{root}/") }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Resolved the way ctx[:fs] resolves its roots, and for the same reason:
|
|
391
|
+
# the two services are handed the SAME `workspace:` list by the profile,
|
|
392
|
+
# and if they disagreed about what those directories are named, every
|
|
393
|
+
# path fs produced would be a path the container does not have.
|
|
394
|
+
def resolve_workspace(dirs)
|
|
395
|
+
Array(dirs).map { |d| File.realpath(File.expand_path(d)) }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Both streams, one process, no reader threads. Open3 would spawn a
|
|
399
|
+
# thread per stream for what is a short, blocking, once-per-container
|
|
400
|
+
# call, and its output would still have to be merged to be useful in an
|
|
401
|
+
# exception message.
|
|
402
|
+
def capture(argv)
|
|
403
|
+
out = IO.popen(argv, err: [:child, :out], &:read)
|
|
404
|
+
[$?&.exitstatus, out.to_s]
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: terret-sandbox-docker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Obie Fernandez
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: terret-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
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'
|
|
26
|
+
description: 'ctx[:sandbox] backed by a long-lived container: one patch row swaps
|
|
27
|
+
this plugin in and every argv the harness spawns — Bash, Grep, the PTY tools — starts
|
|
28
|
+
running inside it, with no change to any tool. The workspace is bind-mounted at
|
|
29
|
+
its own realpath, so host-side file ops and in-container processes see one world
|
|
30
|
+
at one set of paths; the network is denied by default. Shells out to the docker
|
|
31
|
+
CLI, so there are no runtime dependencies beyond stdlib.'
|
|
32
|
+
email:
|
|
33
|
+
- obiefernandez@gmail.com
|
|
34
|
+
executables: []
|
|
35
|
+
extensions: []
|
|
36
|
+
extra_rdoc_files: []
|
|
37
|
+
files:
|
|
38
|
+
- lib/terret/sandbox/docker.rb
|
|
39
|
+
homepage: https://terret.org
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
homepage_uri: https://terret.org
|
|
44
|
+
source_code_uri: https://github.com/terret-org/terret
|
|
45
|
+
bug_tracker_uri: https://github.com/terret-org/terret/issues
|
|
46
|
+
rubygems_mfa_required: 'true'
|
|
47
|
+
rdoc_options: []
|
|
48
|
+
require_paths:
|
|
49
|
+
- lib
|
|
50
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '4.0'
|
|
55
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
requirements: []
|
|
61
|
+
rubygems_version: 4.0.16
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: The container sandbox provider for Terret
|
|
64
|
+
test_files: []
|