Skip to content

Commit 31682c7

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 821fed5 commit 31682c7

File tree

11 files changed

+85
-208
lines changed

11 files changed

+85
-208
lines changed

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: 59 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -536,29 +536,6 @@ def cleanup(self, delete: bool = False):
536536
"""
537537
pass
538538

539-
def archive(self) -> str | None:
540-
"""Archive the directory where the sandbox operated.
541-
542-
Stores the archived sandbox in the file cacher and returns its digest.
543-
Returns None if archiving failed.
544-
545-
"""
546-
logger.info("Archiving sandbox in %s.", self.get_root_path())
547-
548-
with tempfile.TemporaryFile(dir=self.temp_dir) as sandbox_archive:
549-
# Archive the working directory
550-
content_path = self.get_root_path()
551-
try:
552-
with tarfile.open(fileobj=sandbox_archive, mode='w:gz') as tar_file:
553-
tar_file.add(content_path, os.path.basename(content_path))
554-
except Exception:
555-
logger.warning("Failed to archive sandbox", exc_info=True)
556-
return None
557-
558-
# Put archive to FS
559-
sandbox_archive.seek(0)
560-
return self.file_cacher.put_file_from_fobj(sandbox_archive, "Sandbox %s" % self.get_root_path())
561-
562539

563540
class StupidSandbox(SandboxBase):
564541
"""A stupid sandbox implementation. It has very few features and
@@ -891,11 +868,6 @@ class IsolateSandbox(SandboxBase):
891868
"""
892869
next_id = 0
893870

894-
# If the command line starts with this command name, we are just
895-
# going to execute it without sandboxing, and with all permissions
896-
# on the current directory.
897-
SECURE_COMMANDS = ["/bin/cp", "/bin/mv", "/usr/bin/zip", "/usr/bin/unzip"]
898-
899871
def __init__(self, file_cacher, name=None, temp_dir=None):
900872
"""Initialization.
901873
@@ -918,22 +890,25 @@ def __init__(self, file_cacher, name=None, temp_dir=None):
918890
box_id = IsolateSandbox.next_id % 10
919891
IsolateSandbox.next_id += 1
920892

921-
# We create a directory "home" inside the outer temporary directory,
922-
# that will be bind-mounted to "/tmp" inside the sandbox (some
923-
# compilers need "/tmp" to exist, and this is a cheap shortcut to
924-
# create it). The sandbox also runs code as a different user, and so
925-
# we need to ensure that they can read and write to the directory.
926-
# But we don't want everybody on the system to, which is why the
927-
# outer directory exists with no read permissions.
928-
self._outer_dir = tempfile.mkdtemp(dir=self.temp_dir,
929-
prefix="cms-%s-" % (self.name))
930-
self._home = os.path.join(self._outer_dir, "home")
931-
self._home_dest = "/tmp"
932-
os.mkdir(self._home)
933-
self.allow_writing_all()
934-
935893
self.exec_name = 'isolate'
936894
self.box_exec = self.detect_box_executable()
895+
self.box_id = box_id
896+
897+
# Tell isolate to get the sandbox ready. We do our best to cleanup
898+
# after ourselves, but we might have missed something if a previous
899+
# worker was interrupted in the middle of an execution, so we issue an
900+
# idempotent cleanup.
901+
self.cleanup()
902+
903+
isolate_box_dir = self.initialize_isolate()
904+
905+
# self._outer_dir = isolate_box_dir
906+
self._outer_dir = tempfile.mkdtemp(
907+
dir=self.temp_dir, prefix=f"cms-{self.name}-"
908+
)
909+
self._home = os.path.join(isolate_box_dir, "box")
910+
self._home_dest = "/box"
911+
937912
# Used for -M - the meta file ends up in the outer directory. The
938913
# actual filename will be <info_basename>.<execution_number>.
939914
self.info_basename = os.path.join(self._outer_dir, "run.log")
@@ -946,7 +921,6 @@ def __init__(self, file_cacher, name=None, temp_dir=None):
946921
self._home, self.box_exec)
947922

948923
# Default parameters for isolate
949-
self.box_id = box_id # -b
950924
self.chdir = self._home_dest # -c
951925
self.dirs = [] # -d
952926
self.preserve_env = False # -e
@@ -963,9 +937,6 @@ def __init__(self, file_cacher, name=None, temp_dir=None):
963937
self.wallclock_timeout: float | None = None # -w
964938
self.extra_timeout: float | None = None # -x
965939

966-
self.add_mapped_directory(
967-
self._home, dest=self._home_dest, options="rw")
968-
969940
# Create temporary directory on /dev/shm to prevent communication
970941
# between sandboxes.
971942
self.dirs.append((None, "/dev/shm", "tmp"))
@@ -984,13 +955,6 @@ def __init__(self, file_cacher, name=None, temp_dir=None):
984955
# particular, the System.Native assembly.
985956
self.maybe_add_mapped_directory("/etc/mono", options="noexec")
986957

987-
# Tell isolate to get the sandbox ready. We do our best to cleanup
988-
# after ourselves, but we might have missed something if a previous
989-
# worker was interrupted in the middle of an execution, so we issue an
990-
# idempotent cleanup.
991-
self.cleanup()
992-
self.initialize_isolate()
993-
994958
def add_mapped_directory(
995959
self,
996960
src: str,
@@ -1021,61 +985,6 @@ def maybe_add_mapped_directory(
1021985
return self.add_mapped_directory(src, dest, options,
1022986
ignore_if_not_existing=True)
1023987

1024-
def allow_writing_all(self):
1025-
"""Set permissions in such a way that any operation is allowed.
1026-
1027-
"""
1028-
os.chmod(self._home, 0o777)
1029-
for filename in os.listdir(self._home):
1030-
os.chmod(os.path.join(self._home, filename), 0o777)
1031-
1032-
def allow_writing_none(self):
1033-
"""Set permissions in such a way that the user cannot write anything.
1034-
1035-
"""
1036-
os.chmod(self._home, 0o755)
1037-
for filename in os.listdir(self._home):
1038-
os.chmod(os.path.join(self._home, filename), 0o755)
1039-
1040-
def allow_writing_only(self, inner_paths: list[str]):
1041-
"""Set permissions in so that the user can write only some paths.
1042-
1043-
By default the user can only write to the home directory. This
1044-
method further restricts permissions so that it can only write
1045-
to some files inside the home directory.
1046-
1047-
inner_paths: the only paths that the user is allowed to
1048-
write to; they should be "inner" paths (from the perspective
1049-
of the sandboxed process, not of the host system); they can
1050-
be absolute or relative (in which case they are interpreted
1051-
relative to the home directory); paths that point to a file
1052-
outside the home directory are ignored.
1053-
1054-
"""
1055-
outer_paths = []
1056-
for inner_path in inner_paths:
1057-
abs_inner_path = \
1058-
os.path.realpath(os.path.join(self._home_dest, inner_path))
1059-
# If an inner path is absolute (e.g., /fifo0/u0_to_m) then
1060-
# it may be outside home and we should ignore it.
1061-
if os.path.commonpath([abs_inner_path,
1062-
self._home_dest]) != self._home_dest:
1063-
continue
1064-
rel_inner_path = os.path.relpath(abs_inner_path, self._home_dest)
1065-
outer_path = os.path.join(self._home, rel_inner_path)
1066-
outer_paths.append(outer_path)
1067-
1068-
# If one of the specified file do not exists, we touch it to
1069-
# assign the correct permissions.
1070-
for path in outer_paths:
1071-
if not os.path.exists(path):
1072-
open(path, "wb").close()
1073-
1074-
# Close everything, then open only the specified.
1075-
self.allow_writing_none()
1076-
for path in outer_paths:
1077-
os.chmod(path, 0o722)
1078-
1079988
def get_root_path(self) -> str:
1080989
"""Return the toplevel path of the sandbox.
1081990
@@ -1103,8 +1012,11 @@ def detect_box_executable(self) -> str:
11031012
return: the path to a valid (hopefully) isolate.
11041013
11051014
"""
1015+
# TODO do we need this logic??
1016+
# i don't think isolate is usable without installing anyways, you need the config file.
11061017
paths = [os.path.join('.', 'isolate', self.exec_name),
11071018
os.path.join('.', self.exec_name)]
1019+
# in any case this location isn't useful, since we don't have isolate as a submodule anymore.
11081020
if '__file__' in globals():
11091021
paths += [os.path.abspath(os.path.join(
11101022
os.path.dirname(__file__),
@@ -1358,44 +1270,13 @@ def _popen(
13581270
self.log = None
13591271
self.exec_num += 1
13601272

1361-
# We run a selection of commands without isolate, as they need
1362-
# to create new files. This is safe because these commands do
1363-
# not depend on the user input.
1364-
if command[0] in IsolateSandbox.SECURE_COMMANDS:
1365-
logger.debug("Executing non-securely: %s at %s",
1366-
pretty_print_cmdline(command), self._home)
1367-
try:
1368-
prev_permissions = stat.S_IMODE(os.stat(self._home).st_mode)
1369-
os.chmod(self._home, 0o700)
1370-
with open(self.cmd_file, 'at', encoding="utf-8") as cmds:
1371-
cmds.write("%s\n" % (pretty_print_cmdline(command)))
1372-
p = subprocess.Popen(command, cwd=self._home,
1373-
stdin=stdin, stdout=stdout, stderr=stderr,
1374-
close_fds=close_fds)
1375-
os.chmod(self._home, prev_permissions)
1376-
# For secure commands, we clear the output so that it
1377-
# is not forwarded to the contestants. Secure commands
1378-
# are "setup" commands, which should not fail or
1379-
# provide information for the contestants.
1380-
open(os.path.join(self._home, self.stdout_file), "wb").close()
1381-
open(os.path.join(self._home, self.stderr_file), "wb").close()
1382-
self._write_empty_run_log(self.exec_num)
1383-
except OSError:
1384-
logger.critical(
1385-
"Failed to execute program in sandbox with command: %s",
1386-
pretty_print_cmdline(command), exc_info=True)
1387-
raise
1388-
return p
1389-
13901273
args = [self.box_exec] + self.build_box_options() + ["--"] + command
13911274
logger.debug("Executing program in sandbox with command: `%s'.",
13921275
pretty_print_cmdline(args))
1393-
# Temporarily allow writing new files.
1394-
prev_permissions = stat.S_IMODE(os.stat(self._home).st_mode)
1395-
os.chmod(self._home, 0o770)
1396-
with open(self.cmd_file, 'at', encoding="utf-8") as commands:
1397-
commands.write("%s\n" % (pretty_print_cmdline(args)))
1398-
os.chmod(self._home, prev_permissions)
1276+
1277+
with open(self.cmd_file, "a") as commands:
1278+
commands.write(pretty_print_cmdline(args) + "\n")
1279+
13991280
try:
14001281
p = subprocess.Popen(args,
14011282
stdin=stdin, stdout=stdout, stderr=stderr,
@@ -1408,15 +1289,6 @@ def _popen(
14081289

14091290
return p
14101291

1411-
def _write_empty_run_log(self, index: int):
1412-
"""Write a fake run.log file with no information."""
1413-
info_file = "%s.%d" % (self.info_basename, index)
1414-
with open(info_file, "wt", encoding="utf-8") as f:
1415-
f.write("time:0.000\n")
1416-
f.write("time-wall:0.000\n")
1417-
f.write("max-rss:0\n")
1418-
f.write("cg-mem:0\n")
1419-
14201292
def execute_without_std(
14211293
self, command: list[str], wait: bool = False
14221294
) -> bool | subprocess.Popen:
@@ -1469,35 +1341,20 @@ def translate_box_exitcode(self, exitcode: int) -> bool:
14691341
raise SandboxInterfaceException("Sandbox exit status (%d) unknown"
14701342
% exitcode)
14711343

1472-
def initialize_isolate(self):
1344+
def initialize_isolate(self) -> str:
14731345
"""Initialize isolate's box."""
14741346
init_cmd = [self.box_exec, "--box-id=%d" % self.box_id, "--cg", "--init"]
14751347
try:
1476-
subprocess.check_call(init_cmd)
1348+
return subprocess.run(init_cmd, check=True,
1349+
capture_output=True, encoding="utf-8").stdout.strip()
14771350
except subprocess.CalledProcessError as e:
14781351
raise SandboxInterfaceException(
14791352
"Failed to initialize sandbox") from e
14801353

1481-
def cleanup(self, delete=False):
1354+
def cleanup(self, delete: bool = False):
14821355
"""See Sandbox.cleanup()."""
1483-
# The user isolate assigns within the sandbox might have created
1484-
# subdirectories and files therein, making the user outside the sandbox
1485-
# unable to delete the whole tree. If the caller asked us to delete the
1486-
# sandbox, we first issue a chmod within isolate to make sure that we
1487-
# will be able to delete everything. If not, we leave the files as they
1488-
# are to avoid masking possible problems the admin wanted to debug.
1489-
14901356
exe = [self.box_exec, "--box-id=%d" % self.box_id, "--cg"]
14911357

1492-
if delete:
1493-
# Ignore exit status as some files may be owned by our user
1494-
subprocess.call(
1495-
exe + [
1496-
"--dir=%s=%s:rw" % (self._home_dest, self._home),
1497-
"--run", "--",
1498-
"/bin/chmod", "777", "-R", self._home_dest],
1499-
stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
1500-
15011358
# Tell isolate to cleanup the sandbox.
15021359
subprocess.check_call(
15031360
exe + ["--cleanup"],
@@ -1508,6 +1365,37 @@ def cleanup(self, delete=False):
15081365
# Delete the working directory.
15091366
rmtree(self._outer_dir)
15101367

1368+
def archive(self) -> str | None:
1369+
"""Archive the directory where the sandbox operated.
1370+
1371+
Stores the archived sandbox in the file cacher and returns its digest.
1372+
Returns None if archiving failed.
1373+
1374+
"""
1375+
logger.info("Archiving sandbox in %s.", self.get_root_path())
1376+
1377+
with tempfile.TemporaryFile(dir=self.temp_dir) as sandbox_archive:
1378+
# Archive the working directory
1379+
metadata_path = self.get_root_path()
1380+
box_dir = self._home
1381+
with tarfile.open(fileobj=sandbox_archive, mode='w:gz') as tar_file:
1382+
try:
1383+
# Add metadata files
1384+
for x in os.listdir(metadata_path):
1385+
tar_file.add(os.path.join(metadata_path, x), x)
1386+
# Add the box directory
1387+
tar_file.add(box_dir, "box")
1388+
except Exception:
1389+
logger.warning(
1390+
"Failed to add files to sandbox archive", exc_info=True
1391+
)
1392+
1393+
# Put archive to FS
1394+
sandbox_archive.seek(0)
1395+
return self.file_cacher.put_file_from_fobj(
1396+
sandbox_archive, f"Sandbox {self.get_root_path()}"
1397+
)
1398+
15111399

15121400
# StupidSandbox hasn't been tested in a while. It should probably be removed,
15131401
# but for now, for typing purposes let's assume we're always using

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
@@ -437,6 +437,6 @@ def evaluate(self, job, file_cacher):
437437
delete_sandbox(sandbox_mgr, job)
438438
for s in sandbox_user:
439439
delete_sandbox(s, job)
440-
if job.success and not config.worker.keep_sandbox and not job.keep_sandbox:
440+
if job.success and not job.keep_sandbox:
441441
for d in fifo_dir:
442442
rmtree(d)

cms/grading/tasktypes/util.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,16 @@ def delete_sandbox(sandbox: Sandbox, job: Job, success: bool | None = None):
8282
success = job.success
8383

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

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

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

0 commit comments

Comments
 (0)