Skip to content

Commit 48b58cf

Browse files
committed
Add GitHub Actions workflow to build run-mooc-grader images
1 parent 2d52fd9 commit 48b58cf

11 files changed

Lines changed: 477 additions & 0 deletions

File tree

.github/workflows/build.yml

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: Build and push Docker image
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v[0-9]*'
7+
8+
jobs:
9+
build:
10+
name: Build (${{ matrix.platform }})
11+
runs-on: ${{ matrix.runner }}
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
include:
16+
- platform: linux/amd64
17+
runner: ubuntu-24.04
18+
- platform: linux/arm64
19+
runner: ubuntu-24.04-arm
20+
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- name: Log in to Docker Hub
25+
uses: docker/login-action@v3
26+
with:
27+
username: ${{ secrets.DOCKERHUB_USERNAME }}
28+
password: ${{ secrets.DOCKERHUB_TOKEN }}
29+
30+
- name: Set up Docker Buildx
31+
uses: docker/setup-buildx-action@v3
32+
33+
# Build and push platform-specific digest (no manifest tag yet)
34+
- name: Build and push by digest
35+
id: build
36+
uses: docker/build-push-action@v6
37+
with:
38+
context: .
39+
file: docker/Dockerfile
40+
platforms: ${{ matrix.platform }}
41+
push: true
42+
outputs: type=image,name=apluslms/run-mooc-grader,push-by-digest=true,name-canonical=true
43+
44+
# Save the digest so the merge job can find it
45+
- name: Export digest
46+
run: |
47+
mkdir -p /tmp/digests
48+
digest="${{ steps.build.outputs.digest }}"
49+
touch "/tmp/digests/${digest#sha256:}"
50+
51+
- name: Upload digest artifact
52+
uses: actions/upload-artifact@v4
53+
with:
54+
name: digest-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
55+
path: /tmp/digests/*
56+
retention-days: 1
57+
58+
merge:
59+
name: Merge manifests
60+
runs-on: ubuntu-24.04
61+
needs: build
62+
63+
steps:
64+
- name: Download digests
65+
uses: actions/download-artifact@v4
66+
with:
67+
path: /tmp/digests
68+
pattern: digest-*
69+
merge-multiple: true
70+
71+
- name: Log in to Docker Hub
72+
uses: docker/login-action@v3
73+
with:
74+
username: ${{ secrets.DOCKERHUB_USERNAME }}
75+
password: ${{ secrets.DOCKERHUB_TOKEN }}
76+
77+
- name: Set up Docker Buildx
78+
uses: docker/setup-buildx-action@v3
79+
80+
- name: Determine tags
81+
id: meta
82+
uses: docker/metadata-action@v5
83+
with:
84+
images: apluslms/run-mooc-grader
85+
tags: |
86+
type=raw,value=latest
87+
type=match,pattern=v(\d+\.\d+)\.\d+$,group=1
88+
89+
- name: Create and push multi-arch manifest
90+
working-directory: /tmp/digests
91+
run: |
92+
docker buildx imagetools create \
93+
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
94+
$(printf 'apluslms/run-mooc-grader@sha256:%s ' *)

docker/Dockerfile

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
FROM apluslms/service-base:django-1.19
2+
3+
# Set container related configuration via environment variables
4+
ENV CONTAINER_TYPE="grader" \
5+
GRADER_LOCAL_SETTINGS="/srv/grader-cont-settings.py" \
6+
GRADER_SECRET_KEY_FILE="/local/grader/secret_key.py" \
7+
GRADER_AJAX_KEY_FILE="/local/grader/ajax_key.py" \
8+
grader_NO_DATABASE="true"
9+
10+
ARG TARGETPLATFORM
11+
12+
RUN : \
13+
&& apt_install \
14+
apt-transport-https \
15+
jq \
16+
# temp
17+
gnupg curl \
18+
libmagic1 \
19+
\
20+
# install docker-ce
21+
&& if [ "$TARGETPLATFORM" = "linux/amd64" ] ; then ARCH=amd64 ; elif [ "$TARGETPLATFORM" = "linux/arm64" ] ; then ARCH=arm64 ; else exit 1 ; fi \
22+
&& curl -LSs https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg >/dev/null 2>&1 \
23+
&& echo "deb [arch=$ARCH signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian trixie stable" > /etc/apt/sources.list.d/docker.list \
24+
&& apt_install docker-ce \
25+
\
26+
# create user
27+
&& adduser --system --no-create-home --disabled-password --gecos "MOOC-Grader webapp server,,," --home /srv/grader --ingroup nogroup grader \
28+
&& mkdir /srv/grader && chown grader.nogroup /srv/grader \
29+
&& git config --global --add safe.directory /srv/grader \
30+
&& :
31+
32+
COPY docker/rootfs /
33+
COPY . /srv/grader
34+
35+
RUN cd /srv/grader \
36+
# patch and prebuild .pyc files
37+
&& patch -p1 < /srv/cors.patch \
38+
&& python3 -m compileall -q . \
39+
\
40+
# install requirements, remove the file, remove unrequired locales and tests
41+
&& pip_install -r requirements.txt \
42+
&& rm requirements.txt \
43+
&& find /usr/local/lib/python* -type d -regex '.*/locale/[a-z_A-Z]+' -not -regex '.*/\(en\|fi\|sv\)' -print0 | xargs -0 rm -rf \
44+
&& find /usr/local/lib/python* -type d -name 'tests' -print0 | xargs -0 rm -rf \
45+
\
46+
&& export \
47+
GRADER_SECRET_KEY="-" \
48+
GRADER_AJAX_KEY="-" \
49+
&& python3 manage.py compilemessages 2>&1 \
50+
\
51+
# default course link
52+
&& mkdir -p /srv/grader/courses/ \
53+
&& mkdir -p /srv/courses/default \
54+
&& ln -s -T /srv/courses/default /srv/grader/courses/default \
55+
&& chown -R grader.nogroup \
56+
/srv/courses \
57+
/srv/course_store \
58+
/srv/grader \
59+
\
60+
# clean
61+
&& rm -rf $GRADER_SECRET_KEY_FILE $GRADER_AJAX_KEY_FILE \
62+
&& rm -rf /etc/init.d/ /tmp/* \
63+
&& apt_purge \
64+
gnupg curl \
65+
&& :
66+
67+
68+
VOLUME /srv/courses/default
69+
WORKDIR /srv/grader/
70+
EXPOSE 8080
71+
CMD [ "manage", "runserver", "0.0.0.0:8080" ]

docker/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# run-mooc-grader
2+
3+
A Docker container that runs the MOOC-grader for A+ exposed to port 8080.
4+
5+
Note that the MOOC-grader only provides hosting of interactive
6+
course material. The [A-plus front](https://hub.docker.com/r/apluslms/run-aplus-front/)
7+
is required to display the user interface and to store records in the database.
8+
9+
See the [A-plus manual course](https://github.com/apluslms/aplus-manual)
10+
that includes a Docker Compose configuration file to develop and test course content.
11+
12+
## Usage
13+
14+
MOOC-grader is installed in `/srv/grader`.
15+
You can mount a development version of the MOOC-Grader source code to `/src/grader`.
16+
The container will then copy it to `/srv/grader` and compile
17+
the translation file (django.po). If you mount directly to
18+
`/srv/grader`, you need to manually compile the translation file beforehand,
19+
but on the other hand, Django can reload the code and restart the server
20+
without restarting the whole container when you edit the source code files.
21+
22+
Location `/data` is a volume and contains exercise data, database and secret key.
23+
It is world-writable, thus you can run this container as a normal user.
24+
25+
The course (git) directory should be mounted to `/srv/courses/default`.
26+
The course directory name `default` is hardcoded in the scripts
27+
inside the container.
28+
29+
Partial example of `docker-compose.yml` (volumes are optional of course):
30+
31+
```yaml
32+
services:
33+
grader:
34+
image: apluslms/run-mooc-grader
35+
volumes:
36+
# required
37+
- /var/run/docker.sock:/var/run/docker.sock
38+
- /tmp/aplus:/tmp/aplus
39+
# mount a course directory (current dir presumed to be a course repo)
40+
- .:/srv/courses/default:ro
41+
# named persistent volume (until removed)
42+
# - data:/data
43+
# development mounts
44+
# - /home/user/mooc-grader/:/src/grader/:ro
45+
# or...
46+
# - /home/user/mooc-grader/:/srv/grader/
47+
ports:
48+
- "8080:8080"
49+
volumes:
50+
data:
51+
```
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/sh
2+
3+
rm -rf /tmp/aplus/*
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/bin/sh
2+
3+
. /usr/local/lib/cont-init-functions.sh
4+
ENSURE_DIR_MODE=2755
5+
ENSURE_DIR_USER=grader
6+
ENSURE_DIR_GROUP=nogroup
7+
8+
ensure_dir /run/grader
9+
ensure_dir /local/grader
10+
ensure_dir /local/grader/static
11+
ensure_dir /local/grader/media
12+
ensure_dir /local/grader/uploads
13+
ensure_dir /local/grader/ex-meta
14+
15+
# Ensure group permissions
16+
sock=/var/run/docker.sock
17+
gid=$(stat -c '%g' $sock)
18+
gname=$(getent group "$gid")
19+
[ "$gname" ] || groupadd -g "$gid" docker_socket
20+
usermod -G "$gid" grader
21+
22+
# Ensure grader access to /tmp/aplus
23+
chmod 1777 /tmp/aplus

docker/rootfs/etc/services.d/epmd/down

Whitespace-only changes.

docker/rootfs/etc/services.d/postgresql/down

Whitespace-only changes.

docker/rootfs/etc/services.d/rabbitmq/down

Whitespace-only changes.

docker/rootfs/srv/cors.patch

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
diff --git a/grader/settings.py b/grader/settings.py
2+
index 9cfc947..eaab224 100644
3+
--- a/grader/settings.py
4+
+++ b/grader/settings.py
5+
@@ -55,6 +55,7 @@ INSTALLED_APPS = (
6+
# 'django.contrib.contenttypes',
7+
# 'django.contrib.sessions',
8+
# 'django.contrib.messages',
9+
+ 'corsheaders',
10+
'staticfileserver', # override for runserver command, thus this needs to be before django contrib one
11+
'django.contrib.staticfiles',
12+
'access',
13+
@@ -65,6 +66,7 @@ MIDDLEWARE = [
14+
# 'django.middleware.security.SecurityMiddleware',
15+
# 'django.contrib.sessions.middleware.SessionMiddleware',
16+
# 'django.middleware.csrf.CsrfViewMiddleware',
17+
+ 'corsheaders.middleware.CorsMiddleware',
18+
'django.middleware.clickjacking.XFrameOptionsMiddleware',
19+
# 'django.contrib.auth.middleware.AuthenticationMiddleware',
20+
# 'django.middleware.locale.LocaleMiddleware',
21+
@@ -72,6 +74,8 @@ MIDDLEWARE = [
22+
'aplus_auth.auth.django.AuthenticationMiddleware',
23+
]
24+
25+
+CORS_ALLOW_ALL_ORIGINS = True
26+
+
27+
CACHED_LOADERS = [
28+
(
29+
'django.template.loaders.filesystem.Loader',
30+
diff --git a/requirements.txt b/requirements.txt
31+
index 4243873..5886328 100644
32+
--- a/requirements.txt
33+
+++ b/requirements.txt
34+
@@ -6,3 +6,4 @@ docutils ~= 0.17.1
35+
python-magic ~= 0.4.27
36+
aplus-auth ~= 0.2.2
37+
docker ~= 7.1.0
38+
+django-cors-headers ~= 3.13.0
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# This module is adapted from mooc-grader/scripts/docker-run.py.
2+
# This module copies the directories, which should be mounted to
3+
# the grading container, to the path /tmp/aplus. This is required because
4+
# Docker does not support bind mounts from a container into another container.
5+
# The bind mount directory must be located in the host (outside containers).
6+
# The path /tmp/aplus must be mounted from the host into the run-mooc-grader
7+
# container (in docker-compose.yml).
8+
9+
import logging
10+
from typing import Any, Dict, Tuple
11+
import os
12+
import os.path
13+
import shutil
14+
15+
import docker
16+
docker_client = docker.from_env()
17+
18+
from access.config import ConfigError
19+
20+
21+
logger = logging.getLogger("runner.docker")
22+
23+
24+
def get_host_path_and_copy(mounts: Dict[str, str], path: str, submission_id: str):
25+
path = os.path.realpath(path)
26+
for k,v in mounts.items():
27+
if path.startswith(k):
28+
host_path = path.replace(k, v, 1)
29+
# host_path is under /tmp/aplus
30+
# submission_id is inserted into the host_path so that the path
31+
# does not collide with other submissions in case their grading
32+
# is running simultaneously.
33+
host_path = os.path.join(v, submission_id, os.path.relpath(host_path, v))
34+
if os.path.isfile(path):
35+
os.makedirs(os.path.dirname(host_path), mode=0o777, exist_ok=True)
36+
shutil.copy2(path, host_path)
37+
else:
38+
shutil.rmtree(host_path, ignore_errors=True)
39+
shutil.copytree(path, host_path, dirs_exist_ok=True)
40+
return host_path
41+
42+
raise ConfigError(f"Could not find where {path} is mounted")
43+
44+
45+
def run(
46+
submission_id: str,
47+
host_url: str,
48+
readwrite_mounts: Dict[str, str],
49+
readonly_mounts: Dict[str, str],
50+
image: str,
51+
cmd: str,
52+
settings: Dict[str, Any],
53+
**kwargs,
54+
) -> Tuple[int, str, str]:
55+
"""
56+
Grades the submission asynchronously and returns (return_code, out, err).
57+
out and err as in stdout and stderr output of a program.
58+
"""
59+
network = settings.get("network")
60+
if "mounts" not in settings:
61+
return 1, "", 'Missing "mounts" in settings!'
62+
63+
volumes = {
64+
get_host_path_and_copy(settings["mounts"], k, submission_id): {"bind": v, "mode": "rw"}
65+
for k,v in readwrite_mounts.items()
66+
}
67+
volumes.update({
68+
get_host_path_and_copy(settings["mounts"], k, submission_id): {"bind": v, "mode": "ro"}
69+
for k,v in readonly_mounts.items()
70+
})
71+
72+
try:
73+
container = docker_client.containers.run(
74+
image,
75+
cmd,
76+
network = network if network else "bridge",
77+
remove = True,
78+
detach = True,
79+
environment = {
80+
"SID": submission_id,
81+
"REC": host_url,
82+
},
83+
volumes = volumes,
84+
)
85+
86+
return 0, f"{', '.join(container.image.tags)} - {container.name} - {container.short_id}", ""
87+
except Exception as e:
88+
logger.exception("An exception while trying to run grading container")
89+
return 1, "", str(e)

0 commit comments

Comments
 (0)