Skip to content

Commit

Permalink
Migrate to cgroup2, debian:bookworm, Python 3.8+, aiohttp 3+, and rua…
Browse files Browse the repository at this point in the history
…mel.yaml 0.18.0+ (#87)
  • Loading branch information
twd2 authored Jun 26, 2024
1 parent 592c55d commit cabe503
Show file tree
Hide file tree
Showing 13 changed files with 156 additions and 127 deletions.
16 changes: 8 additions & 8 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@ jobs:
id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"

- name: Setup Python 3.5
- name: Setup Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.5
python-version: 3.8

- name: Setup pip cache
uses: actions/cache@v2
with:
path: ~/.cache/pip
key: ${{ runner.os }}-py3.5-pip--${{ hashFiles('requirements.txt') }}
key: ${{ runner.os }}-py3.8-pip--${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-py3.5-pip-
${{ runner.os }}-py3.5-
${{ runner.os }}-py3.8-pip-
${{ runner.os }}-py3.8-
- name: Prepare environment
run: |
Expand All @@ -39,7 +39,7 @@ jobs:
- name: Unit test
run: python -m unittest -v jd4.case_test jd4.compare_test

- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v1

Expand All @@ -53,7 +53,7 @@ jobs:
- name: Integration test
run: |
docker load --input /tmp/jd4.tar
docker run --privileged \
docker run --privileged --cgroupns=host \
-v $(readlink -f examples/config.yaml):/root/.config/jd4/config.yaml \
vijos/jd4 /bin/bash -c "source /venv/bin/activate && python3 -m unittest -v jd4.integration_test"
Expand Down Expand Up @@ -93,7 +93,7 @@ jobs:
with:
push: true
tags: vijos/jd4:latest,vijos/jd4:${{ needs.test.outputs.sha_short }}

- name: Release to GitHub
uses: ncipollo/release-action@v1
with:
Expand Down
10 changes: 5 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM debian:stretch
FROM debian:bookworm
COPY . /tmp/jd4
RUN apt-get update && \
apt-get install -y \
Expand All @@ -8,11 +8,11 @@ RUN apt-get update && \
python3-dev \
g++ \
fp-compiler \
openjdk-8-jdk-headless \
python \
php7.0-cli \
openjdk-17-jdk-headless \
python-is-python3 \
php8.2-cli \
rustc \
haskell-platform \
ghc \
libjavascriptcoregtk-4.0-bin \
golang \
ruby \
Expand Down
10 changes: 5 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Introduction
jd4 is a judging daemon for programming contests like OI and ACM. It is called
jd4 because we had jd, jd2, jd3 before. Unlike previous versions that use
Windows sandboxing techniques, jd4 uses newer sandboxing facilities that
appear in Linux 4.4+. jd4 also multiplexes most I/O on an event loop so that
appear in Linux 5.19+. jd4 also multiplexes most I/O on an event loop so that
only two extra threads are used during a judge - one for input, and one for
output, thus allowing blocking custom judge implementations.

Expand All @@ -21,7 +21,7 @@ Usage

Prerequisites:

- Linux 4.4+
- Linux 5.19+
- Docker

Put config.yaml in the configuration directory, usually in
Expand All @@ -40,8 +40,8 @@ Development

Prerequisites:

- Linux 4.4+
- Python 3.5+
- Linux 5.19+
- Python 3.8+

Use the following command to install Python requirements::

Expand Down Expand Up @@ -113,7 +113,7 @@ throughput increment by using Cython (like 3MB/s to 200MB/s).
Copyright and License
---------------------

Copyright (c) 2017 Vijos Dev Team. All rights reserved.
Copyright (c) 2024 Vijos Dev Team. All rights reserved.

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
Expand Down
12 changes: 11 additions & 1 deletion jd4/_sandbox.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,14 @@ def enter_namespace(root_dir, in_dir, out_dir):
mkdir('dev')
bind_mount('/dev/null', 'dev/null', False, True, True, False)
bind_mount('/dev/urandom', 'dev/urandom', False, True, True, False)
mkdir('.cache')
mount('.cache', '.cache', 'tmpfs', MS_NOSUID, 'size=16m,nr_inodes=4k')
mkdir('tmp')
mount('tmp', 'tmp', 'tmpfs', MS_NOSUID, "size=16m,nr_inodes=4k")
mount('tmp', 'tmp', 'tmpfs', MS_NOSUID, 'size=16m,nr_inodes=4k')
bind_or_link('/bin', 'bin')
bind_or_link('/etc/alternatives', 'etc/alternatives')
bind_or_link('/etc/java-17-openjdk', 'etc/java-17-openjdk')
bind_or_link('/etc/mono', 'etc/mono')
bind_or_link('/lib', 'lib')
bind_or_link('/lib64', 'lib64')
bind_or_link('/usr/bin', 'usr/bin')
Expand All @@ -65,6 +69,12 @@ def enter_namespace(root_dir, in_dir, out_dir):
bind_or_link('/var/lib/ghc', 'var/lib/ghc')
bind_mount(in_dir, 'in', True, False, True, True)
bind_mount(out_dir, 'out', True, False, True, False)
write_text_file('etc/fpc.cfg', '''
-Sgic
-Fu/usr/lib/$fpctarget-gnu/fpc/$fpcversion/units/$fpctarget
-Fu/usr/lib/$fpctarget-gnu/fpc/$fpcversion/units/$fpctarget/*
-Fu/usr/lib/$fpctarget-gnu/fpc/$fpcversion/units/$fpctarget/rtl
''')
write_text_file('etc/passwd', 'icebox:x:1000:1000:icebox:/:/bin/bash\n')
mkdir('old_root')
pivot_root('.', 'old_root')
Expand Down
3 changes: 2 additions & 1 deletion jd4/case.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
DEFAULT_TIME_NS = 1000000000
DEFAULT_MEMORY_BYTES = 268435456
PROCESS_LIMIT = 64
_yaml_safe = yaml.YAML(typ='safe')

class CaseBase:
def __init__(self, time_limit_ns, memory_limit_bytes, process_limit, score):
Expand Down Expand Up @@ -255,7 +256,7 @@ def read_legacy_cases(config, open):
int(score_str))

def read_yaml_cases(config, open):
for case in yaml.safe_load(config)['cases']:
for case in _yaml_safe.load(config)['cases']:
if 'judge' not in case:
yield DefaultCase(partial(open, case['input']),
partial(open, case['output']),
Expand Down
80 changes: 43 additions & 37 deletions jd4/cgroup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from asyncio import get_event_loop, shield, sleep, wait_for, TimeoutError
from itertools import chain
from os import access, cpu_count, geteuid, kill, makedirs, path, rmdir, W_OK
from os import access, cpu_count, geteuid, getpid, kill, makedirs, path, rmdir, W_OK
from signal import SIGKILL
from socket import socket, AF_UNIX, SOCK_STREAM, SOL_SOCKET, SO_PEERCRED
from subprocess import call
Expand All @@ -10,58 +10,63 @@
from jd4.log import logger
from jd4.util import read_text_file, write_text_file

CPUACCT_CGROUP_ROOT = '/sys/fs/cgroup/cpuacct/jd4'
MEMORY_CGROUP_ROOT = '/sys/fs/cgroup/memory/jd4'
PIDS_CGROUP_ROOT = '/sys/fs/cgroup/pids/jd4'
CGROUP2_ROOT = '/sys/fs/cgroup/jd4'
CGROUP2_DAEMON_ROOT = path.join(CGROUP2_ROOT, 'daemon')
WAIT_JITTER_NS = 5000000

def try_init_cgroup():
euid = geteuid()
cgroups_to_init = list()
if not (path.isdir(CPUACCT_CGROUP_ROOT) and access(CPUACCT_CGROUP_ROOT, W_OK)):
cgroups_to_init.append(CPUACCT_CGROUP_ROOT)
if not (path.isdir(MEMORY_CGROUP_ROOT) and access(MEMORY_CGROUP_ROOT, W_OK)):
cgroups_to_init.append(MEMORY_CGROUP_ROOT)
if not (path.isdir(PIDS_CGROUP_ROOT) and access(PIDS_CGROUP_ROOT, W_OK)):
cgroups_to_init.append(PIDS_CGROUP_ROOT)
if cgroups_to_init:
if not (path.isdir(CGROUP2_ROOT) and access(CGROUP2_ROOT, W_OK)):
cgroup2_subtree_control = path.join(CGROUP2_ROOT, 'cgroup.subtree_control')
if euid == 0:
logger.info('Initializing cgroup: %s', ', '.join(cgroups_to_init))
for cgroup_to_init in cgroups_to_init:
makedirs(cgroup_to_init, exist_ok=True)
logger.info('Initializing cgroup: %s', CGROUP2_ROOT)
write_text_file('/sys/fs/cgroup/cgroup.subtree_control', '+cpu +memory +pids')
makedirs(CGROUP2_ROOT, exist_ok=True)
write_text_file(cgroup2_subtree_control, '+cpu +memory +pids')
makedirs(CGROUP2_DAEMON_ROOT, exist_ok=True)
elif __stdin__.isatty():
logger.info('Initializing cgroup: %s', ', '.join(cgroups_to_init))
call(['sudo', 'sh', '-c', 'mkdir -p "{1}" && chown -R "{0}" "{1}"'.format(
euid, '" "'.join(cgroups_to_init))])
logger.info('Initializing cgroup: %s', CGROUP2_ROOT)
call(['sudo', 'sh', '-c',
'''echo "+cpu +memory +pids" > "/sys/fs/cgroup/cgroup.subtree_control" &&
mkdir -p "{1}" &&
chown -R "{0}" "{1}" &&
echo "+cpu +memory +pids" > "{2}" &&
mkdir -p "{3}" &&
chown -R "{0}" "{3}"'''.format(
euid, CGROUP2_ROOT, cgroup2_subtree_control, CGROUP2_DAEMON_ROOT)])
else:
logger.error('Cgroup not initialized')

# Put myself into the cgroup that I can write.
pid = getpid()
cgroup2_daemon_procs = path.join(CGROUP2_DAEMON_ROOT, 'cgroup.procs')
if euid == 0:
logger.info('Entering cgroup: %s', CGROUP2_DAEMON_ROOT)
write_text_file(cgroup2_daemon_procs, str(pid))
elif __stdin__.isatty():
logger.info('Entering cgroup: %s', CGROUP2_DAEMON_ROOT)
call(['sudo', 'sh', '-c', 'echo "{0}" > "{1}"'.format(pid, cgroup2_daemon_procs)])
else:
logger.error('Cgroup not entered')

class CGroup:
def __init__(self):
self.cpuacct_cgroup_dir = mkdtemp(prefix='', dir=CPUACCT_CGROUP_ROOT)
self.memory_cgroup_dir = mkdtemp(prefix='', dir=MEMORY_CGROUP_ROOT)
self.pids_cgroup_dir = mkdtemp(prefix='', dir=PIDS_CGROUP_ROOT)
self.cgroup2_dir = mkdtemp(prefix='', dir=CGROUP2_ROOT)

def close(self):
rmdir(self.cpuacct_cgroup_dir)
rmdir(self.memory_cgroup_dir)
rmdir(self.pids_cgroup_dir)
rmdir(self.cgroup2_dir)

async def accept(self, sock):
loop = get_event_loop()
accept_sock, _ = await loop.sock_accept(sock)
pid = accept_sock.getsockopt(SOL_SOCKET, SO_PEERCRED)
write_text_file(path.join(self.cpuacct_cgroup_dir, 'tasks'), str(pid))
write_text_file(path.join(self.memory_cgroup_dir, 'tasks'), str(pid))
write_text_file(path.join(self.pids_cgroup_dir, 'tasks'), str(pid))
write_text_file(path.join(self.cgroup2_dir, 'cgroup.procs'), str(pid))
accept_sock.close()

@property
def procs(self):
return set(chain(
map(int, read_text_file(path.join(self.cpuacct_cgroup_dir, 'cgroup.procs')).splitlines()),
map(int, read_text_file(path.join(self.memory_cgroup_dir, 'cgroup.procs')).splitlines()),
map(int, read_text_file(path.join(self.pids_cgroup_dir, 'cgroup.procs')).splitlines())))
return set(map(int,
read_text_file(path.join(self.cgroup2_dir, 'cgroup.procs')).splitlines()))

def kill(self):
procs = self.procs
Expand All @@ -77,27 +82,28 @@ def kill(self):

@property
def cpu_usage_ns(self):
return int(read_text_file(path.join(self.cpuacct_cgroup_dir, 'cpuacct.usage')))
return 1000 * int(read_text_file(path.join(self.cgroup2_dir, 'cpu.stat'))
.splitlines()[0].split()[1])

@property
def memory_limit_bytes(self):
return int(read_text_file(path.join(self.memory_cgroup_dir, 'memory.limit_in_bytes')))
return int(read_text_file(path.join(self.cgroup2_dir, 'memory.max')))

@memory_limit_bytes.setter
def memory_limit_bytes(self, value):
write_text_file(path.join(self.memory_cgroup_dir, 'memory.limit_in_bytes'), str(value))
write_text_file(path.join(self.cgroup2_dir, 'memory.max'), str(value))

@property
def memory_usage_bytes(self):
return int(read_text_file(path.join(self.memory_cgroup_dir, 'memory.max_usage_in_bytes')))
return int(read_text_file(path.join(self.cgroup2_dir, 'memory.peak')))

@property
def pids_max(self):
return int(read_text_file(path.join(self.pids_cgroup_dir, 'pids.max')))
return int(read_text_file(path.join(self.cgroup2_dir, 'pids.max')))

@pids_max.setter
def pids_max(self, value):
write_text_file(path.join(self.pids_cgroup_dir, 'pids.max'), str(value))
write_text_file(path.join(self.cgroup2_dir, 'pids.max'), str(value))

def enter_cgroup(socket_path):
with socket(AF_UNIX, SOCK_STREAM) as sock:
Expand Down
3 changes: 2 additions & 1 deletion jd4/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
_CONFIG_DIR = user_config_dir('jd4')
_LANGS_FILE = path.join(_CONFIG_DIR, 'langs.yaml')
_langs = dict()
_yaml = yaml.YAML()

class Executable:
def __init__(self, execute_file, execute_args):
Expand Down Expand Up @@ -155,7 +156,7 @@ async def build(lang, code):
def _init():
try:
with open(_LANGS_FILE) as file:
langs_config = yaml.load(file, Loader=yaml.RoundTripLoader)
langs_config = _yaml.load(file)
except FileNotFoundError:
logger.error('Language file %s not found.', _LANGS_FILE)
exit(1)
Expand Down
5 changes: 3 additions & 2 deletions jd4/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

_CONFIG_DIR = user_config_dir('jd4')
_CONFIG_FILE = path.join(_CONFIG_DIR, 'config.yaml')
_yaml = yaml.YAML()

def _load_config():
try:
with open(_CONFIG_FILE, encoding='utf-8') as file:
return yaml.load(file, Loader=yaml.RoundTripLoader)
return _yaml.load(file)
except FileNotFoundError:
logger.error('Config file %s not found.', _CONFIG_FILE)
exit(1)
Expand All @@ -21,7 +22,7 @@ def _load_config():
async def save_config():
def do_save_config():
with open(_CONFIG_FILE, 'w', encoding='utf-8') as file:
yaml.dump(config, file, Dumper=yaml.RoundTripDumper)
_yaml.dump(config, file)

await get_event_loop().run_in_executor(None, do_save_config)

Expand Down
Loading

0 comments on commit cabe503

Please sign in to comment.