This commit is contained in:
Omar Brikaa 2024-09-06 21:54:50 +03:00 committed by GitHub
commit 2bac980072
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 508 additions and 454 deletions

View File

@ -92,7 +92,7 @@ jobs:
docker run -v $(pwd)'/repo:/piston/repo' -v $(pwd)'/packages:/piston/packages' -d --name repo docker.pkg.github.com/engineer-man/piston/repo-builder --no-build
docker pull docker.pkg.github.com/engineer-man/piston/api
docker build -t piston-api api
docker run --network container:repo -v $(pwd)'/data:/piston' -e PISTON_LOG_LEVEL=DEBUG -e 'PISTON_REPO_URL=http://localhost:8000/index' -d --name api piston-api
docker run --privileged --network container:repo -v $(pwd)'/data:/piston' -e PISTON_LOG_LEVEL=DEBUG -e 'PISTON_REPO_URL=http://localhost:8000/index' -d --name api piston-api
echo Waiting for API to start..
docker run --network container:api appropriate/curl -s --retry 10 --retry-connrefused http://localhost:2000/api/v2/runtimes

View File

@ -1,20 +1,29 @@
FROM node:15.10.0-buster-slim
FROM buildpack-deps:bookworm AS isolate
RUN apt-get update && \
apt-get install -y --no-install-recommends git libcap-dev && \
rm -rf /var/lib/apt/lists/* && \
git clone https://github.com/envicutor/isolate.git /tmp/isolate/ && \
cd /tmp/isolate && \
git checkout af6db68042c3aa0ded80787fbb78bc0846ea2114 && \
make -j$(nproc) install && \
rm -rf /tmp/*
FROM node:20-bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN dpkg-reconfigure -p critical dash
RUN for i in $(seq 1001 1500); do \
groupadd -g $i runner$i && \
useradd -M runner$i -g $i -u $i ; \
done
RUN apt-get update && \
apt-get install -y libxml2 gnupg tar coreutils util-linux libc6-dev \
binutils build-essential locales libpcre3-dev libevent-dev libgmp3-dev \
libncurses6 libncurses5 libedit-dev libseccomp-dev rename procps python3 \
libreadline-dev libblas-dev liblapack-dev libpcre3-dev libarpack2-dev \
libfftw3-dev libglpk-dev libqhull-dev libqrupdate-dev libsuitesparse-dev \
libsundials-dev libpcre2-dev && \
libsundials-dev libpcre2-dev libcap-dev && \
rm -rf /var/lib/apt/lists/*
RUN useradd -M piston
COPY --from=isolate /usr/local/bin/isolate /usr/local/bin
COPY --from=isolate /usr/local/etc/isolate /usr/local/etc/isolate
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
@ -23,7 +32,5 @@ COPY ["package.json", "package-lock.json", "./"]
RUN npm install
COPY ./src ./src
RUN make -C ./src/nosocket/ all && make -C ./src/nosocket/ install
CMD [ "node", "src"]
CMD ["/piston_api/src/docker-entrypoint.sh"]
EXPOSE 2000/tcp

View File

@ -6,50 +6,9 @@ const events = require('events');
const runtime = require('../runtime');
const { Job } = require('../job');
const package = require('../package');
const globals = require('../globals');
const logger = require('logplease').create('api/v2');
const SIGNALS = [
'SIGABRT',
'SIGALRM',
'SIGBUS',
'SIGCHLD',
'SIGCLD',
'SIGCONT',
'SIGEMT',
'SIGFPE',
'SIGHUP',
'SIGILL',
'SIGINFO',
'SIGINT',
'SIGIO',
'SIGIOT',
'SIGKILL',
'SIGLOST',
'SIGPIPE',
'SIGPOLL',
'SIGPROF',
'SIGPWR',
'SIGQUIT',
'SIGSEGV',
'SIGSTKFLT',
'SIGSTOP',
'SIGTSTP',
'SIGSYS',
'SIGTERM',
'SIGTRAP',
'SIGTTIN',
'SIGTTOU',
'SIGUNUSED',
'SIGURG',
'SIGUSR1',
'SIGUSR2',
'SIGVTALRM',
'SIGXCPU',
'SIGXFSZ',
'SIGWINCH',
];
// ref: https://man7.org/linux/man-pages/man7/signal.7.html
function get_job(body) {
let {
language,
@ -61,6 +20,8 @@ function get_job(body) {
run_memory_limit,
run_timeout,
compile_timeout,
run_cpu_time,
compile_cpu_time,
} = body;
return new Promise((resolve, reject) => {
@ -106,7 +67,7 @@ function get_job(body) {
});
}
for (const constraint of ['memory_limit', 'timeout']) {
for (const constraint of ['memory_limit', 'timeout', 'cpu_time']) {
for (const type of ['compile', 'run']) {
const constraint_name = `${type}_${constraint}`;
const constraint_value = body[constraint_name];
@ -135,23 +96,23 @@ function get_job(body) {
}
}
compile_timeout = compile_timeout || rt.timeouts.compile;
run_timeout = run_timeout || rt.timeouts.run;
compile_memory_limit = compile_memory_limit || rt.memory_limits.compile;
run_memory_limit = run_memory_limit || rt.memory_limits.run;
resolve(
new Job({
runtime: rt,
args: args || [],
stdin: stdin || '',
args: args ?? [],
stdin: stdin ?? '',
files,
timeouts: {
run: run_timeout,
compile: compile_timeout,
run: run_timeout ?? rt.timeouts.run,
compile: compile_timeout ?? rt.timeouts.compile,
},
cpu_times: {
run: run_cpu_time ?? rt.cpu_times.run,
compile: compile_cpu_time ?? rt.cpu_times.compile,
},
memory_limits: {
run: run_memory_limit,
compile: compile_memory_limit,
run: run_memory_limit ?? rt.memory_limits.run,
compile: compile_memory_limit ?? rt.memory_limits.compile,
},
})
);
@ -211,7 +172,7 @@ router.ws('/connect', async (ws, req) => {
job = await get_job(msg);
try {
await job.prime();
const box = await job.prime();
ws.send(
JSON.stringify({
@ -221,7 +182,7 @@ router.ws('/connect', async (ws, req) => {
})
);
await job.execute(event_bus);
await job.execute(box, event_bus);
} catch (error) {
logger.error(
`Error cleaning up job: ${job.uuid}:\n${error}`
@ -248,7 +209,9 @@ router.ws('/connect', async (ws, req) => {
break;
case 'signal':
if (job !== null) {
if (SIGNALS.includes(msg.signal)) {
if (
Object.values(globals.SIGNALS).includes(msg.signal)
) {
event_bus.emit('signal', msg.signal);
} else {
ws.close(4005, 'Invalid signal');
@ -279,9 +242,9 @@ router.post('/execute', async (req, res) => {
return res.status(400).json(error);
}
try {
await job.prime();
const box = await job.prime();
let result = await job.execute();
let result = await job.execute(box);
// Backward compatibility when the run stage is not started
if (result.run === undefined) {
result.run = result.compile;

View File

@ -90,6 +90,18 @@ const options = {
parser: parse_int,
validators: [(x, raw) => !is_nan(x) || `${raw} is not a number`],
},
compile_cpu_time: {
desc: 'Max CPU time allowed for compile stage in milliseconds',
default: 10000, // 10 seconds
parser: parse_int,
validators: [(x, raw) => !is_nan(x) || `${raw} is not a number`],
},
run_cpu_time: {
desc: 'Max CPU time allowed for run stage in milliseconds',
default: 3000, // 3 seconds
parser: parse_int,
validators: [(x, raw) => !is_nan(x) || `${raw} is not a number`],
},
compile_memory_limit: {
desc: 'Max memory usage for compile stage in bytes (set to -1 for no limit)',
default: -1, // no limit
@ -117,7 +129,7 @@ const options = {
limit_overrides: {
desc: 'Per-language exceptions in JSON format for each of:\
max_process_count, max_open_files, max_file_size, compile_memory_limit,\
run_memory_limit, compile_timeout, run_timeout, output_max_size',
run_memory_limit, compile_timeout, run_timeout, compile_cpu_time, run_cpu_time, output_max_size',
default: {},
parser: parse_overrides,
validators: [
@ -165,6 +177,8 @@ function parse_overrides(overrides_string) {
'run_memory_limit',
'compile_timeout',
'run_timeout',
'compile_cpu_time',
'run_cpu_time',
'output_max_size',
].includes(key)
) {

13
api/src/docker-entrypoint.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
cd /sys/fs/cgroup && \
mkdir isolate/ && \
echo 1 > isolate/cgroup.procs && \
echo '+cpuset +cpu +io +memory +pids' > cgroup.subtree_control && \
cd isolate && \
mkdir init && \
echo 1 > init/cgroup.procs && \
echo '+cpuset +memory' > cgroup.subtree_control && \
echo "Initialized cgroup" && \
chown -R piston:piston /piston && \
exec su -- piston -c 'ulimit -n 65536 && node /piston_api/src'

View File

@ -7,14 +7,78 @@ const platform = `${is_docker() ? 'docker' : 'baremetal'}-${fs
.split('\n')
.find(x => x.startsWith('ID'))
.replace('ID=', '')}`;
const SIGNALS = {
1: 'SIGHUP',
2: 'SIGINT',
3: 'SIGQUIT',
4: 'SIGILL',
5: 'SIGTRAP',
6: 'SIGABRT',
7: 'SIGBUS',
8: 'SIGFPE',
9: 'SIGKILL',
10: 'SIGUSR1',
11: 'SIGSEGV',
12: 'SIGUSR2',
13: 'SIGPIPE',
14: 'SIGALRM',
15: 'SIGTERM',
16: 'SIGSTKFLT',
17: 'SIGCHLD',
18: 'SIGCONT',
19: 'SIGSTOP',
20: 'SIGTSTP',
21: 'SIGTTIN',
22: 'SIGTTOU',
23: 'SIGURG',
24: 'SIGXCPU',
25: 'SIGXFSZ',
26: 'SIGVTALRM',
27: 'SIGPROF',
28: 'SIGWINCH',
29: 'SIGIO',
30: 'SIGPWR',
31: 'SIGSYS',
34: 'SIGRTMIN',
35: 'SIGRTMIN+1',
36: 'SIGRTMIN+2',
37: 'SIGRTMIN+3',
38: 'SIGRTMIN+4',
39: 'SIGRTMIN+5',
40: 'SIGRTMIN+6',
41: 'SIGRTMIN+7',
42: 'SIGRTMIN+8',
43: 'SIGRTMIN+9',
44: 'SIGRTMIN+10',
45: 'SIGRTMIN+11',
46: 'SIGRTMIN+12',
47: 'SIGRTMIN+13',
48: 'SIGRTMIN+14',
49: 'SIGRTMIN+15',
50: 'SIGRTMAX-14',
51: 'SIGRTMAX-13',
52: 'SIGRTMAX-12',
53: 'SIGRTMAX-11',
54: 'SIGRTMAX-10',
55: 'SIGRTMAX-9',
56: 'SIGRTMAX-8',
57: 'SIGRTMAX-7',
58: 'SIGRTMAX-6',
59: 'SIGRTMAX-5',
60: 'SIGRTMAX-4',
61: 'SIGRTMAX-3',
62: 'SIGRTMAX-2',
63: 'SIGRTMAX-1',
64: 'SIGRTMAX',
};
module.exports = {
data_directories: {
packages: 'packages',
jobs: 'jobs',
},
version: require('../package.json').version,
platform,
pkg_installed_file: '.ppman-installed', //Used as indication for if a package was installed
clean_directories: ['/dev/shm', '/run/lock', '/tmp', '/var/tmp'],
SIGNALS,
};

View File

@ -35,10 +35,6 @@ expressWs(app);
}
}
});
fss.chmodSync(
path.join(config.data_directory, globals.data_directories.jobs),
0o711
);
logger.info('Loading packages');
const pkgdir = path.join(

View File

@ -1,13 +1,10 @@
const logplease = require('logplease');
const logger = logplease.create('job');
const { v4: uuidv4 } = require('uuid');
const cp = require('child_process');
const path = require('path');
const config = require('./config');
const globals = require('./globals');
const fs = require('fs/promises');
const fss = require('fs');
const wait_pid = require('waitpid');
const globals = require('./globals');
const job_states = {
READY: Symbol('Ready to be primed'),
@ -15,17 +12,26 @@ const job_states = {
EXECUTED: Symbol('Executed and ready for cleanup'),
};
let uid = 0;
let gid = 0;
const MAX_BOX_ID = 999;
const ISOLATE_PATH = '/usr/local/bin/isolate';
let box_id = 0;
let remaining_job_spaces = config.max_concurrent_jobs;
let job_queue = [];
class Job {
#active_timeouts;
#active_parent_processes;
const get_next_box_id = () => ++box_id % MAX_BOX_ID;
constructor({ runtime, files, args, stdin, timeouts, memory_limits }) {
class Job {
#dirty_boxes;
constructor({
runtime,
files,
args,
stdin,
timeouts,
cpu_times,
memory_limits,
}) {
this.uuid = uuidv4();
this.logger = logplease.create(`job/${this.uuid}`);
@ -46,29 +52,39 @@ class Job {
this.stdin += '\n';
}
this.#active_timeouts = [];
this.#active_parent_processes = [];
this.timeouts = timeouts;
this.cpu_times = cpu_times;
this.memory_limits = memory_limits;
this.uid = config.runner_uid_min + uid;
this.gid = config.runner_gid_min + gid;
uid++;
gid++;
uid %= config.runner_uid_max - config.runner_uid_min + 1;
gid %= config.runner_gid_max - config.runner_gid_min + 1;
this.logger.debug(`Assigned uid=${this.uid} gid=${this.gid}`);
this.state = job_states.READY;
this.dir = path.join(
config.data_directory,
globals.data_directories.jobs,
this.uuid
);
this.#dirty_boxes = [];
}
async #create_isolate_box() {
const box_id = get_next_box_id();
const metadata_file_path = `/tmp/${box_id}-metadata.txt`;
return new Promise((res, rej) => {
cp.exec(
`isolate --init --cg -b${box_id}`,
(error, stdout, stderr) => {
if (error) {
rej(
`Failed to run isolate --init: ${error.message}\nstdout: ${stdout}\nstderr: ${stderr}`
);
}
if (stdout === '') {
rej('Received empty stdout from isolate --init');
}
const box = {
id: box_id,
metadata_file_path,
dir: `${stdout.trim()}/box`,
};
this.#dirty_boxes.push(box);
res(box);
}
);
});
}
async prime() {
@ -80,208 +96,235 @@ class Job {
}
this.logger.info(`Priming job`);
remaining_job_spaces--;
this.logger.debug('Writing files to job cache');
this.logger.debug(`Transfering ownership`);
await fs.mkdir(this.dir, { mode: 0o700 });
await fs.chown(this.dir, this.uid, this.gid);
this.logger.debug('Running isolate --init');
const box = await this.#create_isolate_box();
this.logger.debug(`Creating submission files in Isolate box`);
const submission_dir = path.join(box.dir, 'submission');
await fs.mkdir(submission_dir);
for (const file of this.files) {
const file_path = path.join(this.dir, file.name);
const rel = path.relative(this.dir, file_path);
const file_content = Buffer.from(file.content, file.encoding);
const file_path = path.join(submission_dir, file.name);
const rel = path.relative(submission_dir, file_path);
if (rel.startsWith('..'))
throw Error(
`File path "${file.name}" tries to escape parent directory: ${rel}`
);
const file_content = Buffer.from(file.content, file.encoding);
await fs.mkdir(path.dirname(file_path), {
recursive: true,
mode: 0o700,
});
await fs.chown(path.dirname(file_path), this.uid, this.gid);
await fs.write_file(file_path, file_content);
await fs.chown(file_path, this.uid, this.gid);
}
this.state = job_states.PRIMED;
this.logger.debug('Primed job');
return box;
}
exit_cleanup() {
for (const timeout of this.#active_timeouts) {
clear_timeout(timeout);
}
this.#active_timeouts = [];
this.logger.debug('Cleared the active timeouts');
async safe_call(
box,
file,
args,
timeout,
cpu_time,
memory_limit,
event_bus = null
) {
let stdout = '';
let stderr = '';
let output = '';
let memory = null;
let code = null;
let signal = null;
let message = null;
let status = null;
let cpu_time_stat = null;
let wall_time_stat = null;
this.cleanup_processes();
this.logger.debug(`Finished exit cleanup`);
}
close_cleanup() {
for (const proc of this.#active_parent_processes) {
proc.stderr.destroy();
if (!proc.stdin.destroyed) {
proc.stdin.end();
proc.stdin.destroy();
}
proc.stdout.destroy();
}
this.#active_parent_processes = [];
this.logger.debug('Destroyed processes writables');
}
async safe_call(file, args, timeout, memory_limit, event_bus = null) {
return new Promise((resolve, reject) => {
const nonetwork = config.disable_networking ? ['nosocket'] : [];
const prlimit = [
'prlimit',
'--nproc=' + this.runtime.max_process_count,
'--nofile=' + this.runtime.max_open_files,
'--fsize=' + this.runtime.max_file_size,
];
const timeout_call = [
'timeout',
const proc = cp.spawn(
ISOLATE_PATH,
[
'--run',
`-b${box.id}`,
`--meta=${box.metadata_file_path}`,
'--cg',
'-s',
'9',
Math.ceil(timeout / 1000),
];
if (memory_limit >= 0) {
prlimit.push('--as=' + memory_limit);
}
const proc_call = [
'nice',
...timeout_call,
...prlimit,
...nonetwork,
'bash',
'-c',
'/box/submission',
'-e',
`--dir=/runtime=${this.runtime.pkgdir}`,
`--dir=/etc:noexec`,
`--processes=${this.runtime.max_process_count}`,
`--open-files=${this.runtime.max_open_files}`,
`--fsize=${Math.floor(this.runtime.max_file_size / 1000)}`,
`--wall-time=${timeout / 1000}`,
`--time=${cpu_time / 1000}`,
`--extra-time=0`,
...(memory_limit >= 0
? [`--cg-mem=${Math.floor(memory_limit / 1000)}`]
: []),
...(config.disable_networking ? [] : ['--share-net']),
'--',
'/bin/bash',
file,
...args,
];
var stdout = '';
var stderr = '';
var output = '';
const proc = cp.spawn(proc_call[0], proc_call.splice(1), {
],
{
env: {
...this.runtime.env_vars,
PISTON_LANGUAGE: this.runtime.language,
},
stdio: 'pipe',
cwd: this.dir,
uid: this.uid,
gid: this.gid,
detached: true, //give this process its own process group
});
this.#active_parent_processes.push(proc);
if (event_bus === null) {
proc.stdin.write(this.stdin);
proc.stdin.end();
proc.stdin.destroy();
} else {
event_bus.on('stdin', data => {
proc.stdin.write(data);
});
event_bus.on('kill', signal => {
proc.kill(signal);
});
}
);
const kill_timeout =
(timeout >= 0 &&
set_timeout(async _ => {
this.logger.info(`Timeout exceeded timeout=${timeout}`);
try {
process.kill(proc.pid, 'SIGKILL');
}
catch (e) {
// Could already be dead and just needs to be waited on
this.logger.debug(
`Got error while SIGKILLing process ${proc}:`,
e
);
}
}, timeout)) ||
null;
this.#active_timeouts.push(kill_timeout);
proc.stderr.on('data', async data => {
if (event_bus !== null) {
event_bus.emit('stderr', data);
} else if ((stderr.length + data.length) > this.runtime.output_max_size) {
this.logger.info(`stderr length exceeded`);
try {
process.kill(proc.pid, 'SIGKILL');
}
catch (e) {
// Could already be dead and just needs to be waited on
this.logger.debug(
`Got error while SIGKILLing process ${proc}:`,
e
);
}
} else {
stderr += data;
output += data;
}
if (event_bus === null) {
proc.stdin.write(this.stdin);
proc.stdin.end();
proc.stdin.destroy();
} else {
event_bus.on('stdin', data => {
proc.stdin.write(data);
});
proc.stdout.on('data', async data => {
if (event_bus !== null) {
event_bus.emit('stdout', data);
} else if ((stdout.length + data.length) > this.runtime.output_max_size) {
this.logger.info(`stdout length exceeded`);
try {
process.kill(proc.pid, 'SIGKILL');
}
catch (e) {
// Could already be dead and just needs to be waited on
this.logger.debug(
`Got error while SIGKILLing process ${proc}:`,
e
);
}
} else {
stdout += data;
output += data;
}
event_bus.on('kill', signal => {
proc.kill(signal);
});
}
proc.on('exit', () => this.exit_cleanup());
proc.stderr.on('data', async data => {
if (event_bus !== null) {
event_bus.emit('stderr', data);
} else if (
stderr.length + data.length >
this.runtime.output_max_size
) {
message = 'stderr length exceeded';
this.logger.info(message);
try {
process.kill(proc.pid, 'SIGABRT');
} catch (e) {
// Could already be dead and just needs to be waited on
this.logger.debug(
`Got error while SIGABRTing process ${proc}:`,
e
);
}
} else {
stderr += data;
output += data;
}
});
proc.on('close', (code, signal) => {
this.close_cleanup();
proc.stdout.on('data', async data => {
if (event_bus !== null) {
event_bus.emit('stdout', data);
} else if (
stdout.length + data.length >
this.runtime.output_max_size
) {
message = 'stdout length exceeded';
this.logger.info(message);
try {
process.kill(proc.pid, 'SIGABRT');
} catch (e) {
// Could already be dead and just needs to be waited on
this.logger.debug(
`Got error while SIGABRTing process ${proc}:`,
e
);
}
} else {
stdout += data;
output += data;
}
});
resolve({ stdout, stderr, code, signal, output });
const data = await new Promise((res, rej) => {
proc.on('exit', (_, signal) => {
res({
signal,
});
});
proc.on('error', err => {
this.exit_cleanup();
this.close_cleanup();
reject({ error: err, stdout, stderr, output });
rej({
error: err,
});
});
});
try {
const metadata_str = (
await fs.read_file(box.metadata_file_path)
).toString();
const metadata_lines = metadata_str.split('\n');
for (const line of metadata_lines) {
if (!line) continue;
const [key, value] = line.split(':');
if (key === undefined || value === undefined) {
throw new Error(
`Failed to parse metadata file, received: ${line}`
);
}
switch (key) {
case 'cg-mem':
memory = parse_int(value) * 1000;
break;
case 'exitcode':
code = parse_int(value);
break;
case 'exitsig':
signal = globals.SIGNALS[parse_int(value)] ?? null;
break;
case 'message':
message = message || value;
break;
case 'status':
status = value;
break;
case 'time':
cpu_time_stat = parse_float(value) * 1000;
break;
case 'time-wall':
wall_time_stat = parse_float(value) * 1000;
break;
default:
break;
}
}
} catch (e) {
throw new Error(
`Error reading metadata file: ${box.metadata_file_path}\nError: ${e.message}\nIsolate run stdout: ${stdout}\nIsolate run stderr: ${stderr}`
);
}
return {
...data,
stdout,
stderr,
code,
signal,
output,
memory,
message,
status,
cpu_time: cpu_time_stat,
wall_time: wall_time_stat,
};
}
async execute(event_bus = null) {
async execute(box, event_bus = null) {
if (this.state !== job_states.PRIMED) {
throw new Error(
'Job must be in primed state, current state: ' +
this.state.toString()
this.state.toString()
);
}
@ -298,49 +341,61 @@ class Job {
const { emit_event_bus_result, emit_event_bus_stage } =
event_bus === null
? {
emit_event_bus_result: () => { },
emit_event_bus_stage: () => { },
}
emit_event_bus_result: () => {},
emit_event_bus_stage: () => {},
}
: {
emit_event_bus_result: (stage, result, event_bus) => {
const { error, code, signal } = result;
event_bus.emit('exit', stage, {
error,
code,
signal,
});
},
emit_event_bus_stage: (stage, event_bus) => {
event_bus.emit('stage', stage);
},
};
emit_event_bus_result: (stage, result) => {
const { error, code, signal } = result;
event_bus.emit('exit', stage, {
error,
code,
signal,
});
},
emit_event_bus_stage: stage => {
event_bus.emit('stage', stage);
},
};
if (this.runtime.compiled) {
this.logger.debug('Compiling');
emit_event_bus_stage('compile', event_bus);
emit_event_bus_stage('compile');
compile = await this.safe_call(
path.join(this.runtime.pkgdir, 'compile'),
box,
'/runtime/compile',
code_files.map(x => x.name),
this.timeouts.compile,
this.cpu_times.compile,
this.memory_limits.compile,
event_bus
);
emit_event_bus_result('compile', compile, event_bus);
emit_event_bus_result('compile', compile);
compile_errored = compile.code !== 0;
if (!compile_errored) {
const old_box_dir = box.dir;
box = await this.#create_isolate_box();
await fs.rename(
path.join(old_box_dir, 'submission'),
path.join(box.dir, 'submission')
);
}
}
let run;
if (!compile_errored) {
this.logger.debug('Running');
emit_event_bus_stage('run', event_bus);
emit_event_bus_stage('run');
run = await this.safe_call(
path.join(this.runtime.pkgdir, 'run'),
box,
'/runtime/run',
[code_files[0].name, ...this.args],
this.timeouts.run,
this.cpu_times.run,
this.memory_limits.run,
event_bus
);
emit_event_bus_result('run', run, event_bus);
emit_event_bus_result('run', run);
}
this.state = job_states.EXECUTED;
@ -353,139 +408,34 @@ class Job {
};
}
cleanup_processes(dont_wait = []) {
let processes = [1];
const to_wait = [];
this.logger.debug(`Cleaning up processes`);
while (processes.length > 0) {
processes = [];
const proc_ids = fss.readdir_sync('/proc');
processes = proc_ids.map(proc_id => {
if (isNaN(proc_id)) return -1;
try {
const proc_status = fss.read_file_sync(
path.join('/proc', proc_id, 'status')
);
const proc_lines = proc_status.to_string().split('\n');
const state_line = proc_lines.find(line =>
line.starts_with('State:')
);
const uid_line = proc_lines.find(line =>
line.starts_with('Uid:')
);
const [_, ruid, euid, suid, fuid] = uid_line.split(/\s+/);
const [_1, state, user_friendly] = state_line.split(/\s+/);
const proc_id_int = parse_int(proc_id);
// Skip over any processes that aren't ours.
if (ruid != this.uid && euid != this.uid) return -1;
if (state == 'Z') {
// Zombie process, just needs to be waited, regardless of the user id
if (!to_wait.includes(proc_id_int))
to_wait.push(proc_id_int);
return -1;
}
// We should kill in all other state (Sleep, Stopped & Running)
return proc_id_int;
} catch {
return -1;
}
return -1;
});
processes = processes.filter(p => p > 0);
if (processes.length > 0)
this.logger.debug(`Got processes to kill: ${processes}`);
for (const proc of processes) {
// First stop the processes, but keep their resources allocated so they cant re-fork
try {
process.kill(proc, 'SIGSTOP');
} catch (e) {
// Could already be dead
this.logger.debug(
`Got error while SIGSTOPping process ${proc}:`,
e
);
}
}
for (const proc of processes) {
// Then clear them out of the process tree
try {
process.kill(proc, 'SIGKILL');
} catch (e) {
// Could already be dead and just needs to be waited on
this.logger.debug(
`Got error while SIGKILLing process ${proc}:`,
e
);
}
to_wait.push(proc);
}
}
this.logger.debug(
`Finished kill-loop, calling wait_pid to end any zombie processes`
);
for (const proc of to_wait) {
if (dont_wait.includes(proc)) continue;
wait_pid(proc);
}
this.logger.debug(`Cleaned up processes`);
}
async cleanup_filesystem() {
for (const clean_path of globals.clean_directories) {
const contents = await fs.readdir(clean_path);
for (const file of contents) {
const file_path = path.join(clean_path, file);
try {
const stat = await fs.stat(file_path);
if (stat.uid === this.uid) {
await fs.rm(file_path, {
recursive: true,
force: true,
});
}
} catch (e) {
// File was somehow deleted in the time that we read the dir to when we checked the file
this.logger.warn(`Error removing file ${file_path}: ${e}`);
}
}
}
await fs.rm(this.dir, { recursive: true, force: true });
}
async cleanup() {
this.logger.info(`Cleaning up job`);
this.exit_cleanup(); // Run process janitor, just incase there are any residual processes somehow
this.close_cleanup();
await this.cleanup_filesystem();
remaining_job_spaces++;
if (job_queue.length > 0) {
job_queue.shift()();
}
await Promise.all(
this.#dirty_boxes.map(async box => {
cp.exec(
`isolate --cleanup --cg -b${box.id}`,
(error, stdout, stderr) => {
if (error) {
this.logger.error(
`Failed to run isolate --cleanup: ${error.message} on box #${box.id}\nstdout: ${stdout}\nstderr: ${stderr}`
);
}
}
);
try {
await fs.rm(box.metadata_file_path);
} catch (e) {
this.logger.error(
`Failed to remove the metadata directory of box #${box.id}. Error: ${e.message}`
);
}
})
);
}
}

View File

@ -145,7 +145,11 @@ class Package {
await fs.write_file(path.join(this.install_path, '.env'), filtered_env);
logger.debug('Changing Ownership of package directory');
await util.promisify(chownr)(this.install_path, 0, 0);
await util.promisify(chownr)(
this.install_path,
process.getuid(),
process.getgid()
);
logger.debug('Writing installed state to disk');
await fs.write_file(

View File

@ -15,6 +15,7 @@ class Runtime {
pkgdir,
runtime,
timeouts,
cpu_times,
memory_limits,
max_process_count,
max_open_files,
@ -27,6 +28,7 @@ class Runtime {
this.pkgdir = pkgdir;
this.runtime = runtime;
this.timeouts = timeouts;
this.cpu_times = cpu_times;
this.memory_limits = memory_limits;
this.max_process_count = max_process_count;
this.max_open_files = max_open_files;
@ -62,6 +64,18 @@ class Runtime {
language_limit_overrides
),
},
cpu_times: {
compile: this.compute_single_limit(
language_name,
'compile_cpu_time',
language_limit_overrides
),
run: this.compute_single_limit(
language_name,
'run_cpu_time',
language_limit_overrides
),
},
memory_limits: {
compile: this.compute_single_limit(
language_name,
@ -171,6 +185,7 @@ class Runtime {
.split('\n')
.map(line => line.split('=', 2))
.forEach(([key, val]) => {
val = val.replace_all(this.pkgdir, '/runtime');
this._env_vars[key.trim()] = val.trim();
});
}

View File

@ -23,20 +23,20 @@ fetch_packages(){
mkdir build
# Start a piston container
docker run \
--privileged \
-v "$PWD/build":'/piston/packages' \
--tmpfs /piston/jobs \
-dit \
-p $port:2000 \
--name builder_piston_instance \
ghcr.io/engineer-man/piston
# Ensure the CLI is installed
cd ../cli
npm i
cd -
# Evalulate the specfile
../cli/index.js -u "http://127.0.0.1:$port" ppman spec $1
../cli/index.js -u "http://127.0.0.1:$port" ppman spec $1
}
build_container(){
@ -61,4 +61,4 @@ fetch_packages $SPEC_FILE
build_container $TAG
echo "Start your custom piston container with"
echo "$ docker run --tmpfs /piston/jobs -dit -p 2000:2000 $TAG"
echo "$ docker run --privileged -dit -p 2000:2000 $TAG"

View File

@ -4,8 +4,7 @@ services:
api:
build: api
container_name: piston_api
cap_add:
- CAP_SYS_ADMIN
privileged: true
restart: always
ports:
- 2000:2000
@ -13,8 +12,6 @@ services:
- ./data/piston/packages:/piston/packages
environment:
- PISTON_REPO_URL=http://repo:8000/index
tmpfs:
- /piston/jobs:exec,uid=1000,gid=1000,mode=711
repo: # Local testing of packages
build: repo

View File

@ -5,10 +5,10 @@ services:
image: ghcr.io/engineer-man/piston
container_name: piston_api
restart: always
privileged: true
ports:
- 2000:2000
volumes:
- ./data/piston/packages:/piston/packages
tmpfs:
- /piston/jobs:exec,uid=1000,gid=1000,mode=711
- /tmp:exec

View File

@ -135,8 +135,21 @@ key:
default: 3000
```
The maximum time that is allowed to be taken by a stage in milliseconds.
Use -1 for unlimited time.
The maximum time that is allowed to be taken by a stage in milliseconds. This is the wall-time of the stage. The time that the CPU does not spend working on the stage (e.g, due to context switches or IO) is counted.
## Compile/Run CPU-Time
```yaml
key:
- PISTON_COMPILE_CPU_TIME
default: 10000
key:
- PISTON_RUN_CPU_TIME
default: 3000
```
The maximum CPU-time that is allowed to be consumed by a stage in milliseconds. The time that the CPU does not spend working on the stage (e.g, IO and context switches) is not counted. This option is typically used in algorithm contests.
## Compile/Run memory limits
@ -178,7 +191,7 @@ default: {}
```
Per-language overrides/exceptions for the each of `max_process_count`, `max_open_files`, `max_file_size`,
`compile_memory_limit`, `run_memory_limit`, `compile_timeout`, `run_timeout`, `output_max_size`. Defined as follows:
`compile_memory_limit`, `run_memory_limit`, `compile_timeout`, `run_timeout`, `compile_cpu_time`, `run_cpu_time`, `output_max_size`. Defined as follows:
```
PISTON_LIMIT_OVERRIDES={"c++":{"max_process_count":128}}

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# optimizing for small programs
rename 's/$/\.zig/' "$@" # Add .zig extension
zig build-exe -O ReleaseSafe --color off --cache-dir . --global-cache-dir . --name out *.zig
#!/usr/bin/env bash
# optimizing for small programs
rename 's/$/\.zig/' "$@" # Add .zig extension
zig build-exe -O ReleaseSafe --color off --cache-dir . --global-cache-dir . --name out *.zig

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash
# compiler path
export PATH=$PWD/bin:$PATH
#!/usr/bin/env bash
# compiler path
export PATH=$PWD/bin:$PATH

View File

@ -1,8 +1,9 @@
{
"language": "zig",
"version": "0.10.1",
"aliases": [],
"limit_overrides": {
"compile_timeout": 15000
}
}
{
"language": "zig",
"version": "0.10.1",
"aliases": [],
"limit_overrides": {
"compile_timeout": 15000,
"compile_cpu_time": 15000
}
}

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash
shift # Filename is only used in compile step, so we can take it out here
./out "$@"
#!/usr/bin/env bash
shift # Filename is only used in compile step, so we can take it out here
./out "$@"

View File

@ -1,6 +1,6 @@
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("OK\n", .{});
}
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("OK\n", .{});
}

View File

@ -3,6 +3,7 @@
"version": "0.8.0",
"aliases": ["zig"],
"limit_overrides": {
"compile_timeout": 15000
"compile_timeout": 15000,
"compile_cpu_time": 15000
}
}

View File

@ -3,6 +3,7 @@
"version": "0.9.1",
"aliases": ["zig"],
"limit_overrides": {
"compile_timeout": 15000
"compile_timeout": 15000,
"compile_cpu_time": 15000
}
}

View File

@ -104,7 +104,8 @@ POST https://emkc.org/api/v2/piston/execute
- Docker
- Docker Compose
- Node JS (>= 13, preferably >= 15)
- Node JS (>= 15)
- cgroup v2 enabled, and cgroup v1 disabled
### After system dependencies are installed, clone this repository:
@ -135,8 +136,8 @@ The API will now be online with no language runtimes installed. To install runti
```sh
docker run \
--privileged \
-v $PWD:'/piston' \
--tmpfs /piston/jobs \
-dit \
-p 2000:2000 \
--name piston_api \
@ -245,8 +246,10 @@ This endpoint requests execution of some arbitrary code.
- `files[].encoding` (_optional_) The encoding scheme used for the file content. One of `base64`, `hex` or `utf8`. Defaults to `utf8`.
- `stdin` (_optional_) The text to pass as stdin to the program. Must be a string or left out. Defaults to blank string.
- `args` (_optional_) The arguments to pass to the program. Must be an array or left out. Defaults to `[]`.
- `compile_timeout` (_optional_) The maximum time allowed for the compile stage to finish before bailing out in milliseconds. Must be a number or left out. Defaults to `10000` (10 seconds).
- `run_timeout` (_optional_) The maximum time allowed for the run stage to finish before bailing out in milliseconds. Must be a number or left out. Defaults to `3000` (3 seconds).
- `compile_timeout` (_optional_) The maximum wall-time allowed for the compile stage to finish before bailing out in milliseconds. Must be a number or left out. Defaults to `10000` (10 seconds).
- `run_timeout` (_optional_) The maximum wall-time allowed for the run stage to finish before bailing out in milliseconds. Must be a number or left out. Defaults to `3000` (3 seconds).
- `compile_cpu_time` (_optional_) The maximum CPU-time allowed for the compile stage to finish before bailing out in milliseconds. Must be a number or left out. Defaults to `10000` (10 seconds).
- `run_cpu_time` (_optional_) The maximum CPU-time allowed for the run stage to finish before bailing out in milliseconds. Must be a number or left out. Defaults to `3000` (3 seconds).
- `compile_memory_limit` (_optional_) The maximum amount of memory the compile stage is allowed to use in bytes. Must be a number or left out. Defaults to `-1` (no limit)
- `run_memory_limit` (_optional_) The maximum amount of memory the run stage is allowed to use in bytes. Must be a number or left out. Defaults to `-1` (no limit)
@ -264,6 +267,8 @@ This endpoint requests execution of some arbitrary code.
"args": ["1", "2", "3"],
"compile_timeout": 10000,
"run_timeout": 3000,
"compile_cpu_time": 10000,
"run_cpu_time": 3000,
"compile_memory_limit": -1,
"run_memory_limit": -1
}
@ -273,7 +278,12 @@ A typical response upon successful execution will contain 1 or 2 keys `run` and
`compile` will only be present if the language requested requires a compile stage.
Each of these keys has an identical structure, containing both a `stdout` and `stderr` key, which is a string containing the text outputted during the stage into each buffer.
It also contains the `code` and `signal` which was returned from each process.
It also contains the `code` and `signal` which was returned from each process. It also includes a nullable human-readable `message` which is a description of why a stage has failed and a two-letter `status` that is either:
- `RE` for runtime error
- `SG` for dying on a signal
- `TO` for timeout (either via `timeout` or `cpu_time`)
- `XX` for internal error
```json
HTTP/1.1 200 OK
@ -287,7 +297,12 @@ Content-Type: application/json
"stderr": "",
"output": "[\n '/piston/packages/node/15.10.0/bin/node',\n '/piston/jobs/9501b09d-0105-496b-b61a-e5148cf66384/my_cool_code.js',\n '1',\n '2',\n '3'\n]\n",
"code": 0,
"signal": null
"signal": null,
"message": null,
"status": null,
"cpu_time": 8,
"wall_time": 154,
"memory": 1160000
}
}
```