Skip to content

Commit 65dc18d

Browse files
committed
Initial.
0 parents  commit 65dc18d

29 files changed

+1713
-0
lines changed

Diff for: Dockerfile

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
FROM python:3.5.2
2+
3+
RUN apt-get update && apt-get install -y netcat
4+
5+
RUN mkdir /judge
6+
COPY requirements.txt /judge/
7+
WORKDIR /judge
8+
RUN pip3 install -r requirements.txt
9+
COPY . /judge/
10+
11+
CMD ["bash", "-c", "bash wait-for-db.sh && gunicorn --worker-class eventlet --bind 0.0.0.0:80 -w 1 main:app"]

Diff for: autoscale.py

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import os
2+
import logging
3+
import time
4+
from collections import deque
5+
6+
import digitalocean
7+
from dotenv import load_dotenv, find_dotenv
8+
9+
from main import app
10+
from models import APIKey, Job
11+
import util
12+
13+
load_dotenv(find_dotenv())
14+
15+
JUDGE_URL = os.getenv('JUDGE_URL', '')
16+
MAX_JURIES = 10
17+
18+
DIGITALOCEAN_API_TOKEN = os.getenv('DIGITALOCEAN_API_TOKEN', '')
19+
20+
# TODO: Add stop command for jury systemd service
21+
USER_DATA_TEMPLATE = '''#!/bin/bash
22+
23+
cat > /etc/systemd/system/docker-jury.service <<EOF
24+
[Unit]
25+
Description=Jury container
26+
Requires=docker.service
27+
After=docker.service
28+
29+
[Service]
30+
Restart=always
31+
ExecStart=/usr/bin/docker run --cap-add=SYS_PTRACE -e JUDGE_URL={judge_url} -e JUDGE_API_KEY={api_key} easyctf/openctf-jury:latest
32+
ExecStop=:
33+
34+
[Install]
35+
WantedBy=default.target
36+
EOF
37+
38+
systemctl daemon-reload
39+
systemctl enable docker-jury
40+
systemctl start docker-jury
41+
'''
42+
43+
logging.getLogger().setLevel(logging.INFO)
44+
logger = logging.getLogger('autoscale')
45+
logger.setLevel(logging.DEBUG)
46+
logging.info('Starting up!')
47+
48+
49+
class Cloud:
50+
def get_current_jury_count(self):
51+
raise NotImplemented
52+
53+
def create_jury(self, n=1):
54+
raise NotImplemented
55+
56+
def destroy_jury(self, n=1):
57+
raise NotImplemented
58+
59+
60+
class DigitalOcean(Cloud):
61+
def __init__(self, token):
62+
self.token = token
63+
self.manager = digitalocean.Manager(token=self.token)
64+
65+
def _get_juries(self):
66+
return self.manager.get_all_droplets('jury')
67+
68+
def get_current_jury_count(self):
69+
return len(self._get_juries())
70+
71+
def create_jury(self, n=1):
72+
for _ in range(n):
73+
name = 'jury-{}'.format(util.generate_hex_string(8))
74+
with app.app_context():
75+
api_key = APIKey.new(name=name, perm_jury=True).key
76+
77+
digitalocean.Droplet(
78+
token=self.token,
79+
name=name,
80+
region='sfo2',
81+
image='docker-16-04',
82+
size_slug='2gb',
83+
tags=['jury'],
84+
user_data=USER_DATA_TEMPLATE.format(judge_url=JUDGE_URL, api_key=api_key)
85+
).create()
86+
87+
def destroy_jury(self, n=1):
88+
juries = self._get_juries()
89+
n = min(n, len(juries))
90+
for _ in range(n):
91+
juries.pop().destroy()
92+
return n
93+
94+
95+
class LoadIndex:
96+
def __init__(self, jury_count=1):
97+
self.window_size = 10
98+
self.last_n = deque()
99+
self.jury_count = jury_count
100+
101+
def update(self, new_load):
102+
self.last_n.append(new_load)
103+
if len(self.last_n) > self.window_size:
104+
self.last_n.popleft()
105+
106+
def update_jury_count(self, jury_count):
107+
self.jury_count = jury_count
108+
109+
def optimal_change(self):
110+
avg = sum(self.last_n) / len(self.last_n)
111+
index = avg / self.jury_count
112+
logger.info('Average enqueued is {} - {} per jury.'.format(avg, index))
113+
if index >= 20:
114+
return int(index) // 20
115+
if index < 2:
116+
return -1
117+
return 0
118+
119+
120+
def get_enqueued_jobs():
121+
with app.app_context():
122+
return Job.query_can_claim().count()
123+
124+
125+
cloud = DigitalOcean(token=DIGITALOCEAN_API_TOKEN)
126+
127+
load_index = LoadIndex()
128+
129+
# TODO: better tracking of juries
130+
jury_count = cloud.get_current_jury_count()
131+
132+
133+
def tick():
134+
global jury_count
135+
enqueued_jobs = get_enqueued_jobs()
136+
load_index.update(enqueued_jobs)
137+
load_index.update_jury_count(jury_count)
138+
optimal_change = load_index.optimal_change()
139+
140+
logger.info('{} juries currently exist and optimal change is {}.'.format(jury_count, optimal_change))
141+
if optimal_change >= 2:
142+
if jury_count < MAX_JURIES:
143+
to_create = min(optimal_change, MAX_JURIES - jury_count)
144+
logger.info('Spinning up {} juries.'.format(to_create))
145+
cloud.create_jury(to_create)
146+
jury_count += to_create
147+
else:
148+
logger.info('Maximum jury count reached.')
149+
elif optimal_change <= -1:
150+
# TODO: clean shutdown of juries or detection of jury's current job
151+
if jury_count > 1:
152+
to_destroy = min(-optimal_change, jury_count - 1)
153+
logger.info('Destroying {} juries.'.format(to_destroy))
154+
destroyed = cloud.destroy_jury(to_destroy)
155+
jury_count -= destroyed
156+
logger.info('Destroyed {} juries.'.format(destroyed))
157+
else:
158+
logger.info('Not enough juries to destroy!')
159+
160+
161+
if cloud.get_current_jury_count() == 0:
162+
logger.info('Spinning up 1 jury because none previously existed.')
163+
cloud.create_jury()
164+
jury_count += 1
165+
166+
while True:
167+
tick()
168+
169+
time.sleep(5)

Diff for: config.py

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import os
2+
import pathlib
3+
4+
from dotenv import load_dotenv, find_dotenv
5+
6+
load_dotenv(find_dotenv())
7+
8+
SUPPORTED_LANGUAGES = {
9+
'cxx': 'C++',
10+
'python2': 'Python 2',
11+
'python3': 'Python 3',
12+
'java': 'Java',
13+
}
14+
15+
16+
class JudgeConfig:
17+
def __init__(self, app_root: str = None, testing: bool = False):
18+
if app_root is None:
19+
self.app_root = pathlib.Path(os.path.dirname(os.path.abspath(__file__)))
20+
else:
21+
self.app_root = pathlib.Path(app_root)
22+
23+
self.ENABLE_SOCKETIO = bool(int(os.getenv('ENABLE_SOCKETIO', 1)))
24+
25+
self.SECRET_KEY = None
26+
self._load_secret_key()
27+
self.SQLALCHEMY_DATABASE_URI = self._get_test_database_uri() if testing else self._get_database_uri()
28+
self.SQLALCHEMY_TRACK_MODIFICATIONS = False
29+
self.REDIS_URI = self._get_redis_uri()
30+
31+
if testing:
32+
self.TESTING = True
33+
self.WTF_CSRF_ENABLED = False
34+
35+
def _load_secret_key(self):
36+
if 'SECRET_KEY' in os.environ:
37+
self.SECRET_KEY = os.environ['SECRET_KEY']
38+
else:
39+
secret_path = self.app_root / '.secret_key'
40+
with secret_path.open('a+b') as secret_file:
41+
secret_file.seek(0)
42+
contents = secret_file.read()
43+
if not contents and len(contents) == 0:
44+
key = os.urandom(128)
45+
secret_file.write(key)
46+
secret_file.flush()
47+
else:
48+
key = contents
49+
self.SECRET_KEY = key
50+
51+
return self.SECRET_KEY
52+
53+
def _get_database_uri(self):
54+
return os.getenv('DATABASE_URI', '')
55+
56+
def _get_test_database_uri(self):
57+
return os.getenv('TEST_DATABASE_URI', '')
58+
59+
def _get_redis_uri(self):
60+
return os.getenv('REDIS_URI', '')

Diff for: conftest.py

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import pytest
2+
3+
import main
4+
from config import JudgeConfig
5+
from main import db as app_db
6+
7+
8+
@pytest.fixture(scope='session')
9+
def app(request):
10+
app = main.app
11+
app.config.from_object(JudgeConfig(testing=True))
12+
13+
ctx = app.app_context()
14+
ctx.push()
15+
16+
def teardown():
17+
ctx.pop()
18+
19+
request.addfinalizer(teardown)
20+
21+
return app
22+
23+
24+
@pytest.fixture(scope='function')
25+
def client(app, request_context):
26+
return app.test_client()
27+
28+
29+
@pytest.fixture(scope='class')
30+
def db(request, app):
31+
app_db.reflect() # Weird hack
32+
app_db.drop_all()
33+
34+
app_db.create_all()
35+
36+
def teardown():
37+
app_db.session.close()
38+
app_db.drop_all()
39+
40+
request.addfinalizer(teardown)
41+
return app_db
42+
43+
44+
@pytest.fixture(scope='function')
45+
def request_context(request, app):
46+
ctx = app.test_request_context()
47+
ctx.push()
48+
49+
def teardown():
50+
ctx.pop()
51+
52+
request.addfinalizer(teardown)
53+
54+
55+
@pytest.fixture(scope='class')
56+
def session(request, db):
57+
connection = db.engine.connect()
58+
transaction = connection.begin()
59+
60+
options = dict(bind=connection, binds={})
61+
session = db.create_scoped_session(options=options)
62+
63+
db.session = session
64+
65+
def teardown():
66+
transaction.rollback()
67+
connection.close()
68+
session.remove()
69+
70+
request.addfinalizer(teardown)
71+
return session

Diff for: constants.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import enum
2+
3+
4+
class APIKeyType(enum.Enum):
5+
jury = 'jury'
6+
observer = 'observer'
7+
8+
9+
class JobStatus(enum.Enum):
10+
queued = 'queued'
11+
cancelled = 'cancelled'
12+
started = 'started'
13+
awaiting_verdict = 'awaiting_verdict'
14+
finished = 'finished'
15+
16+
17+
class JobVerdict(enum.Enum):
18+
accepted = 'AC'
19+
ran = 'RAN'
20+
invalid_source = 'IS'
21+
wrong_answer = 'WA'
22+
time_limit_exceeded = 'TLE'
23+
memory_limit_exceeded = 'MLE'
24+
runtime_error = 'RTE'
25+
illegal_syscall = 'ISC'
26+
compilation_error = 'CE'
27+
judge_error = 'JE'

Diff for: docker-compose.yml

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
version: '2'
2+
services:
3+
judge:
4+
image: app
5+
env_file: .env
6+
links:
7+
- db
8+
- redis
9+
ports:
10+
- "80:80"
11+
depends_on:
12+
- migrations
13+
- redis
14+
15+
db:
16+
image: mariadb:10.1.16
17+
env_file: .env
18+
expose:
19+
- 3306
20+
volumes:
21+
- "./.data/db:/var/lib/mysql"
22+
23+
migrations:
24+
build: .
25+
image: app
26+
env_file: .env
27+
command: bash -c "bash wait-for-db.sh && python3 manage.py db upgrade"
28+
links:
29+
- db
30+
depends_on:
31+
- db
32+
33+
redis:
34+
restart: "no"
35+
image: redis

0 commit comments

Comments
 (0)