Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 70 additions & 21 deletions tools/docker/Dockerfile
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Docker image is:

  • Forced to run based on a tmr release (it downloads the source of a git tag)
  • Builds tmr

I think it would be better to either:

  1. Force the build based on a tmr release, but grab the .deb file that is already built by the CI.
  • The CI needs minor tweaks to publish the debs, but they have already been built
  1. Allow the build from any commit, or even uncommitted changes.
  • Do not download the source, but take it from the context (i.e., slightly change the Dockerfile and the build command)
Option 1 Option 2
Speed of the build Fast Slow
Customizing what packages are installed Yes Yes
Making code changes Need a release Don't even need a commit
Dockerfile complexity Simple More complex
Maintainability issues Relies on CI building the release into .deb files Relies on tmr compatibility with Rust version¹

¹ If rustup downloads a newer version of Rust, the build may fail.

Given the use case of this Docker image (a quick and easy way of deploying task-maker), I'm biased to solution 1.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not both? I have created another Dockerfile where I COPY my local repository and build task-maker-rust. I think it would be nice to provide a image for uses that want to use task-maker-rust as soon as possible and another for the people interested in developing and contributing.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edomora97 wrote:

Option 1 Option 2
Speed of the build Fast Slow

Regarding the speed of build, this is a bit faster than the previous version (building from source), but it's still not super fast as on my machine it takes ~ 10 minutes (against 16.5 minutes).

Original file line number Diff line number Diff line change
@@ -1,47 +1,96 @@
FROM ubuntu:18.04
LABEL maintainer="Edoardo Morassutto <[email protected]>"
FROM ubuntu:24.04

ARG UID=1000
ARG GID=1000
# Example invocation of docker build
# $ docker build \
# --build-arg TM_VERSION=X.Y.Z \
# --build-arg VCS_REF="$(git rev-parse HEAD)" \
# --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
# -t <yourrepo>/task-maker-rust:X.Y.Z
# .

ARG TM_VERSION # task-maker-rust version (required)
ARG VCS_REF # git commit SHA for v${TM_VERSION}
ARG BUILD_DATE # e.g. 2025-09-04T12:34:56Z (RFC 3339 UTC)
ARG IMAGE_URL
ARG DOCS_URL='https://github.com/olimpiadi-informatica/task-maker-rust#readme'
ARG VENDOR='Olimpiadi Italiane di Informatica'
ARG BASE_NAME='ubuntu:24.04'
ARG BASE_DIGEST='sha256:9cbed754112939e914291337b5e554b07ad7c392491dba6daf25eef1332a22e8'

LABEL \
org.opencontainers.image.title="task-maker-rust" \
org.opencontainers.image.description="task-maker-rust server and worker to build programming tasks for CMS." \
org.opencontainers.image.version="${TM_VERSION}" \
org.opencontainers.image.revision="${VCS_REF}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.url="${IMAGE_URL}" \
org.opencontainers.image.documentation="${DOCS_URL}" \
org.opencontainers.image.source="https://github.com/olimpiadi-informatica/task-maker-rust" \
org.opencontainers.image.authors="task-maker-rust contributors" \
org.opencontainers.image.vendor="${VENDOR}" \
org.opencontainers.image.licenses="MPL-2.0" \
org.opencontainers.image.base.name="${BASE_NAME}" \
org.opencontainers.image.base.digest="${BASE_DIGEST}"

ARG TM_UID=1000
ARG TM_GID=1000

ENV RUST_LOG='info'
ENV RUST_BACKTRACE=1

# run the following as root
USER root

# install dependencies
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -yy \
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -yy \
asymptote \
build-essential \
fpc \
latexmk \
libseccomp-dev \
python \
python-sortedcontainers \
libssl-dev \
python3 \
python3-sortedcontainers \
texlive \
texlive-latex-extra \
wget \
curl \
&& rm -rf /var/lib/apt/lists/*

# task-maker-rust version (required)
ARG TM_VERSION
# delete default ubuntu user, this removes also the group
RUN userdel --remove ubuntu

# install task-maker-rust
RUN (test -n "$TM_VERSION" || (echo "Please use --build-arg TM_VERSION=0.3.X" >&2 && exit 1)) \
&& wget https://github.com/olimpiadi-informatica/task-maker-rust/releases/download/v${TM_VERSION}/task-maker-rust_${TM_VERSION}_amd64.deb \
&& dpkg -i task-maker-rust_${TM_VERSION}_amd64.deb \
&& rm task-maker-rust_${TM_VERSION}_amd64.deb
# create a group and a user called taskmaker, create the home directory
RUN groupadd ${TM_GID:+-g "${TM_GID}"} task-maker
RUN useradd -m -g task-maker ${TM_UID:+-u "${TM_UID}"} task-maker

# drop root privileges
RUN groupadd -g $GID user \
&& useradd -m -g $GID -u $UID user
USER user
# use /home/task-maker as work directory
WORKDIR /home/task-maker

# install task-maker-rust using .deb from releases
ARG TM_DEB_VERSION="${TM_VERSION}-1.ubuntu-24.04"
ARG TM_DEB_NAME="task-maker-rust_${TM_DEB_VERSION}_amd64.deb"

RUN (test -n "$TM_VERSION" || (echo "Please use --build-arg TM_VERSION=X.Y.Z" >&2 && exit 1)) \
&& wget "https://github.com/olimpiadi-informatica/task-maker-rust/releases/download/v${TM_VERSION}/${TM_DEB_NAME}" \
&& dpkg -i "${TM_DEB_NAME}" \
&& rm -rf "${TM_DEB_NAME}"

# run everything as a unprivileged user
# (docker still needs --privileged to run because rask-maker-rust needs privileges to create a sandbox)
USER task-maker

# server-client port
EXPOSE 27182
# server-worker port
EXPOSE 27183

# start task-maker-rust server and worker
ADD entrypoint.sh healthcheck.sh /
CMD /entrypoint.sh
ADD entrypoint.py healthcheck.sh /home/task-maker

ENTRYPOINT ["/home/task-maker/entrypoint.py"]

# check the status of the server and the workers
HEALTHCHECK --interval=5s CMD /healthcheck.sh
HEALTHCHECK --interval=5s \
CMD /home/task-maker/healthcheck.sh || exit 1
251 changes: 251 additions & 0 deletions tools/docker/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import sys
import shlex
import shutil
import signal
import atexit
import time
import threading
import subprocess
import logging
from glob import glob
from pathlib import Path
from multiprocessing import cpu_count
import tempfile
import argparse
from typing import List, Optional, Any
from types import FrameType

# ---------- defaults from env ----------
SERVER_ARGS: str = os.environ.get("SERVER_ARGS", "")
WORKER_ARGS: str = os.environ.get("WORKER_ARGS", "")
SERVER_ADDR: str = os.environ.get("SERVER_ADDR", "127.0.0.1:27183")
SPAWN_SERVER: bool = os.environ.get("SPAWN_SERVER", "true").lower() == "true"
SPAWN_WORKERS: bool = os.environ.get("SPAWN_WORKERS", "true").lower() == "true"
TM_LOGLEVEL: str = os.environ.get("TM_LOGLEVEL", "info").lower()

# default: nproc - 1 (at least 1)
default_nworkers: int = max(cpu_count() - 1, 1)

# ---------- logging ----------
LOGGER_NAME = "tm_entrypoint"
logger = logging.getLogger(LOGGER_NAME)

def _map_tm_loglevel(level: str) -> int:
table = {
"error": logging.ERROR,
"warn": logging.WARNING,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
}
return table.get(level, logging.ERROR)

def configure_logging() -> None:
logging.basicConfig(
level=_map_tm_loglevel(TM_LOGLEVEL),
format="[%(asctime)s %(levelname)s\t%(name)s::%(funcName)s] %(message)s",
datefmt= "%Y-%m-%dT%H:%M:%S.xxxxxxxxxZ",
stream=sys.stderr,
)
eff = logging.getLevelName(logger.getEffectiveLevel())
logger.debug("Logger initialized at DEBUG (effective: %s).", eff)
logger.info("Logging configured (level=%s).", eff)

# ---------- CLI ----------
def cli() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Entrypoint for task-maker-rust Docker image."
)
parser.add_argument(
"-j", "--jobs",
type=int,
default=default_nworkers,
help=f"Number of workers to launch [default: <nproc>-1 = {default_nworkers}]"
)

args = parser.parse_args()
assert args.jobs > 0, f"Please specify a positive number of jobs, not {args.jobs}."

return args

# ---------- cleanup ----------
if SPAWN_SERVER or SPAWN_WORKERS:
def cleanup() -> None:
logger.debug("Cleaning up temporary stores")
for p in glob("/tmp/tmserver.*"):
logger.debug("Removing server store: %s", p)
shutil.rmtree(p, ignore_errors=True)
for p in glob("/tmp/tmworker.*"):
logger.debug("Removing worker store: %s", p)
shutil.rmtree(p, ignore_errors=True)
logger.debug("Cleanup complete.")

atexit.register(cleanup)

def _signal_handler(signum: int, frame: Optional[FrameType]) -> None:
name = signal.Signals(signum).name if hasattr(signal, "Signals") else str(signum)
logger.warning("Received signal %s — shutting down.", name)
# atexit will run cleanup
code = 130 if signum == signal.SIGINT else 143
sys.exit(code)

signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)

# ---------- verbosity mapping for task-maker-tools ----------
def loglevel_verbosity_flag(level: str) -> str:
flag = {
"error": "",
"warn": "-v",
"warning": "-v",
"info": "-vv",
"debug": "-vvv",
}.get(level, "")
logger.debug(f"Mapped TM_LOGLEVEL={level} to "
f"task-maker-tools flag '{flag or "<none>"}'")
return flag

# ---------- spawn helpers ----------
def make_worker_stores(nworkers: int) -> List[str]:
base = tempfile.mktemp(prefix="tmworker.", dir="/tmp")
stores: List[str] = []
for i in range(1, nworkers + 1):
idx = f"{i:02d}"
d = f"{base}-{idx}"
Path(d).mkdir(parents=True, exist_ok=True)
stores.append(d)
logger.debug("Created %d worker stores (base=%s): %s", nworkers, base, stores)
return stores

def spawn_tmserver() -> int:
verbosity_flag: str = loglevel_verbosity_flag(TM_LOGLEVEL)
def make_server_store() -> Path:
path = tempfile.mkdtemp(prefix="tmserver.", dir="/tmp")
logger.debug("Created server store: %s", path)
return path

server_store: str = make_server_store()
cmd: List[str] = ["task-maker-tools"]
if verbosity_flag:
cmd.append(verbosity_flag)
cmd += ["server", "--store-dir", server_store]
if SERVER_ARGS.strip():
cmd += shlex.split(SERVER_ARGS)

logger.debug("Exec: %s", " ".join(shlex.quote(c) for c in cmd))
logger.info("Starting server (store=%s)…", server_store)
rc = subprocess.call(cmd)
logger.info("Server exited with rc=%d", rc)
return rc

def spawn_tmworker(store_dir: str) -> subprocess.Popen[Any]:
verbosity_flag: str = loglevel_verbosity_flag(TM_LOGLEVEL)

cmd: List[str] = ["task-maker-tools"]
if verbosity_flag:
cmd.append(verbosity_flag)
cmd += ["worker", "--store-dir", store_dir]
if WORKER_ARGS.strip():
cmd += shlex.split(WORKER_ARGS)
cmd.append(SERVER_ADDR)

logger.debug("Exec: %s", " ".join(shlex.quote(c) for c in cmd))
logger.info("Starting worker (store=%s) → %s", store_dir, SERVER_ADDR)
proc = subprocess.Popen(cmd)
logger.debug("Worker PID %s started for store %s", proc.pid, store_dir)
return proc

# ---------- main ----------
def main() -> int:
configure_logging()
args = cli()
nworkers: int = int(args.jobs)

logger.info(
f"Config: jobs={nworkers}, server={SPAWN_SERVER}, worker={SPAWN_WORKERS}, "
f"addr={SERVER_ADDR}, tm_loglevel={TM_LOGLEVEL}"
)
if SERVER_ARGS.strip():
logger.debug("SERVER_ARGS=%r", SERVER_ARGS)
if WORKER_ARGS.strip():
logger.debug("WORKER_ARGS=%r", WORKER_ARGS)

# create nworkers file in the current dir and save a 0 into it
nworkers_file: Path = Path('nworkers')
with nworkers_file.open('w') as nw_fp:
nw_fp.write("0\n")

if SPAWN_WORKERS:
worker_stores: List[str] = make_worker_stores(nworkers)
# overwrite ith the actual number of wokers if we spawn them
with nworkers_file.open('w') as nw_fp:
nw_fp.write(f"{nworkers}\n")

# worker only
if (not SPAWN_SERVER) and SPAWN_WORKERS:
logger.info("Mode: workers only.")
procs: List[subprocess.Popen[Any]] = [spawn_tmworker(store_dir=w)
for w in worker_stores]
exit_codes: List[Optional[int]] = [p.wait() for p in procs]
max_rc = max(code or 0 for code in exit_codes)
logger.info("All workers exited. Max rc=%d", max_rc)
return max_rc

# server only
elif SPAWN_SERVER and (not SPAWN_WORKERS):
logger.info("Mode: server only.")
return spawn_tmserver()

# server + worker
elif SPAWN_SERVER and SPAWN_WORKERS:
logger.info("Mode: server + workers.")
procs: List[subprocess.Popen[Any]] = []

def launch_workers() -> None:
logger.debug("Delaying worker launch by 2s to let server come up…")
time.sleep(2.0)
for w in worker_stores:
procs.append(spawn_tmworker(store_dir=w))
for p in procs:
rc = p.wait()
logger.info("Worker PID %s exited with rc=%d", p.pid, rc)

t = threading.Thread(target=launch_workers, daemon=True)
t.start()
server_rc: int = spawn_tmserver()

logger.debug("Server finished (rc=%d). Joining worker launcher…", server_rc)
t.join(timeout=1.0)
# If any workers still running, terminate politely
for p in procs:
if p.poll() is None:
logger.debug("Terminating lingering worker PID %s…", p.pid)
try:
p.terminate()
except Exception as e:
logger.warning("Failed to terminate worker PID %s: %s", p.pid, e)
return server_rc

# nothing to spawn -> shell
else:
logger.info("Mode: nothing to spawn — opening interactive shell.")
shell: str = os.environ.get("SHELL", "/bin/bash")
try:
return subprocess.call([shell])
except FileNotFoundError:
logger.error("Shell %r not found; exiting 0.", shell)
return 0

if __name__ == "__main__":
try:
sys.exit(main())
except subprocess.CalledProcessError as e:
logger.exception("Subprocess failed (rc=%s).", e.returncode)
sys.exit(e.returncode)
except Exception:
logger.exception("Fatal error:")
sys.exit(1)
Loading