Skip to content

Commit 269b630

Browse files
committed
Use isolate's /box directory for the sandbox (and other cleanups)
* remove the "secure commands" mechanism, run all commands in isolate * remove the allow_writing_only mechanism (to be replaced with proper FS quotas) * simplify the cleanup() logic, always delete sandboxes after we are finished using them (sandbox archive works well enough for debugging failures)
1 parent 118c89f commit 269b630

11 files changed

Lines changed: 61 additions & 149 deletions

File tree

cms/conf.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ class DatabaseConfig:
8585

8686
@dataclass()
8787
class WorkerConfig:
88-
keep_sandbox: bool = False
88+
# TODO delete entirely??
89+
pass
8990

9091

9192
@dataclass()

cms/grading/Sandbox.py

Lines changed: 35 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -234,21 +234,22 @@ def __init__(
234234
# to have a number in the [0, num_workers_on_this_machine) range.
235235
box_id = (1 + (shard % 64)) * BOXES_PER_SHARD + box_index
236236

237-
# We create a directory "home" inside the outer temporary directory,
238-
# that will be bind-mounted to "/tmp" inside the sandbox (some
239-
# compilers need "/tmp" to exist, and this is a cheap shortcut to
240-
# create it). The sandbox also runs code as a different user, and so
241-
# we need to ensure that they can read and write to the directory.
242-
# But we don't want everybody on the system to, which is why the
243-
# outer directory exists with no read permissions.
237+
self.box_id = box_id
238+
239+
# Tell isolate to get the sandbox ready. We do our best to cleanup
240+
# after ourselves, but we might have missed something if a previous
241+
# worker was interrupted in the middle of an execution, so we issue an
242+
# idempotent cleanup.
243+
self.cleanup()
244+
245+
isolate_box_dir = self.initialize_isolate()
246+
244247
self._outer_dir = tempfile.mkdtemp(
245-
dir=self.temp_dir, prefix="cms-%s-" % (self.name)
248+
dir=self.temp_dir, prefix=f"cms-{self.name}-"
246249
)
247-
self._home = os.path.join(self._outer_dir, "home")
248-
self._home_dest = "/tmp"
249-
os.mkdir(self._home)
250+
self._home = os.path.join(isolate_box_dir, "box")
251+
self._home_dest = "/box"
250252

251-
self.exec_name = "isolate"
252253
# Used for -M - the meta file ends up in the outer directory. The
253254
# actual filename will be <info_basename>.<execution_number>.
254255
self.info_basename = os.path.join(self._outer_dir, "run.meta")
@@ -259,7 +260,6 @@ def __init__(
259260
self.cmd_file = os.path.join(self._outer_dir, "commands.log")
260261

261262
# Default parameters for isolate
262-
self.box_id = box_id # -b
263263
self.chdir = self._home_dest # -c
264264
self.dirs: list[tuple[str | None, str, str | None]] = [] # -d
265265
self.preserve_env = False # -e
@@ -278,10 +278,6 @@ def __init__(
278278

279279
self.max_processes: int = 1
280280

281-
self.allow_writing_all()
282-
283-
self.add_mapped_directory(self._home, dest=self._home_dest, options="rw")
284-
285281
# Create temporary directory on /dev/shm to prevent communication
286282
# between sandboxes.
287283
self.dirs.append((None, "/dev/shm", "tmp"))
@@ -304,13 +300,6 @@ def __init__(
304300
# particular, the System.Native assembly.
305301
self.maybe_add_mapped_directory("/etc/mono", options="noexec")
306302

307-
# Tell isolate to get the sandbox ready. We do our best to cleanup
308-
# after ourselves, but we might have missed something if a previous
309-
# worker was interrupted in the middle of an execution, so we issue an
310-
# idempotent cleanup.
311-
self.cleanup()
312-
self.initialize_isolate()
313-
314303
def set_multiprocess(self, multiprocess: bool):
315304
"""Set the sandbox to (dis-)allow multiple threads and processes.
316305
@@ -707,32 +696,9 @@ def cleanup(self, delete: bool = False):
707696
delete: if True, also delete get_root_path() and everything it
708697
contains.
709698
"""
710-
# The user isolate assigns within the sandbox might have created
711-
# subdirectories and files therein, making the user outside the sandbox
712-
# unable to delete the whole tree. If the caller asked us to delete the
713-
# sandbox, we first issue a chmod within isolate to make sure that we
714-
# will be able to delete everything. If not, we leave the files as they
715-
# are to avoid masking possible problems the admin wanted to debug.
716699

717700
exe = ["isolate", "--box-id=%d" % self.box_id, "--cg"]
718701

719-
if delete:
720-
# Ignore exit status as some files may be owned by our user
721-
subprocess.call(
722-
exe
723-
+ [
724-
"--dir=%s=%s:rw" % (self._home_dest, self._home),
725-
"--run",
726-
"--",
727-
"/bin/chmod",
728-
"777",
729-
"-R",
730-
self._home_dest,
731-
],
732-
stdout=subprocess.DEVNULL,
733-
stderr=subprocess.STDOUT,
734-
)
735-
736702
# Tell isolate to cleanup the sandbox.
737703
subprocess.check_call(
738704
exe + ["--cleanup"], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT
@@ -755,18 +721,24 @@ def archive(self, file_cacher: FileCacher) -> str | None:
755721

756722
with tempfile.TemporaryFile(dir=self.temp_dir) as sandbox_archive:
757723
# Archive the working directory
758-
content_path = self.get_root_path()
759-
try:
760-
with tarfile.open(fileobj=sandbox_archive, mode="w:gz") as tar_file:
761-
tar_file.add(content_path, os.path.basename(content_path))
762-
except Exception:
763-
logger.warning("Failed to archive sandbox", exc_info=True)
764-
return None
724+
metadata_path = self.get_root_path()
725+
box_dir = self._home
726+
with tarfile.open(fileobj=sandbox_archive, mode='w:gz') as tar_file:
727+
try:
728+
# Add metadata files
729+
for x in os.listdir(metadata_path):
730+
tar_file.add(os.path.join(metadata_path, x), x)
731+
# Add the box directory
732+
tar_file.add(box_dir, "box")
733+
except Exception:
734+
logger.warning(
735+
"Failed to add files to sandbox archive", exc_info=True
736+
)
765737

766738
# Put archive to FS
767739
sandbox_archive.seek(0)
768740
return file_cacher.put_file_from_fobj(
769-
sandbox_archive, "Sandbox %s" % self.get_root_path()
741+
sandbox_archive, f"Sandbox {self.get_root_path()}"
770742
)
771743

772744
def add_mapped_directory(
@@ -800,55 +772,6 @@ def maybe_add_mapped_directory(
800772
src, dest, options, ignore_if_not_existing=True
801773
)
802774

803-
def allow_writing_all(self):
804-
"""Set permissions in such a way that any operation is allowed."""
805-
os.chmod(self._home, 0o777)
806-
for filename in os.listdir(self._home):
807-
os.chmod(os.path.join(self._home, filename), 0o777)
808-
809-
def allow_writing_none(self):
810-
"""Set permissions in such a way that the user cannot write anything."""
811-
os.chmod(self._home, 0o755)
812-
for filename in os.listdir(self._home):
813-
os.chmod(os.path.join(self._home, filename), 0o755)
814-
815-
def allow_writing_only(self, inner_paths: list[str]):
816-
"""Set permissions in so that the user can write only some paths.
817-
818-
By default the user can only write to the home directory. This
819-
method further restricts permissions so that it can only write
820-
to some files inside the home directory.
821-
822-
inner_paths: the only paths that the user is allowed to
823-
write to; they should be "inner" paths (from the perspective
824-
of the sandboxed process, not of the host system); they can
825-
be absolute or relative (in which case they are interpreted
826-
relative to the home directory); paths that point to a file
827-
outside the home directory are ignored.
828-
829-
"""
830-
outer_paths = []
831-
for inner_path in inner_paths:
832-
abs_inner_path = os.path.realpath(os.path.join(self._home_dest, inner_path))
833-
# If an inner path is absolute (e.g., /fifo0/u0_to_m) then
834-
# it may be outside home and we should ignore it.
835-
if os.path.commonpath([abs_inner_path, self._home_dest]) != self._home_dest:
836-
continue
837-
rel_inner_path = os.path.relpath(abs_inner_path, self._home_dest)
838-
outer_path = os.path.join(self._home, rel_inner_path)
839-
outer_paths.append(outer_path)
840-
841-
# If one of the specified file do not exists, we touch it to
842-
# assign the correct permissions.
843-
for path in outer_paths:
844-
if not os.path.exists(path):
845-
open(path, "wb").close()
846-
847-
# Close everything, then open only the specified.
848-
self.allow_writing_none()
849-
for path in outer_paths:
850-
os.chmod(path, 0o722)
851-
852775
def build_box_options(self) -> list[str]:
853776
"""Translate the options defined in the instance to a string
854777
that can be postponed to isolate as an arguments list.
@@ -980,12 +903,10 @@ def _popen(
980903
"Executing program in sandbox with command: `%s'.",
981904
pretty_print_cmdline(args),
982905
)
983-
# Temporarily allow writing new files.
984-
prev_permissions = stat.S_IMODE(os.stat(self._home).st_mode)
985-
os.chmod(self._home, 0o770)
986-
with open(self.cmd_file, "at", encoding="utf-8") as commands:
987-
commands.write("%s\n" % (pretty_print_cmdline(args)))
988-
os.chmod(self._home, prev_permissions)
906+
907+
with open(self.cmd_file, "a") as commands:
908+
commands.write(pretty_print_cmdline(args) + "\n")
909+
989910
try:
990911
p = subprocess.Popen(
991912
args, stdin=stdin, stdout=stdout, stderr=stderr, close_fds=close_fds
@@ -1000,10 +921,12 @@ def _popen(
1000921

1001922
return p
1002923

1003-
def initialize_isolate(self):
924+
def initialize_isolate(self) -> str:
1004925
"""Initialize isolate's box."""
1005926
init_cmd = ["isolate", "--box-id=%d" % self.box_id, "--cg", "--init"]
1006927
try:
1007-
subprocess.check_call(init_cmd)
928+
return subprocess.run(
929+
init_cmd, check=True, capture_output=True, encoding="utf-8"
930+
).stdout.strip()
1008931
except subprocess.CalledProcessError as e:
1009932
raise SandboxInterfaceException("Failed to initialize sandbox") from e

cms/grading/steps/evaluation.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,6 @@ def evaluation_step_before_run(
212212
for name in [sandbox.stderr_file, sandbox.stdout_file]:
213213
if name is not None:
214214
writable_files.append(name)
215-
sandbox.allow_writing_only(writable_files)
216215

217216
sandbox.set_multiprocess(multiprocess)
218217

cms/grading/tasktypes/Communication.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,6 @@ def evaluate(self, job: EvaluationJob, file_cacher: FileCacher):
444444
delete_sandbox(sandbox_mgr, job, file_cacher)
445445
for s in sandbox_user:
446446
delete_sandbox(s, job, file_cacher)
447-
if job.success and not config.worker.keep_sandbox and not job.keep_sandbox:
447+
if job.success and not job.keep_sandbox:
448448
for d in fifo_dir:
449449
rmtree(d)

cms/grading/tasktypes/util.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,16 @@ def delete_sandbox(sandbox: Sandbox, job: Job, file_cacher: FileCacher, success:
8484
success = job.success
8585

8686
# Archive the sandbox if required
87-
if job.archive_sandbox:
87+
if job.archive_sandbox or not success:
8888
sandbox_digest = sandbox.archive(file_cacher)
8989
if sandbox_digest is not None:
9090
job.sandbox_digests[sandbox.get_root_path()] = sandbox_digest
9191

92-
# If the job was not successful, we keep the sandbox around.
93-
if not success:
94-
logger.warning("Sandbox %s kept around because job did not succeed.",
95-
sandbox.get_root_path())
92+
if not success:
93+
logger.warning(f"Job did not succeed! Sandbox digest: {sandbox_digest}")
9694

97-
delete = success and not config.worker.keep_sandbox and not job.keep_sandbox
9895
try:
99-
sandbox.cleanup(delete=delete)
96+
sandbox.cleanup(delete=True)
10097
except OSError:
10198
err_msg = "Couldn't delete sandbox."
10299
logger.warning(err_msg, exc_info=True)

cmstestsuite/unit_tests/grading/steps/fakesandbox.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
import os
2323
from collections import deque
2424
from io import BytesIO, StringIO
25+
import tempfile
2526

2627
from cms.grading.Sandbox import Sandbox
28+
from cms.util import rmtree
2729

2830

2931
class FakeSandbox(Sandbox):
@@ -139,7 +141,10 @@ def execute_without_std(self, command, wait=False):
139141
return data["success"]
140142

141143
def initialize_isolate(self):
142-
pass
144+
tmpd = tempfile.mkdtemp(dir=self.temp_dir, prefix="cms-fake-sandbox-")
145+
os.mkdir(tmpd+"/box")
146+
return tmpd
143147

144148
def cleanup(self, delete=False):
145-
pass
149+
if hasattr(self, '_outer_dir'):
150+
rmtree(self._outer_dir)

cmstestsuite/unit_tests/grading/tasktypes/BatchTest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ def test_alone_sandbox_failure(self):
225225
self.assertResultsInJob(job)
226226
sandbox.get_file_to_storage.assert_not_called()
227227
# We preserve the sandbox to let admins check the problem.
228-
sandbox.cleanup.assert_called_once_with(delete=False)
228+
sandbox.archive.assert_called_once()
229229

230230
def test_grader_success(self):
231231
# We sprinkle in also a header, that should be copied, but not the
@@ -412,7 +412,7 @@ def test_stdio_diff_evaluation_step_sandbox_failure_(self):
412412
self.assertResultsInJob(job)
413413
# eval_output should not have been called, and the sandbox not deleted.
414414
self.eval_output.assert_not_called()
415-
sandbox.cleanup.assert_called_once_with(delete=False)
415+
sandbox.archive.assert_called_once()
416416

417417
def test_stdio_diff_eval_output_failure_(self):
418418
tt, job = self.prepare(["alone", ["", ""], "diff"], {"foo": EXE_FOO})
@@ -424,7 +424,7 @@ def test_stdio_diff_eval_output_failure_(self):
424424
self.assertResultsInJob(job)
425425
# Even if the error is in the eval_output sandbox, we keep also the one
426426
# for evaluation_step to allow debugging.
427-
sandbox.cleanup.assert_called_once_with(delete=False)
427+
sandbox.archive.assert_called_once()
428428

429429
def test_stdio_diff_get_output_success(self):
430430
tt, job = self.prepare(["alone", ["", ""], "diff"], {"foo": EXE_FOO})

cmstestsuite/unit_tests/grading/tasktypes/CommunicationTest.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def test_one_file_sandbox_failure(self):
203203
self.assertResultsInJob(job, False, None, None, None)
204204
sandbox.get_file_to_storage.assert_not_called()
205205
# We preserve the sandbox to let admins check the problem.
206-
sandbox.cleanup.assert_called_once_with(delete=False)
206+
sandbox.archive.assert_called_once()
207207

208208
def test_many_files_success(self):
209209
tt, job = self.prepare(
@@ -469,8 +469,8 @@ def test_single_process_manager_failure(self):
469469
tt.evaluate(job, self.file_cacher)
470470

471471
self.assertResultsInJob(job, False, None, None, None)
472-
sandbox_mgr.cleanup.assert_called_once_with(delete=False)
473-
sandbox_usr.cleanup.assert_called_once_with(delete=False)
472+
sandbox_mgr.archive.assert_called_once()
473+
sandbox_usr.archive.assert_called_once()
474474

475475
def test_single_process_manager_sandbox_failure(self):
476476
# Manager sandbox had problems, it's not the user's fault.
@@ -487,8 +487,8 @@ def test_single_process_manager_sandbox_failure(self):
487487
tt.evaluate(job, self.file_cacher)
488488

489489
self.assertResultsInJob(job, False, None, None, None)
490-
sandbox_mgr.cleanup.assert_called_once_with(delete=False)
491-
sandbox_usr.cleanup.assert_called_once_with(delete=False)
490+
sandbox_mgr.archive.assert_called_once()
491+
sandbox_usr.archive.assert_called_once()
492492

493493
def test_single_process_manager_and_user_failure(self):
494494
# Manager had problems, it's not the user's fault even if also their
@@ -506,8 +506,8 @@ def test_single_process_manager_and_user_failure(self):
506506
tt.evaluate(job, self.file_cacher)
507507

508508
self.assertResultsInJob(job, False, None, None, None)
509-
sandbox_mgr.cleanup.assert_called_once_with(delete=False)
510-
sandbox_usr.cleanup.assert_called_once_with(delete=False)
509+
sandbox_mgr.archive.assert_called_once()
510+
sandbox_usr.archive.assert_called_once()
511511

512512
def test_single_process_user_sandbox_failure(self):
513513
# User sandbox had problems, it's not the user's fault.
@@ -524,8 +524,8 @@ def test_single_process_user_sandbox_failure(self):
524524
tt.evaluate(job, self.file_cacher)
525525

526526
self.assertResultsInJob(job, False, None, None, None)
527-
sandbox_mgr.cleanup.assert_called_once_with(delete=False)
528-
sandbox_usr.cleanup.assert_called_once_with(delete=False)
527+
sandbox_mgr.archive.assert_called_once()
528+
sandbox_usr.archive.assert_called_once()
529529

530530
def test_single_process_user_failure(self):
531531
# User program had problems, it's the user's fault.

cmstestsuite/unit_tests/grading/tasktypes/tasktypetestutils.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,6 @@ def setUpMocks(self, tasktype: str):
9595
"""
9696
self.tasktype = tasktype
9797

98-
# Ensure we don't retain all sandboxes so we can verify delete().
99-
patcher = patch.object(config.worker, "keep_sandbox", False)
100-
self.addCleanup(patcher.stop)
101-
patcher.start()
102-
10398
# Mock the set of languages (if the task type uses it). Child classes
10499
# can update this dict before the test to change the set of languages
105100
# CMS understands.

0 commit comments

Comments
 (0)