Skip to content

Commit d59ff3b

Browse files
blarghmateyCopilot
andcommitted
fix: correct grader path handling in ContainerGrader and entrypoint
ContainerGrader had two bugs affecting how grader files were located inside the container at runtime: 1. Docker backend bind-mounted the grader problem directory at /grader, overwriting the grader_support package that the base image copies there. Fixed by binding at /graders instead and passing the resulting absolute in-container path (/graders/<file>) to the entrypoint. 2. Kubernetes backend set working_dir to the grader problem directory (e.g. /graders/ps07/Robot/), preventing Python from finding the grader_support package which lives at /grader/grader_support/. Fixed by keeping working_dir=/grader (the base image WORKDIR) and passing the absolute grader path in args instead of just the basename. entrypoint.py previously passed the full absolute path verbatim to __import__(), which fails for paths containing slashes. It now detects absolute paths, inserts the parent directory into sys.path, and uses only the basename as the importable module name. Also updates grader_support/README.md to document the correct layout (/graders/ for course grader scripts, /grader/ for grader_support) and the gradelib compatibility note for course teams migrating from Python 2 graders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 39d1d7b commit d59ff3b

3 files changed

Lines changed: 75 additions & 16 deletions

File tree

grader_support/README.md

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ This Dockerfile builds the base image that all course-specific grader images ext
55
### What it contains
66

77
- Python 3.11 (slim)
8-
- The `grader_support` package (test framework and runner used by all graders)
8+
- The `grader_support` package (test framework and runner used by all graders) at `/grader/grader_support/`
9+
- A non-root `grader` user (UID 1000)
910
- An entrypoint that reads student submissions from the `SUBMISSION_CODE` environment variable
1011

1112
### Building
@@ -22,19 +23,45 @@ make docker-build
2223

2324
### Course team usage
2425

25-
Course teams create their own image `FROM grader-base` and add their grader scripts and any Python dependencies their graders require:
26+
Course teams create their own image `FROM grader-base` and add their grader scripts plus any Python dependencies required by the graders.
27+
28+
#### Directory layout inside the container
29+
30+
```
31+
/grader/ ← WORKDIR (base image); grader_support lives here
32+
└── grader_support/ ← test framework (gradelib, run, entrypoint)
33+
34+
/graders/ ← course grader scripts (course team copies these)
35+
└── ps01/
36+
│ └── Problem1/
37+
│ ├── grade_Problem1.py ← grader script (defines `grader = Grader()`)
38+
│ └── answer.py ← reference solution
39+
└── ml/
40+
└── cluster/
41+
└── grade_cluster.py
42+
```
43+
44+
`grader_root` in the handler config should point to `/graders/` (or a subdirectory of it). The `SUBMISSION_CODE` env var carries student code; the entrypoint writes it to `/tmp` (a writable tmpfs even when the root filesystem is read-only).
45+
46+
#### Example course Dockerfile
2647

2748
```dockerfile
28-
FROM registry.example.com/grader-base:latest
49+
# syntax=docker/dockerfile:1
50+
ARG GRADER_BASE_IMAGE=ghcr.io/mitodl/xqueue-watcher-grader-base:latest
51+
FROM ${GRADER_BASE_IMAGE}
52+
53+
# pip must run as root; the base image ends with USER grader.
54+
USER root
55+
RUN pip install --no-cache-dir numpy==1.26.4 scipy==1.13.0
2956

30-
# Install course-specific Python dependencies
31-
RUN pip install --no-cache-dir numpy==1.26.4 scipy==1.12.0
57+
# Copy grader scripts to /graders/. Do NOT copy them to /grader/ — that
58+
# would overwrite the grader_support package from the base image.
59+
COPY --chown=grader:grader graders/ /graders/
3260

33-
# Copy grader scripts into the image
34-
COPY graders/ /graders/
61+
USER grader
3562
```
3663

37-
Then reference the image in the xqueue-watcher handler config (`conf.d/my-course.json`):
64+
#### Example handler config (`conf.d/my-course.json`)
3865

3966
```json
4067
{
@@ -46,7 +73,7 @@ Then reference the image in the xqueue-watcher handler config (`conf.d/my-course
4673
{
4774
"HANDLER": "xqueue_watcher.containergrader.ContainerGrader",
4875
"KWARGS": {
49-
"grader_root": "/graders/my-course/",
76+
"grader_root": "/graders/",
5077
"image": "registry.example.com/my-course-grader:latest",
5178
"backend": "kubernetes",
5279
"cpu_limit": "500m",
@@ -59,6 +86,8 @@ Then reference the image in the xqueue-watcher handler config (`conf.d/my-course
5986
}
6087
```
6188

89+
The `grader` field inside each xqueue submission payload should be a path **relative to `grader_root`**, e.g. `"ps01/Problem1/grade_Problem1.py"`.
90+
6291
### Security properties
6392

6493
Grader containers run with:
@@ -67,3 +96,11 @@ Grader containers run with:
6796
- No network access (`network_disabled: true` / Kubernetes NetworkPolicy)
6897
- CPU and memory limits enforced by the container runtime
6998
- Hard wall-clock timeout via `activeDeadlineSeconds` (Kubernetes) or `timeout` (Docker)
99+
100+
### Important: `gradelib` compatibility
101+
102+
The `grader_support/__init__.py` injects the framework's Python 3 `gradelib` and
103+
`graderutil` modules into `sys.modules` before any grader file is imported. This
104+
means grader scripts that do `from gradelib import *` receive the framework version
105+
automatically, even if a legacy `gradelib.py` exists elsewhere on disk. Course teams
106+
do not need to ship their own copy of `gradelib.py`.

grader_support/entrypoint.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ def main():
4747
sys.path.insert(0, "/tmp")
4848
sys.path.insert(0, cwd)
4949

50+
# grader_name may be an absolute path (e.g. /graders/ps07/Robot/grade_Robot.py)
51+
# when the containergrader passes the full in-container path. In that case, add
52+
# the grader's directory to sys.path and use only the basename as the module name
53+
# so that __import__() can find it.
54+
if os.path.isabs(grader_name):
55+
grader_dir = os.path.dirname(grader_name)
56+
grader_module = os.path.basename(grader_name)
57+
if grader_dir not in sys.path:
58+
sys.path.insert(0, grader_dir)
59+
else:
60+
grader_module = grader_name
61+
5062
# Rename so the module name matches what run.py expects (submission_name
5163
# without the .py extension becomes the importable module name).
5264
import shutil
@@ -56,7 +68,7 @@ def main():
5668

5769
seed_int = int(seed)
5870
output = _run_module.run(
59-
grader_name[:-3] if grader_name.endswith(".py") else grader_name,
71+
grader_module[:-3] if grader_module.endswith(".py") else grader_module,
6072
submission_name[:-3] if submission_name.endswith(".py") else submission_name,
6173
seed_int,
6274
)

xqueue_watcher/containergrader.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,13 @@ def _build_k8s_job(self, job_name, grader_path, code, seed):
142142
"""Return a kubernetes Job manifest dict for the given grading run."""
143143
from kubernetes import client as k8s_client
144144

145-
grader_rel = str(Path(grader_path).basename())
146-
grader_dir = str(Path(grader_path).dirname())
145+
# Pass the absolute in-container path to the grader file. The
146+
# entrypoint handles absolute paths by adding the parent directory to
147+
# sys.path before importing the module, so Python can find the file.
148+
# working_dir must stay at /grader (the WORKDIR of the base image)
149+
# so that `python -m grader_support.entrypoint` can locate the
150+
# grader_support package.
151+
grader_abs = str(grader_path)
147152

148153
return k8s_client.V1Job(
149154
api_version="batch/v1",
@@ -170,8 +175,8 @@ def _build_k8s_job(self, job_name, grader_path, code, seed):
170175
k8s_client.V1Container(
171176
name="grader",
172177
image=self.image,
173-
args=[grader_rel, "submission.py", str(seed)],
174-
working_dir=grader_dir,
178+
args=[grader_abs, "submission.py", str(seed)],
179+
working_dir="/grader",
175180
env=[
176181
k8s_client.V1EnvVar(
177182
name="SUBMISSION_CODE",
@@ -238,15 +243,20 @@ def _run_docker(self, grader_path, code, seed):
238243

239244
grader_dir = str(Path(grader_path).dirname().absolute())
240245
grader_rel = str(Path(grader_path).basename())
246+
# Mount the problem directory at /graders/ (not /grader/ which would
247+
# overwrite the base image's grader_support package). Pass the grader
248+
# as an absolute in-container path so the entrypoint can add its parent
249+
# directory to sys.path before importing.
250+
container_grader_path = f"/graders/{grader_rel}"
241251

242252
client = docker_sdk.from_env()
243253
try:
244254
result = client.containers.run(
245255
image=self.image,
246-
command=[grader_rel, "submission.py", str(seed)],
256+
command=[container_grader_path, "submission.py", str(seed)],
247257
working_dir="/grader",
248258
environment={"SUBMISSION_CODE": code},
249-
volumes={grader_dir: {"bind": "/grader", "mode": "ro"}},
259+
volumes={grader_dir: {"bind": "/graders", "mode": "ro"}},
250260
mem_limit=self.memory_limit,
251261
nano_cpus=int(_parse_cpu_millis(self.cpu_limit) * 1_000_000),
252262
network_disabled=True,

0 commit comments

Comments
 (0)