Import webserver and get it running
This commit is contained in:
parent
967cf770c2
commit
d54d0fb5bb
|
@ -4,3 +4,4 @@
|
|||
**/.terraform
|
||||
**/build
|
||||
**/node_modules
|
||||
**/out
|
||||
|
|
|
@ -3,3 +3,4 @@
|
|||
.terraform
|
||||
build
|
||||
node_modules
|
||||
out
|
||||
|
|
47
Makefile
47
Makefile
|
@ -21,13 +21,13 @@ help:
|
|||
sed -E 's/[.]PHONY: */ make /' | \
|
||||
sed -E 's/[#]## *(.+)/\n (\1)\n/'
|
||||
|
||||
### Build things locally
|
||||
### Build artifacts locally
|
||||
|
||||
.PHONY: image
|
||||
image:
|
||||
@: $${I}
|
||||
ifeq ($(I),composite)
|
||||
node src/build-composite-image.js
|
||||
node tools/build-composite-image.js
|
||||
else
|
||||
docker build . -f docker/$(I)/Dockerfile -t riju:$(I) --pull
|
||||
endif
|
||||
|
@ -47,7 +47,7 @@ pkg:
|
|||
cd $(BUILD)/src && pkg="$(PWD)/$(BUILD)/pkg" ../build.bash
|
||||
fakeroot dpkg-deb --build $(BUILD)/pkg $(BUILD)/$(DEB)
|
||||
|
||||
### Run things inside Docker
|
||||
### Manipulate artifacts inside Docker
|
||||
|
||||
VOLUME_MOUNT ?= $(PWD)
|
||||
|
||||
|
@ -57,7 +57,7 @@ shell:
|
|||
ifeq ($(I),admin)
|
||||
docker run -it --rm --hostname $(I) -v $(VOLUME_MOUNT):/src -v /var/run/docker.sock:/var/run/docker.sock -v $(HOME)/.aws:/var/riju/.aws:ro -e AWS_REGION -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e VOLUME_MOUNT=$(VOLUME_MOUNT) --network host riju:$(I)
|
||||
else
|
||||
docker run -it --rm --hostname $(I) -v $(VOLUME_MOUNT):/src riju:$(I)
|
||||
docker run -it --rm --hostname $(I) -v $(VOLUME_MOUNT):/src -p 127.0.0.1:6119:6119 -p 127.0.0.1:6120:6120 riju:$(I)
|
||||
endif
|
||||
|
||||
.PHONY: install
|
||||
|
@ -66,7 +66,42 @@ install:
|
|||
[[ -z "$$(ls -A /var/lib/apt/lists)" ]] && sudo apt update
|
||||
sudo apt reinstall -y ./$(BUILD)/$(DEB)
|
||||
|
||||
### Fetch things from registries
|
||||
### Build and run application code
|
||||
|
||||
.PHONY: frontend
|
||||
frontend:
|
||||
npx webpack --mode=production
|
||||
|
||||
.PHONY: frontend-dev
|
||||
frontend-dev:
|
||||
npx webpack --mode=development --watch
|
||||
|
||||
.PHONY: system
|
||||
system:
|
||||
./system/compile.bash
|
||||
|
||||
.PHONY: system-dev
|
||||
system-dev:
|
||||
watchexec -w system/src -n ./system/compile.bash
|
||||
|
||||
.PHONY: server
|
||||
server:
|
||||
node backend/server.js
|
||||
|
||||
.PHONY: server-dev
|
||||
server-dev:
|
||||
watchexec -w backend -r -n node backend/server.js
|
||||
|
||||
.PHONY: build
|
||||
build: frontend system
|
||||
|
||||
.PHONY: dev
|
||||
dev:
|
||||
make -j2 frontend-dev system-dev server-dev
|
||||
|
||||
### Run application code
|
||||
|
||||
### Fetch artifacts from registries
|
||||
|
||||
.PHONY: pull
|
||||
pull:
|
||||
|
@ -80,7 +115,7 @@ download:
|
|||
mkdir -p $(BUILD)
|
||||
aws s3 cp $(S3_DEB) $(BUILD)/$(DEB)
|
||||
|
||||
### Publish things to registries
|
||||
### Publish artifacts to registries
|
||||
|
||||
.PHONY: push
|
||||
push:
|
||||
|
|
|
@ -0,0 +1,436 @@
|
|||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import pty from "node-pty";
|
||||
import PQueue from "p-queue";
|
||||
import rpc from "vscode-jsonrpc";
|
||||
import { v4 as getUUID } from "uuid";
|
||||
|
||||
import { langs } from "./langs.js";
|
||||
import { borrowUser } from "./users.js";
|
||||
import * as util from "./util.js";
|
||||
import { bash } from "./util.js";
|
||||
|
||||
const allSessions = new Set();
|
||||
|
||||
export class Session {
|
||||
get homedir() {
|
||||
return `/tmp/riju/${this.uuid}`;
|
||||
}
|
||||
|
||||
get config() {
|
||||
return langs[this.lang];
|
||||
}
|
||||
|
||||
get uid() {
|
||||
return this.uidInfo.uid;
|
||||
}
|
||||
|
||||
returnUID = async () => {
|
||||
this.uidInfo && (await this.uidInfo.returnUID());
|
||||
};
|
||||
|
||||
get context() {
|
||||
return { uid: this.uid, uuid: this.uuid };
|
||||
}
|
||||
|
||||
log = (msg) => this.logPrimitive(`[${this.uuid}] ${msg}`);
|
||||
|
||||
constructor(ws, lang, log) {
|
||||
this.ws = ws;
|
||||
this.uuid = getUUID();
|
||||
this.lang = lang;
|
||||
this.tearingDown = false;
|
||||
this.uidInfo = null;
|
||||
this.term = null;
|
||||
this.lsp = null;
|
||||
this.daemon = null;
|
||||
this.formatter = null;
|
||||
this.logPrimitive = log;
|
||||
this.msgQueue = new PQueue({ concurrency: 1 });
|
||||
this.log(`Creating session, language ${this.lang}`);
|
||||
}
|
||||
|
||||
run = async (args, options) => {
|
||||
return await util.run(args, this.log, options);
|
||||
};
|
||||
|
||||
privilegedSetup = () => util.privilegedSetup(this.context);
|
||||
privilegedSpawn = (args) => util.privilegedSpawn(this.context, args);
|
||||
privilegedUseradd = () => util.privilegedUseradd(this.uid);
|
||||
privilegedTeardown = () => util.privilegedTeardown(this.context);
|
||||
|
||||
setup = async () => {
|
||||
try {
|
||||
allSessions.add(this);
|
||||
const { uid, returnUID } = await borrowUser(this.log);
|
||||
this.uidInfo = { uid, returnUID };
|
||||
this.log(`Borrowed uid ${this.uid}`);
|
||||
await this.run(this.privilegedSetup());
|
||||
if (this.config.setup) {
|
||||
await this.run(this.privilegedSpawn(bash(this.config.setup)));
|
||||
}
|
||||
await this.runCode();
|
||||
if (this.config.daemon) {
|
||||
const daemonArgs = this.privilegedSpawn(bash(this.config.daemon));
|
||||
const daemonProc = spawn(daemonArgs[0], daemonArgs.slice(1));
|
||||
this.daemon = {
|
||||
proc: daemonProc,
|
||||
};
|
||||
for (const stream of [daemonProc.stdout, daemonProc.stderr]) {
|
||||
stream.on("data", (data) =>
|
||||
this.send({
|
||||
event: "serviceLog",
|
||||
service: "daemon",
|
||||
output: data.toString("utf8"),
|
||||
})
|
||||
);
|
||||
daemonProc.on("close", (code, signal) =>
|
||||
this.send({
|
||||
event: "serviceFailed",
|
||||
service: "daemon",
|
||||
error: `Exited with status ${signal || code}`,
|
||||
})
|
||||
);
|
||||
daemonProc.on("error", (err) =>
|
||||
this.send({
|
||||
event: "serviceFailed",
|
||||
service: "daemon",
|
||||
error: `${err}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (this.config.lsp) {
|
||||
if (this.config.lsp.setup) {
|
||||
await this.run(this.privilegedSpawn(bash(this.config.lsp.setup)));
|
||||
}
|
||||
const lspArgs = this.privilegedSpawn(bash(this.config.lsp.start));
|
||||
const lspProc = spawn(lspArgs[0], lspArgs.slice(1));
|
||||
this.lsp = {
|
||||
proc: lspProc,
|
||||
reader: new rpc.StreamMessageReader(lspProc.stdout),
|
||||
writer: new rpc.StreamMessageWriter(lspProc.stdin),
|
||||
};
|
||||
this.lsp.reader.listen((data) => {
|
||||
this.send({ event: "lspOutput", output: data });
|
||||
});
|
||||
lspProc.stderr.on("data", (data) =>
|
||||
this.send({
|
||||
event: "serviceLog",
|
||||
service: "lsp",
|
||||
output: data.toString("utf8"),
|
||||
})
|
||||
);
|
||||
lspProc.on("close", (code, signal) =>
|
||||
this.send({
|
||||
event: "serviceFailed",
|
||||
service: "lsp",
|
||||
error: `Exited with status ${signal || code}`,
|
||||
})
|
||||
);
|
||||
lspProc.on("error", (err) =>
|
||||
this.send({ event: "serviceFailed", service: "lsp", error: `${err}` })
|
||||
);
|
||||
this.send({ event: "lspStarted", root: this.homedir });
|
||||
}
|
||||
this.ws.on("message", (msg) =>
|
||||
this.msgQueue.add(() => this.receive(msg))
|
||||
);
|
||||
this.ws.on("close", async () => {
|
||||
await this.teardown();
|
||||
});
|
||||
this.ws.on("error", async (err) => {
|
||||
this.log(`Websocket error: ${err}`);
|
||||
await this.teardown();
|
||||
});
|
||||
} catch (err) {
|
||||
this.log(`Error while setting up environment`);
|
||||
console.log(err);
|
||||
this.sendError(err);
|
||||
await this.teardown();
|
||||
}
|
||||
};
|
||||
|
||||
send = async (msg) => {
|
||||
try {
|
||||
if (this.tearingDown) {
|
||||
return;
|
||||
}
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
} catch (err) {
|
||||
this.log(`Failed to send websocket message: ${err}`);
|
||||
console.log(err);
|
||||
await this.teardown();
|
||||
}
|
||||
};
|
||||
|
||||
sendError = async (err) => {
|
||||
await this.send({ event: "terminalClear" });
|
||||
await this.send({
|
||||
event: "terminalOutput",
|
||||
output: `Riju encountered an unexpected error: ${err}
|
||||
\r
|
||||
\rYou may want to save your code and refresh the page.
|
||||
`,
|
||||
});
|
||||
};
|
||||
|
||||
logBadMessage = (msg) => {
|
||||
this.log(`Got malformed message from client: ${JSON.stringify(msg)}`);
|
||||
};
|
||||
|
||||
receive = async (event) => {
|
||||
try {
|
||||
if (this.tearingDown) {
|
||||
return;
|
||||
}
|
||||
let msg;
|
||||
n;
|
||||
try {
|
||||
msg = JSON.parse(event);
|
||||
} catch (err) {
|
||||
this.log(`Failed to parse message from client: ${event}`);
|
||||
return;
|
||||
}
|
||||
switch (msg && msg.event) {
|
||||
case "terminalInput":
|
||||
if (typeof msg.input !== "string") {
|
||||
this.logBadMessage(msg);
|
||||
break;
|
||||
}
|
||||
if (!this.term) {
|
||||
this.log("terminalInput ignored because term is null");
|
||||
break;
|
||||
}
|
||||
this.term.pty.write(msg.input);
|
||||
break;
|
||||
case "runCode":
|
||||
if (typeof msg.code !== "string") {
|
||||
this.logBadMessage(msg);
|
||||
break;
|
||||
}
|
||||
await this.runCode(msg.code);
|
||||
break;
|
||||
case "formatCode":
|
||||
if (typeof msg.code !== "string") {
|
||||
this.logBadMessage(msg);
|
||||
break;
|
||||
}
|
||||
await this.formatCode(msg.code);
|
||||
break;
|
||||
case "lspInput":
|
||||
if (typeof msg.input !== "object" || !msg) {
|
||||
this.logBadMessage(msg);
|
||||
break;
|
||||
}
|
||||
if (!this.lsp) {
|
||||
this.log(`lspInput ignored because lsp is null`);
|
||||
break;
|
||||
}
|
||||
this.lsp.writer.write(msg.input);
|
||||
break;
|
||||
case "ensure":
|
||||
if (!this.config.ensure) {
|
||||
this.log(`ensure ignored because of missing configuration`);
|
||||
break;
|
||||
}
|
||||
await this.ensure(this.config.ensure);
|
||||
break;
|
||||
default:
|
||||
this.logBadMessage(msg);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
this.log(`Error while handling message from client`);
|
||||
console.log(err);
|
||||
this.sendError(err);
|
||||
}
|
||||
};
|
||||
|
||||
writeCode = async (code) => {
|
||||
if (this.config.main.includes("/")) {
|
||||
await this.run(
|
||||
this.privilegedSpawn([
|
||||
"mkdir",
|
||||
"-p",
|
||||
path.dirname(`${this.homedir}/${this.config.main}`),
|
||||
])
|
||||
);
|
||||
}
|
||||
await this.run(
|
||||
this.privilegedSpawn([
|
||||
"sh",
|
||||
"-c",
|
||||
`cat > ${path.resolve(this.homedir, this.config.main)}`,
|
||||
]),
|
||||
{ input: code }
|
||||
);
|
||||
};
|
||||
|
||||
runCode = async (code) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
repl,
|
||||
main,
|
||||
suffix,
|
||||
createEmpty,
|
||||
compile,
|
||||
run,
|
||||
template,
|
||||
} = this.config;
|
||||
if (this.term) {
|
||||
const pid = this.term.pty.pid;
|
||||
const args = this.privilegedSpawn(
|
||||
bash(`kill -SIGTERM ${pid}; sleep 1; kill -SIGKILL ${pid}`)
|
||||
);
|
||||
spawn(args[0], args.slice(1));
|
||||
// Signal to terminalOutput message generator using closure.
|
||||
this.term.live = false;
|
||||
this.term = null;
|
||||
}
|
||||
this.send({ event: "terminalClear" });
|
||||
let cmdline;
|
||||
if (code) {
|
||||
cmdline = run;
|
||||
if (compile) {
|
||||
cmdline = `( ${compile} ) && ( ${run} )`;
|
||||
}
|
||||
} else if (repl) {
|
||||
cmdline = repl;
|
||||
} else {
|
||||
cmdline = `echo '${name} has no REPL, press Run to see it in action'`;
|
||||
}
|
||||
if (code === undefined) {
|
||||
code = createEmpty !== undefined ? createEmpty : template;
|
||||
}
|
||||
if (code && suffix) {
|
||||
code += suffix;
|
||||
}
|
||||
await this.writeCode(code);
|
||||
const termArgs = this.privilegedSpawn(bash(cmdline));
|
||||
const term = {
|
||||
pty: pty.spawn(termArgs[0], termArgs.slice(1), {
|
||||
name: "xterm-color",
|
||||
}),
|
||||
live: true,
|
||||
};
|
||||
this.term = term;
|
||||
this.term.pty.on("data", (data) => {
|
||||
// Capture term in closure so that we don't keep sending output
|
||||
// from the old pty even after it's been killed (see ghci).
|
||||
if (term.live) {
|
||||
this.send({ event: "terminalOutput", output: data });
|
||||
}
|
||||
});
|
||||
this.term.pty.on("exit", (code, signal) => {
|
||||
if (term.live) {
|
||||
this.send({
|
||||
event: "serviceFailed",
|
||||
service: "terminal",
|
||||
error: `Exited with status ${signal || code}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this.log(`Error while running user code`);
|
||||
console.log(err);
|
||||
this.sendError(err);
|
||||
}
|
||||
};
|
||||
|
||||
formatCode = async (code) => {
|
||||
try {
|
||||
if (!this.config.format) {
|
||||
this.log("formatCode ignored because format is null");
|
||||
return;
|
||||
}
|
||||
if (this.formatter) {
|
||||
const pid = this.formatter.proc.pid;
|
||||
const args = this.privilegedSpawn(
|
||||
bash(`kill -SIGTERM ${pid}; sleep 1; kill -SIGKILL ${pid}`)
|
||||
);
|
||||
spawn(args[0], args.slice(1));
|
||||
this.formatter.live = false;
|
||||
this.formatter = null;
|
||||
}
|
||||
const args = this.privilegedSpawn(bash(this.config.format.run));
|
||||
const formatter = {
|
||||
proc: spawn(args[0], args.slice(1)),
|
||||
live: true,
|
||||
input: code,
|
||||
output: "",
|
||||
};
|
||||
formatter.proc.stdin.end(code);
|
||||
formatter.proc.stdout.on("data", (data) => {
|
||||
if (!formatter.live) return;
|
||||
formatter.output += data.toString("utf8");
|
||||
});
|
||||
formatter.proc.stderr.on("data", (data) => {
|
||||
if (!formatter.live) return;
|
||||
this.send({
|
||||
event: "serviceLog",
|
||||
service: "formatter",
|
||||
output: data.toString("utf8"),
|
||||
});
|
||||
});
|
||||
formatter.proc.on("close", (code, signal) => {
|
||||
if (!formatter.live) return;
|
||||
if (code === 0) {
|
||||
this.send({
|
||||
event: "formattedCode",
|
||||
code: formatter.output,
|
||||
originalCode: formatter.input,
|
||||
});
|
||||
} else {
|
||||
this.send({
|
||||
event: "serviceFailed",
|
||||
service: "formatter",
|
||||
error: `Exited with status ${signal || code}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
formatter.proc.on("error", (err) => {
|
||||
if (!formatter.live) return;
|
||||
this.send({
|
||||
event: "serviceFailed",
|
||||
service: "formatter",
|
||||
error: `${err}`,
|
||||
});
|
||||
});
|
||||
this.formatter = formatter;
|
||||
} catch (err) {
|
||||
this.log(`Error while running code formatter`);
|
||||
console.log(err);
|
||||
this.sendError(err);
|
||||
}
|
||||
};
|
||||
|
||||
ensure = async (cmd) => {
|
||||
const code = await this.run(this.privilegedSpawn(bash(cmd)), {
|
||||
check: false,
|
||||
});
|
||||
this.send({ event: "ensured", code });
|
||||
};
|
||||
|
||||
teardown = async () => {
|
||||
try {
|
||||
if (this.tearingDown) {
|
||||
return;
|
||||
}
|
||||
this.log(`Tearing down session`);
|
||||
this.tearingDown = true;
|
||||
allSessions.delete(this);
|
||||
if (this.uidInfo) {
|
||||
await this.run(this.privilegedTeardown());
|
||||
await this.returnUID();
|
||||
}
|
||||
this.ws.terminate();
|
||||
} catch (err) {
|
||||
this.log(`Error during teardown`);
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import process from "process";
|
||||
|
||||
export const PRIVILEGED = process.env.RIJU_PRIVILEGED ? true : false;
|
|
@ -0,0 +1,2 @@
|
|||
// TODO
|
||||
export const langs = {};
|
|
@ -0,0 +1,110 @@
|
|||
import child_process from "child_process";
|
||||
import process from "process";
|
||||
|
||||
import readline from "historic-readline";
|
||||
import { quote } from "shell-quote";
|
||||
import rpc from "vscode-jsonrpc";
|
||||
|
||||
import { langs } from "./langs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function printUsage() {
|
||||
console.log(`usage: yarn lsp-repl (LANG | CMDLINE...)`);
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (["-h", "-help", "--help", "help"].includes(args[0])) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let cmdline;
|
||||
if (args.length === 1 && langs[args[0]] && langs[args[0]].lsp) {
|
||||
cmdline = ["bash", "-c", langs[args[0]].lsp.start];
|
||||
} else {
|
||||
cmdline = args;
|
||||
}
|
||||
|
||||
console.error(quote(cmdline));
|
||||
const proc = child_process.spawn(cmdline[0], cmdline.slice(1));
|
||||
|
||||
proc.stderr.on("data", (data) => process.stderr.write(data));
|
||||
proc.on("close", (code, signal) => {
|
||||
if (code) {
|
||||
console.error(`Language server exited with code ${code}`);
|
||||
process.exit(code);
|
||||
} else {
|
||||
console.error(`Language server exited due to signal ${signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
console.error(`Failed to start language server: ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const reader = new rpc.StreamMessageReader(proc.stdout);
|
||||
const writer = new rpc.StreamMessageWriter(proc.stdin);
|
||||
|
||||
reader.listen((data) => {
|
||||
console.log("<<< " + JSON.stringify(data) + "\n");
|
||||
});
|
||||
|
||||
// https://stackoverflow.com/a/10608048/3538165
|
||||
function fixStdoutFor(cli) {
|
||||
var oldStdout = process.stdout;
|
||||
var newStdout = Object.create(oldStdout);
|
||||
newStdout.write = function () {
|
||||
cli.output.write("\x1b[2K\r");
|
||||
var result = oldStdout.write.apply(
|
||||
this,
|
||||
Array.prototype.slice.call(arguments)
|
||||
);
|
||||
cli._refreshLine();
|
||||
return result;
|
||||
};
|
||||
process.__defineGetter__("stdout", function () {
|
||||
return newStdout;
|
||||
});
|
||||
}
|
||||
|
||||
readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
path: ".lsp-repl-history",
|
||||
next: (cli) => {
|
||||
fixStdoutFor(cli);
|
||||
cli.setPrompt(">>> ");
|
||||
cli.on("line", (line) => {
|
||||
if (line) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(line);
|
||||
} catch (err) {
|
||||
console.error(`Invalid JSON: ${err}`);
|
||||
cli.prompt();
|
||||
return;
|
||||
}
|
||||
console.log();
|
||||
writer.write(data);
|
||||
}
|
||||
cli.prompt();
|
||||
});
|
||||
cli.on("SIGINT", () => {
|
||||
console.error("^C");
|
||||
cli.write("", { ctrl: true, name: "u" });
|
||||
cli.prompt();
|
||||
});
|
||||
cli.on("close", () => {
|
||||
console.error();
|
||||
process.exit(0);
|
||||
});
|
||||
console.log();
|
||||
cli.prompt();
|
||||
},
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
|
||||
import { v4 as getUUID } from "uuid";
|
||||
|
||||
import { langs } from "./langs";
|
||||
import { MIN_UID, MAX_UID, borrowUser, ignoreUsers } from "./users";
|
||||
import {
|
||||
privilegedSetup,
|
||||
privilegedSpawn,
|
||||
privilegedTeardown,
|
||||
run,
|
||||
} from "./util";
|
||||
|
||||
function die(msg) {
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dirs = await new Promise((resolve, reject) =>
|
||||
fs.readdir("/tmp/riju", (err, dirs) => (err ? reject(err) : resolve(dirs)))
|
||||
);
|
||||
const uids = (
|
||||
await Promise.all(
|
||||
dirs.map(
|
||||
(dir) =>
|
||||
new Promise((resolve, reject) =>
|
||||
fs.stat(`/tmp/riju/${dir}`, (err, stat) =>
|
||||
err ? reject(err) : resolve(stat.uid)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).filter((uid) => uid >= MIN_UID && uid < MAX_UID);
|
||||
await ignoreUsers(uids, log);
|
||||
const uuid = getUUID();
|
||||
const { uid, returnUID } = await borrowUser(log);
|
||||
await run(privilegedSetup({ uid, uuid }), log);
|
||||
const args = privilegedSpawn({ uid, uuid }, ["bash"]);
|
||||
const proc = spawn(args[0], args.slice(1), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
proc.on("error", reject);
|
||||
proc.on("close", resolve);
|
||||
});
|
||||
await run(privilegedTeardown({ uid, uuid }), log);
|
||||
await returnUID();
|
||||
}
|
||||
|
||||
main().catch(die);
|
|
@ -0,0 +1,113 @@
|
|||
import http from "http";
|
||||
import https from "https";
|
||||
import path from "path";
|
||||
|
||||
import express from "express";
|
||||
import ws from "express-ws";
|
||||
import _ from "lodash";
|
||||
|
||||
import * as api from "./api.js";
|
||||
import { langs } from "./langs.js";
|
||||
|
||||
const host = process.env.HOST || "localhost";
|
||||
const port = parseInt(process.env.PORT || "") || 6119;
|
||||
const tlsPort = parseInt(process.env.TLS_PORT || "") || 6120;
|
||||
const useTLS = process.env.TLS ? true : false;
|
||||
const analyticsEnabled = process.env.ANALYTICS ? true : false;
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set("query parser", (qs) => new URLSearchParams(qs));
|
||||
app.set("view engine", "ejs");
|
||||
|
||||
app.get("/", (_, res) => {
|
||||
res.render(path.resolve("frontend/pages/index"), {
|
||||
langs,
|
||||
analyticsEnabled,
|
||||
});
|
||||
});
|
||||
for (const [lang, { aliases }] of Object.entries(langs)) {
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
app.get(`/${_.escapeRegExp(alias)}`, (_, res) => {
|
||||
res.redirect(301, `/${lang}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
app.get("/:lang", (req, res) => {
|
||||
const lang = req.params.lang;
|
||||
const lowered = lang.toLowerCase();
|
||||
if (lowered !== lang) {
|
||||
res.redirect(301, `/${lowered}`);
|
||||
} else if (langs[lang]) {
|
||||
res.render(path.resolve("frontend/pages/app"), {
|
||||
config: { id: lang, ...langs[lang] },
|
||||
analyticsEnabled,
|
||||
});
|
||||
} else {
|
||||
res.send(`No such language: ${lang}`);
|
||||
}
|
||||
});
|
||||
app.use("/css", express.static("frontend/styles"));
|
||||
app.use("/js", express.static("frontend/out"));
|
||||
|
||||
function addWebsocket(baseApp, httpsServer) {
|
||||
const app = ws(baseApp, httpsServer).app;
|
||||
app.ws("/api/v1/ws", (ws, req) => {
|
||||
const lang = req.query.get("lang");
|
||||
if (!lang) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
event: "error",
|
||||
errorMessage: "No language specified",
|
||||
})
|
||||
);
|
||||
ws.close();
|
||||
} else if (!langs[lang]) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
event: "error",
|
||||
errorMessage: `No such language: ${lang}`,
|
||||
})
|
||||
);
|
||||
ws.close();
|
||||
} else {
|
||||
new api.Session(ws, lang, console.log).setup();
|
||||
}
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
if (useTLS) {
|
||||
const httpsServer = https.createServer(
|
||||
{
|
||||
key: Buffer.from(process.env.TLS_PRIVATE_KEY || "", "base64").toString(
|
||||
"ascii"
|
||||
),
|
||||
cert: Buffer.from(process.env.TLS_CERTIFICATE || "", "base64").toString(
|
||||
"ascii"
|
||||
),
|
||||
},
|
||||
app
|
||||
);
|
||||
addWebsocket(app, httpsServer);
|
||||
httpsServer.listen(tlsPort, host, () =>
|
||||
console.log(`Listening on https://${host}:${tlsPort}`)
|
||||
);
|
||||
const server = http
|
||||
.createServer((req, res) => {
|
||||
res.writeHead(301, {
|
||||
Location: "https://" + req.headers["host"] + req.url,
|
||||
});
|
||||
res.end();
|
||||
})
|
||||
.listen(port, host, () =>
|
||||
console.log(`Listening on http://${host}:${port}`)
|
||||
);
|
||||
} else {
|
||||
addWebsocket(app, undefined);
|
||||
const server = app.listen(port, host, () =>
|
||||
console.log(`Listening on http://${host}:${port}`)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,746 @@
|
|||
import fs from "fs";
|
||||
import process from "process";
|
||||
import { promisify } from "util";
|
||||
|
||||
import _ from "lodash";
|
||||
import { Moment } from "moment";
|
||||
import moment from "moment";
|
||||
import PQueue from "p-queue";
|
||||
import rimraf from "rimraf";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { v4 as getUUID } from "uuid";
|
||||
|
||||
import api from "./api";
|
||||
import { LangConfig, langs } from "./langs";
|
||||
|
||||
function parseIntOr(thing, def) {
|
||||
const num = parseInt(thing);
|
||||
return Number.isNaN(num) ? def : num;
|
||||
}
|
||||
|
||||
const TIMEOUT_FACTOR = parseIntOr(process.env.TIMEOUT_FACTOR, 1);
|
||||
const CONCURRENCY = parseIntOr(process.env.CONCURRENCY, 2);
|
||||
const BASE_TIMEOUT_SECS = 5;
|
||||
|
||||
function findPosition(str, idx) {
|
||||
const lines = str.substring(0, idx).split("\n");
|
||||
const line = lines.length - 1;
|
||||
const character = lines[lines.length - 1].length;
|
||||
return { line, character };
|
||||
}
|
||||
|
||||
async function sendInput(send, input) {
|
||||
for (const line of input.split("\n")) {
|
||||
if (line === "EOF") {
|
||||
send({ event: "terminalInput", input: "\u0004" });
|
||||
} else if (line.startsWith("DELAY:")) {
|
||||
const delay = parseFloat(line.replace(/DELAY: */, ""));
|
||||
if (Number.isNaN(delay)) continue;
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, delay * 1000 * TIMEOUT_FACTOR)
|
||||
);
|
||||
} else {
|
||||
send({ event: "terminalInput", input: line + "\r" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Test {
|
||||
get config() {
|
||||
return langs[this.lang];
|
||||
}
|
||||
|
||||
record = (msg) => {
|
||||
const dur = moment.duration(moment().diff(this.startTime));
|
||||
this.messages.push({ time: dur.asSeconds(), ...msg });
|
||||
};
|
||||
|
||||
send = (msg) => {
|
||||
this.ws.onMessage(JSON.stringify(msg));
|
||||
this.record(msg);
|
||||
this.handledMessages += 1;
|
||||
};
|
||||
|
||||
constructor(lang, type) {
|
||||
this.lang = lang;
|
||||
this.type = type;
|
||||
this.messages = [];
|
||||
this.timedOut = false;
|
||||
this.handledMessages = 0;
|
||||
this.handleUpdate = () => {};
|
||||
this.startTime = null;
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
getLog = (opts) => {
|
||||
opts = opts || {};
|
||||
return this.messages
|
||||
.map((msg) => JSON.stringify(msg, null, opts.pretty && 2))
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
run = async () => {
|
||||
if ((this.config.skip || []).includes(this.type)) {
|
||||
return "skipped";
|
||||
}
|
||||
this.startTime = moment();
|
||||
let session = null;
|
||||
let timeout = null;
|
||||
try {
|
||||
const that = this;
|
||||
this.ws = {
|
||||
on: function (type, handler) {
|
||||
switch (type) {
|
||||
case "message":
|
||||
this.onMessage = handler;
|
||||
for (const msg of this.messageQueue) {
|
||||
this.onMessage(msg);
|
||||
}
|
||||
this.messageQueue = [];
|
||||
break;
|
||||
case "close":
|
||||
case "error":
|
||||
// No need to clean up, we'll call teardown() explicitly.
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unexpected websocket handler type: ${type}`);
|
||||
}
|
||||
},
|
||||
onMessage: function (msg) {
|
||||
this.messageQueue.push(msg);
|
||||
},
|
||||
messageQueue: [],
|
||||
send: function (data) {
|
||||
that.record(JSON.parse(data));
|
||||
that.handleUpdate();
|
||||
},
|
||||
terminate: function () {},
|
||||
};
|
||||
session = new api.Session(this.ws, this.lang, (msg) => {
|
||||
this.record({ event: "serverLog", message: msg });
|
||||
});
|
||||
timeout = setTimeout(() => {
|
||||
this.timedOut = true;
|
||||
this.handleUpdate();
|
||||
}, (this.config.timeout || BASE_TIMEOUT_SECS) * 1000 * TIMEOUT_FACTOR);
|
||||
await session.setup();
|
||||
switch (this.type) {
|
||||
case "ensure":
|
||||
await this.testEnsure();
|
||||
break;
|
||||
case "run":
|
||||
await this.testRun();
|
||||
break;
|
||||
case "repl":
|
||||
await this.testRepl();
|
||||
break;
|
||||
case "runrepl":
|
||||
await this.testRunRepl();
|
||||
break;
|
||||
case "scope":
|
||||
await this.testScope();
|
||||
break;
|
||||
case "format":
|
||||
await this.testFormat();
|
||||
break;
|
||||
case "lsp":
|
||||
await this.testLsp();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected test type: ${this.type}`);
|
||||
}
|
||||
} finally {
|
||||
this.ws = null;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (session) {
|
||||
await session.teardown();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wait = async (desc, handler) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
this.handleUpdate = () => {
|
||||
if (this.timedOut) {
|
||||
reject(new Error(`timeout while waiting for ${desc}`));
|
||||
} else {
|
||||
while (this.handledMessages < this.messages.length) {
|
||||
const msg = this.messages[this.handledMessages];
|
||||
const result = handler(msg);
|
||||
if (![undefined, null, false].includes(result)) {
|
||||
resolve(result);
|
||||
}
|
||||
this.handledMessages += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
this.handleUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
waitForOutput = async (pattern, maxLength) => {
|
||||
pattern = pattern.replace(/\n/g, "\r\n");
|
||||
let output = "";
|
||||
return await this.wait(`output ${JSON.stringify(pattern)}`, (msg) => {
|
||||
const prevLength = output.length;
|
||||
if (msg.event === "terminalOutput") {
|
||||
// Applying stripAnsi here is wrong because escape sequences
|
||||
// could be split across multiple messages. Who cares?
|
||||
output += stripAnsi(msg.output);
|
||||
}
|
||||
if (typeof maxLength === "number") {
|
||||
return (
|
||||
output
|
||||
.substring(prevLength - maxLength)
|
||||
.match(new RegExp(pattern)) !== null
|
||||
);
|
||||
} else {
|
||||
return output.indexOf(pattern, prevLength - pattern.length) != -1;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
testEnsure = async () => {
|
||||
this.send({ event: "ensure" });
|
||||
const code = await this.wait("ensure response", (msg) => {
|
||||
if (msg.event === "ensured") {
|
||||
return msg.code;
|
||||
}
|
||||
});
|
||||
if (code !== 0) {
|
||||
throw new Error(`ensure failed with code ${code}`);
|
||||
}
|
||||
};
|
||||
testRun = async () => {
|
||||
const pattern = this.config.hello || "Hello, world!";
|
||||
this.send({ event: "runCode", code: this.config.template });
|
||||
if (this.config.helloInput !== undefined) {
|
||||
sendInput(this.send, this.config.helloInput);
|
||||
}
|
||||
await this.waitForOutput(pattern, this.config.helloMaxLength);
|
||||
};
|
||||
testRepl = async () => {
|
||||
const input = this.config.input || "123 * 234";
|
||||
const output = this.config.output || "28782";
|
||||
sendInput(this.send, input);
|
||||
await this.waitForOutput(output);
|
||||
};
|
||||
testRunRepl = async () => {
|
||||
const input = this.config.runReplInput || this.config.input || "123 * 234";
|
||||
const output = this.config.runReplOutput || this.config.output || "28782";
|
||||
this.send({ event: "runCode", code: this.config.template });
|
||||
sendInput(this.send, input);
|
||||
await this.waitForOutput(output);
|
||||
};
|
||||
testScope = async () => {
|
||||
const code = this.config.scope.code;
|
||||
const after = this.config.scope.after;
|
||||
const input = this.config.scope.input || "x";
|
||||
const output = this.config.scope.output || "28782";
|
||||
let allCode = this.config.template;
|
||||
if (!allCode.endsWith("\n")) {
|
||||
allCode += "\n";
|
||||
}
|
||||
if (after) {
|
||||
allCode = allCode.replace(after + "\n", after + "\n" + code + "\n");
|
||||
} else {
|
||||
allCode = allCode + code + "\n";
|
||||
}
|
||||
this.send({ event: "runCode", code: allCode });
|
||||
sendInput(this.send, input);
|
||||
await this.waitForOutput(output);
|
||||
};
|
||||
testFormat = async () => {
|
||||
const input = this.config.format.input;
|
||||
const output = this.config.format.output || this.config.template;
|
||||
this.send({ event: "formatCode", code: input });
|
||||
const result = await this.wait("formatter response", (msg) => {
|
||||
if (msg.event === "formattedCode") {
|
||||
return msg.code;
|
||||
}
|
||||
});
|
||||
if (output !== result) {
|
||||
throw new Error("formatted code did not match");
|
||||
}
|
||||
};
|
||||
testLsp = async () => {
|
||||
const insertedCode = this.config.lsp.code;
|
||||
const after = this.config.lsp.after;
|
||||
const item = this.config.lsp.item;
|
||||
const idx = after
|
||||
? this.config.template.indexOf(after) + after.length
|
||||
: this.config.template.length;
|
||||
const code =
|
||||
this.config.template.slice(0, idx) +
|
||||
insertedCode +
|
||||
this.config.template.slice(idx);
|
||||
const root = await this.wait("lspStarted message", (msg) => {
|
||||
if (msg.event === "lspStarted") {
|
||||
return msg.root;
|
||||
}
|
||||
});
|
||||
this.send({
|
||||
event: "lspInput",
|
||||
input: {
|
||||
jsonrpc: "2.0",
|
||||
id: "0d75333a-47d8-4da8-8030-c81d7bd9eed7",
|
||||
method: "initialize",
|
||||
params: {
|
||||
processId: null,
|
||||
clientInfo: { name: "vscode" },
|
||||
rootPath: root,
|
||||
rootUri: `file://${root}`,
|
||||
capabilities: {
|
||||
workspace: {
|
||||
applyEdit: true,
|
||||
workspaceEdit: {
|
||||
documentChanges: true,
|
||||
resourceOperations: ["create", "rename", "delete"],
|
||||
failureHandling: "textOnlyTransactional",
|
||||
},
|
||||
didChangeConfiguration: { dynamicRegistration: true },
|
||||
didChangeWatchedFiles: { dynamicRegistration: true },
|
||||
symbol: {
|
||||
dynamicRegistration: true,
|
||||
symbolKind: {
|
||||
valueSet: [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
],
|
||||
},
|
||||
},
|
||||
executeCommand: { dynamicRegistration: true },
|
||||
configuration: true,
|
||||
workspaceFolders: true,
|
||||
},
|
||||
textDocument: {
|
||||
publishDiagnostics: {
|
||||
relatedInformation: true,
|
||||
versionSupport: false,
|
||||
tagSupport: { valueSet: [1, 2] },
|
||||
},
|
||||
synchronization: {
|
||||
dynamicRegistration: true,
|
||||
willSave: true,
|
||||
willSaveWaitUntil: true,
|
||||
didSave: true,
|
||||
},
|
||||
completion: {
|
||||
dynamicRegistration: true,
|
||||
contextSupport: true,
|
||||
completionItem: {
|
||||
snippetSupport: true,
|
||||
commitCharactersSupport: true,
|
||||
documentationFormat: ["markdown", "plaintext"],
|
||||
deprecatedSupport: true,
|
||||
preselectSupport: true,
|
||||
tagSupport: { valueSet: [1] },
|
||||
},
|
||||
completionItemKind: {
|
||||
valueSet: [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
],
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
dynamicRegistration: true,
|
||||
contentFormat: ["markdown", "plaintext"],
|
||||
},
|
||||
signatureHelp: {
|
||||
dynamicRegistration: true,
|
||||
signatureInformation: {
|
||||
documentationFormat: ["markdown", "plaintext"],
|
||||
parameterInformation: { labelOffsetSupport: true },
|
||||
},
|
||||
contextSupport: true,
|
||||
},
|
||||
definition: { dynamicRegistration: true, linkSupport: true },
|
||||
references: { dynamicRegistration: true },
|
||||
documentHighlight: { dynamicRegistration: true },
|
||||
documentSymbol: {
|
||||
dynamicRegistration: true,
|
||||
symbolKind: {
|
||||
valueSet: [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
],
|
||||
},
|
||||
hierarchicalDocumentSymbolSupport: true,
|
||||
},
|
||||
codeAction: {
|
||||
dynamicRegistration: true,
|
||||
isPreferredSupport: true,
|
||||
codeActionLiteralSupport: {
|
||||
codeActionKind: {
|
||||
valueSet: [
|
||||
"",
|
||||
"quickfix",
|
||||
"refactor",
|
||||
"refactor.extract",
|
||||
"refactor.inline",
|
||||
"refactor.rewrite",
|
||||
"source",
|
||||
"source.organizeImports",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
codeLens: { dynamicRegistration: true },
|
||||
formatting: { dynamicRegistration: true },
|
||||
rangeFormatting: { dynamicRegistration: true },
|
||||
onTypeFormatting: { dynamicRegistration: true },
|
||||
rename: { dynamicRegistration: true, prepareSupport: true },
|
||||
documentLink: { dynamicRegistration: true, tooltipSupport: true },
|
||||
typeDefinition: { dynamicRegistration: true, linkSupport: true },
|
||||
implementation: { dynamicRegistration: true, linkSupport: true },
|
||||
colorProvider: { dynamicRegistration: true },
|
||||
foldingRange: {
|
||||
dynamicRegistration: true,
|
||||
rangeLimit: 5000,
|
||||
lineFoldingOnly: true,
|
||||
},
|
||||
declaration: { dynamicRegistration: true, linkSupport: true },
|
||||
},
|
||||
},
|
||||
initializationOptions: this.config.lsp.init || {},
|
||||
trace: "off",
|
||||
workspaceFolders: [
|
||||
{
|
||||
uri: `file://${root}`,
|
||||
name: `file://${root}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.wait("response to lsp initialize", (msg) => {
|
||||
return (
|
||||
msg.event === "lspOutput" &&
|
||||
msg.output.id === "0d75333a-47d8-4da8-8030-c81d7bd9eed7"
|
||||
);
|
||||
});
|
||||
this.send({
|
||||
event: "lspInput",
|
||||
input: { jsonrpc: "2.0", method: "initialized", params: {} },
|
||||
});
|
||||
this.send({
|
||||
event: "lspInput",
|
||||
input: {
|
||||
jsonrpc: "2.0",
|
||||
method: "textDocument/didOpen",
|
||||
params: {
|
||||
textDocument: {
|
||||
uri: `file://${root}/${this.config.main}`,
|
||||
languageId:
|
||||
this.config.lsp.lang || this.config.monacoLang || "plaintext",
|
||||
version: 1,
|
||||
text: code,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
this.send({
|
||||
event: "lspInput",
|
||||
input: {
|
||||
jsonrpc: "2.0",
|
||||
id: "ecdb8a55-f755-4553-ae8e-91d6ebbc2045",
|
||||
method: "textDocument/completion",
|
||||
params: {
|
||||
textDocument: {
|
||||
uri: `file://${root}/${this.config.main}`,
|
||||
},
|
||||
position: findPosition(code, idx + insertedCode.length),
|
||||
context: { triggerKind: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const items = await this.wait(
|
||||
"response to lsp completion request",
|
||||
(msg) => {
|
||||
if (msg.event === "lspOutput") {
|
||||
if (msg.output.method === "workspace/configuration") {
|
||||
this.send({
|
||||
event: "lspInput",
|
||||
input: {
|
||||
jsonrpc: "2.0",
|
||||
id: msg.output.id,
|
||||
result: Array(msg.output.params.items.length).fill(
|
||||
this.config.lsp.config == undefined
|
||||
? this.config.lsp.config
|
||||
: {}
|
||||
),
|
||||
},
|
||||
});
|
||||
} else if (msg.output.id === "ecdb8a55-f755-4553-ae8e-91d6ebbc2045") {
|
||||
return msg.output.result.items || msg.output.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!(items && items.filter(({ label }) => label === item).length > 0)) {
|
||||
throw new Error("completion item did not appear");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function lint(lang) {
|
||||
const config = langs[lang];
|
||||
if (!config.template.endsWith("\n")) {
|
||||
throw new Error("template is missing a trailing newline");
|
||||
}
|
||||
// These can be removed when the types are adjusted to make these
|
||||
// situations impossible.
|
||||
if (
|
||||
config.format &&
|
||||
!config.format.input &&
|
||||
!(config.skip || []).includes("format")
|
||||
) {
|
||||
throw new Error("formatter is missing test");
|
||||
}
|
||||
if (
|
||||
config.lsp &&
|
||||
!(config.lsp.code && config.lsp.item) &&
|
||||
!(config.skip || []).includes("lsp")
|
||||
) {
|
||||
throw new Error("LSP is missing test");
|
||||
}
|
||||
}
|
||||
|
||||
const testTypes = {
|
||||
ensure: {
|
||||
pred: ({ ensure }) => (ensure ? true : false),
|
||||
},
|
||||
run: { pred: (config) => true },
|
||||
repl: {
|
||||
pred: ({ repl }) => (repl ? true : false),
|
||||
},
|
||||
runrepl: {
|
||||
pred: ({ repl }) => (repl ? true : false),
|
||||
},
|
||||
scope: {
|
||||
pred: ({ scope }) => (scope ? true : false),
|
||||
},
|
||||
format: {
|
||||
pred: ({ format }) => (format ? true : false),
|
||||
},
|
||||
lsp: { pred: ({ lsp }) => (lsp ? true : false) },
|
||||
};
|
||||
|
||||
function getTestList() {
|
||||
const tests = [];
|
||||
for (const [id, cfg] of Object.entries(langs)) {
|
||||
for (const [type, { pred }] of Object.entries(testTypes)) {
|
||||
if (pred(cfg)) {
|
||||
tests.push({ lang: id, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
async function writeLog(lang, type, result, log) {
|
||||
log = `${result.toUpperCase()}: ${lang}/${type}\n` + log;
|
||||
await promisify(fs.mkdir)(`tests/${lang}`, { recursive: true });
|
||||
await promisify(fs.writeFile)(`tests/${lang}/${type}.log`, log);
|
||||
await promisify(fs.mkdir)(`tests-run/${lang}`, { recursive: true });
|
||||
await promisify(fs.symlink)(
|
||||
`../../tests/${lang}/${type}.log`,
|
||||
`tests-run/${lang}/${type}.log`
|
||||
);
|
||||
await promisify(fs.mkdir)(`tests-${result}/${lang}`, { recursive: true });
|
||||
await promisify(fs.symlink)(
|
||||
`../../tests/${lang}/${type}.log`,
|
||||
`tests-${result}/${lang}/${type}.log`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let tests = getTestList();
|
||||
const args = process.argv.slice(2);
|
||||
for (const arg of args) {
|
||||
tests = tests.filter(
|
||||
({ lang, type }) =>
|
||||
arg
|
||||
.split(",")
|
||||
.filter((arg) =>
|
||||
[lang, type].concat(langs[lang].aliases || []).includes(arg)
|
||||
).length > 0
|
||||
);
|
||||
}
|
||||
if (tests.length === 0) {
|
||||
console.error("no tests selected");
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`Running ${tests.length} test${tests.length !== 1 ? "s" : ""}`);
|
||||
const lintSeen = new Set();
|
||||
let lintPassed = new Set();
|
||||
let lintFailed = new Map();
|
||||
for (const { lang } of tests) {
|
||||
if (!lintSeen.has(lang)) {
|
||||
lintSeen.add(lang);
|
||||
try {
|
||||
lint(lang);
|
||||
lintPassed.add(lang);
|
||||
} catch (err) {
|
||||
lintFailed.set(lang, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lintFailed.size > 0) {
|
||||
console.error(
|
||||
`Language${lintFailed.size !== 1 ? "s" : ""} failed linting:`
|
||||
);
|
||||
console.error(
|
||||
Array.from(lintFailed)
|
||||
.map(([lang, err]) => ` - ${lang} (${err})`)
|
||||
.join("\n")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await promisify(rimraf)("tests-run");
|
||||
await promisify(rimraf)("tests-passed");
|
||||
await promisify(rimraf)("tests-skipped");
|
||||
await promisify(rimraf)("tests-failed");
|
||||
const queue = new PQueue({ concurrency: CONCURRENCY });
|
||||
let passed = new Set();
|
||||
let skipped = new Set();
|
||||
let failed = new Map();
|
||||
for (const { lang, type } of tests) {
|
||||
queue.add(async () => {
|
||||
const test = new Test(lang, type);
|
||||
let err;
|
||||
try {
|
||||
err = await test.run();
|
||||
} catch (error) {
|
||||
err = error;
|
||||
}
|
||||
if (err === "skipped") {
|
||||
skipped.add({ lang, type });
|
||||
console.error(`SKIPPED: ${lang}/${type}`);
|
||||
await writeLog(lang, type, "skipped", "");
|
||||
} else if (!err) {
|
||||
passed.add({ lang, type });
|
||||
console.error(`PASSED: ${lang}/${type}`);
|
||||
await writeLog(
|
||||
lang,
|
||||
type,
|
||||
"passed",
|
||||
test.getLog({ pretty: true }) + "\n"
|
||||
);
|
||||
} else {
|
||||
failed.set({ lang, type }, err);
|
||||
console.error(`FAILED: ${lang}/${type}`);
|
||||
console.error(test.getLog());
|
||||
console.error(err);
|
||||
await writeLog(
|
||||
lang,
|
||||
type,
|
||||
"failed",
|
||||
test.getLog({ pretty: true }) +
|
||||
"\n" +
|
||||
(err.stack ? err.stack + "\n" : err ? `${err}` : "")
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
await queue.onIdle();
|
||||
console.error();
|
||||
console.error(
|
||||
"================================================================================"
|
||||
);
|
||||
console.error();
|
||||
if (passed.size > 0) {
|
||||
console.error(`${passed.size} test${passed.size !== 1 ? "s" : ""} PASSED`);
|
||||
}
|
||||
if (skipped.size > 0) {
|
||||
console.error(
|
||||
`${skipped.size} test${skipped.size !== 1 ? "s" : ""} SKIPPED`
|
||||
);
|
||||
}
|
||||
if (failed.size > 0) {
|
||||
console.error(`${failed.size} test${failed.size !== 1 ? "s" : ""} FAILED`);
|
||||
_.sortBy(Array.from(failed), [
|
||||
([{ lang }, _]) => lang,
|
||||
([{ type }, _]) => type,
|
||||
]).forEach(([{ lang, type }, err]) =>
|
||||
console.error(` - ${lang}/${type} (${err})`)
|
||||
);
|
||||
}
|
||||
process.exit(failed.size > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
|
@ -0,0 +1,96 @@
|
|||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
|
||||
import AsyncLock from "async-lock";
|
||||
import _ from "lodash";
|
||||
import parsePasswd from "parse-passwd";
|
||||
|
||||
import { PRIVILEGED } from "./config.js";
|
||||
import { privilegedUseradd, run } from "./util.js";
|
||||
|
||||
// Keep in sync with system/src/riju-system-privileged.c
|
||||
export const MIN_UID = 2000;
|
||||
export const MAX_UID = 65000;
|
||||
|
||||
const CUR_UID = os.userInfo().uid;
|
||||
|
||||
let availIds = null;
|
||||
let nextId = null;
|
||||
let lock = new AsyncLock();
|
||||
|
||||
async function readExistingUsers(log) {
|
||||
availIds = parsePasswd(
|
||||
await new Promise((resolve, reject) =>
|
||||
fs.readFile("/etc/passwd", "utf-8", (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter(({ username }) => username.startsWith("riju"))
|
||||
.map(({ uid }) => parseInt(uid))
|
||||
.filter((uid) => !isNaN(uid) && uid >= MIN_UID && uid < MAX_UID)
|
||||
.reverse();
|
||||
nextId = (_.max(availIds) || MIN_UID - 1) + 1;
|
||||
log(`Found ${availIds.length} existing users, next is riju${nextId}`);
|
||||
}
|
||||
|
||||
async function createUser(log) {
|
||||
if (nextId >= MAX_UID) {
|
||||
throw new Error("too many users");
|
||||
}
|
||||
const uid = nextId;
|
||||
await run(privilegedUseradd(uid), log);
|
||||
log(`Created new user with ID ${uid}`);
|
||||
nextId += 1;
|
||||
return uid;
|
||||
}
|
||||
|
||||
export async function ignoreUsers(uids, log) {
|
||||
await lock.acquire("key", async () => {
|
||||
if (availIds === null || nextId === null) {
|
||||
await readExistingUsers(log);
|
||||
}
|
||||
const uidSet = new Set(uids);
|
||||
if (uidSet.size > 0) {
|
||||
const plural = uidSet.size !== 1 ? "s" : "";
|
||||
log(
|
||||
`Ignoring user${plural} from open session${plural}: ${Array.from(uidSet)
|
||||
.sort()
|
||||
.map((uid) => `riju${uid}`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
availIds = availIds.filter((uid) => !uidSet.has(uid));
|
||||
});
|
||||
}
|
||||
|
||||
export async function borrowUser(log) {
|
||||
if (!PRIVILEGED) {
|
||||
return { uid: CUR_UID, returnUID: async () => {} };
|
||||
} else {
|
||||
return await lock.acquire("key", async () => {
|
||||
if (availIds === null || nextId === null) {
|
||||
await readExistingUsers(log);
|
||||
}
|
||||
let uid;
|
||||
if (availIds.length > 0) {
|
||||
uid = availIds.pop();
|
||||
} else {
|
||||
uid = await createUser(log);
|
||||
}
|
||||
return {
|
||||
uid,
|
||||
returnUID: async () => {
|
||||
await lock.acquire("key", () => {
|
||||
availIds.push(uid);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
import { spawn, spawnSync } from "child_process";
|
||||
import os from "os";
|
||||
import process from "process";
|
||||
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
import { PRIVILEGED } from "./config.js";
|
||||
import { MIN_UID, MAX_UID } from "./users.js";
|
||||
|
||||
export const rijuSystemPrivileged = "system/out/riju-system-privileged";
|
||||
|
||||
const rubyVersion = (() => {
|
||||
try {
|
||||
return spawnSync("ruby", ["-e", "puts RUBY_VERSION"])
|
||||
.stdout.toString()
|
||||
.trim();
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
function getEnv({ uid, uuid }) {
|
||||
const cwd = `/tmp/riju/${uuid}`;
|
||||
const path = [
|
||||
rubyVersion && `${cwd}/.gem/ruby/${rubyVersion}/bin`,
|
||||
`${cwd}/.local/bin`,
|
||||
`${cwd}/node_modules/.bin`,
|
||||
`/usr/local/sbin`,
|
||||
`/usr/local/bin`,
|
||||
`/usr/sbin`,
|
||||
`/usr/bin`,
|
||||
`/bin`,
|
||||
].filter((x) => x);
|
||||
const username =
|
||||
uid >= MIN_UID && uid < MAX_UID ? `riju${uid}` : os.userInfo().username;
|
||||
return {
|
||||
HOME: cwd,
|
||||
HOSTNAME: "riju",
|
||||
LANG: process.env.LANG || "",
|
||||
LC_ALL: process.env.LC_ALL || "",
|
||||
LOGNAME: username,
|
||||
PATH: PRIVILEGED ? path.join(":") : process.env.PATH || "",
|
||||
PWD: cwd,
|
||||
SHELL: "/usr/bin/bash",
|
||||
TERM: "xterm-256color",
|
||||
TMPDIR: `${cwd}`,
|
||||
USER: username,
|
||||
USERNAME: username,
|
||||
};
|
||||
}
|
||||
|
||||
function getEnvString(ctx) {
|
||||
return Object.entries(getEnv(ctx))
|
||||
.map(([key, val]) => `${key}=${quote([val])}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export async function run(args, log, options) {
|
||||
options = options || {};
|
||||
const input = options.input;
|
||||
const check = options.check === undefined ? true : options.check;
|
||||
delete options.input;
|
||||
delete options.check;
|
||||
const proc = spawn(args[0], args.slice(1), options);
|
||||
if (typeof input === "string") {
|
||||
proc.stdin.end(input);
|
||||
}
|
||||
let output = "";
|
||||
proc.stdout.on("data", (data) => {
|
||||
output += `${data}`;
|
||||
});
|
||||
proc.stderr.on("data", (data) => {
|
||||
output += `${data}`;
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
proc.on("error", reject);
|
||||
proc.on("close", (code, signal) => {
|
||||
output = output.trim();
|
||||
if (output) {
|
||||
log(`Output from ${args[0]}:\n` + output);
|
||||
}
|
||||
if (code === 0 || !check) {
|
||||
resolve(code);
|
||||
} else {
|
||||
reject(`command ${args[0]} failed with error code ${signal || code}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function privilegedUseradd(uid) {
|
||||
return [rijuSystemPrivileged, "useradd", `${uid}`];
|
||||
}
|
||||
|
||||
export function privilegedSetup({ uid, uuid }) {
|
||||
return [rijuSystemPrivileged, "setup", `${uid}`, uuid];
|
||||
}
|
||||
|
||||
export function privilegedSpawn(ctx, args) {
|
||||
const { uid, uuid } = ctx;
|
||||
return [
|
||||
rijuSystemPrivileged,
|
||||
"spawn",
|
||||
`${uid}`,
|
||||
uuid,
|
||||
"sh",
|
||||
"-c",
|
||||
`exec env -i ${getEnvString(ctx)} "$@"`,
|
||||
"--",
|
||||
].concat(args);
|
||||
}
|
||||
|
||||
export function privilegedTeardown({ uid, uuid }) {
|
||||
return [rijuSystemPrivileged, "teardown", `${uid}`, uuid];
|
||||
}
|
||||
|
||||
export function bash(cmdline) {
|
||||
if (!cmdline.match(/[;|&(){}=]/)) {
|
||||
// Reduce number of subshells we generate, if we're just running a
|
||||
// single command (no shell logic).
|
||||
cmdline = "exec " + cmdline;
|
||||
}
|
||||
return ["bash", "-c", cmdline];
|
||||
}
|
|
@ -4,6 +4,6 @@ COPY docker/admin/install.bash /tmp/
|
|||
RUN /tmp/install.bash
|
||||
|
||||
WORKDIR /src
|
||||
COPY docker/admin/pid1.bash /usr/local/sbin/
|
||||
ENTRYPOINT ["/usr/local/sbin/pid1.bash"]
|
||||
COPY docker/shared/my_init docker/admin/pid1.bash /usr/local/sbin/
|
||||
ENTRYPOINT ["/usr/local/sbin/my_init", "/usr/local/sbin/pid1.bash"]
|
||||
CMD ["bash"]
|
||||
|
|
|
@ -27,7 +27,7 @@ deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) sta
|
|||
EOF
|
||||
|
||||
apt-get update
|
||||
apt-get install -y docker-ce-cli less make man nodejs sudo unzip wget yarn
|
||||
apt-get install -y docker-ce-cli less make man nodejs sudo tmux unzip wget yarn
|
||||
|
||||
wget https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -O awscli.zip
|
||||
unzip awscli.zip
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
set -euo pipefail
|
||||
|
||||
groupadd -g "$(stat -c %g "$PWD")" -o -p '!' -r riju
|
||||
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -m -N -l -s /usr/bin/bash -G sudo riju
|
||||
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -p '!' -m -N -l -s /usr/bin/bash -G sudo riju
|
||||
|
||||
runuser -u riju -- touch /home/riju/.sudo_as_admin_successful
|
||||
runuser -u riju -- ln -sT /var/riju/.aws /home/riju/.aws
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
FROM riju:compile AS compile
|
||||
FROM riju:composite
|
||||
|
||||
RUN useradd -p '!' -m -l -s /usr/bin/bash riju
|
||||
|
||||
WORKDIR /src
|
||||
ENV RIJU_PRIVILEGED=1
|
||||
|
||||
COPY --chown=riju:riju --from=compile . ./
|
||||
RUN chown root:riju system/out/*-privileged && chmod a=,g=rx,u=rwxs system/out/*-privileged
|
||||
|
||||
USER riju
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
apt-get update
|
||||
apt-get dist-upgrade -y
|
||||
apt-get install -y curl gnupg lsb-release
|
||||
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add -
|
||||
curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
|
||||
|
||||
ubuntu_ver="$(lsb_release -rs)"
|
||||
ubuntu_name="$(lsb_release -cs)"
|
||||
|
||||
node_repo="$(curl -sS https://deb.nodesource.com/setup_current.x | grep NODEREPO= | grep -Eo 'node_[0-9]+\.x' | head -n1)"
|
||||
|
||||
tee -a /etc/apt/sources.list.d/custom.list >/dev/null <<EOF
|
||||
deb [arch=amd64] https://deb.nodesource.com/${node_repo} ${ubuntu_name} main
|
||||
deb [arch=amd64] https://dl.yarnpkg.com/debian/ stable main
|
||||
EOF
|
||||
|
||||
apt-get update
|
||||
apt-get install -y make nodejs yarn
|
||||
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
rm "$0"
|
|
@ -0,0 +1,21 @@
|
|||
FROM ubuntu:rolling AS build
|
||||
|
||||
COPY docker/runtime/install.bash /tmp/
|
||||
RUN /tmp/install.bash
|
||||
|
||||
WORKDIR /src
|
||||
ENV RIJU_PRIVILEGED=1
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
|
||||
COPY webpack.config.cjs ./
|
||||
COPY frontend/src ./frontend/src/
|
||||
RUN make frontend
|
||||
|
||||
COPY system ./system/
|
||||
RUN make system
|
||||
|
||||
COPY frontend/pages ./frontend/pages/
|
||||
COPY frontend/styles ./frontend/styles/
|
||||
COPY backend ./backend/
|
|
@ -4,6 +4,6 @@ COPY docker/packaging/install.bash /tmp/
|
|||
RUN /tmp/install.bash
|
||||
|
||||
WORKDIR /src
|
||||
COPY docker/packaging/pid1.bash /usr/local/sbin/
|
||||
ENTRYPOINT ["/usr/local/sbin/pid1.bash"]
|
||||
COPY docker/shared/my_init docker/packaging/pid1.bash /usr/local/sbin/
|
||||
ENTRYPOINT ["/usr/local/sbin/my_init", "/usr/local/sbin/pid1.bash"]
|
||||
CMD ["bash"]
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
set -euo pipefail
|
||||
|
||||
groupadd -g "$(stat -c %g "$PWD")" -o -p '!' -r riju
|
||||
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -m -N -l -s /usr/bin/bash -G sudo riju
|
||||
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -p '!' -m -N -l -s /usr/bin/bash -G sudo riju
|
||||
|
||||
runuser -u riju touch /home/riju/.sudo_as_admin_successful
|
||||
runuser -u riju -- yarn install
|
||||
|
|
|
@ -4,6 +4,8 @@ COPY docker/runtime/install.bash /tmp/
|
|||
RUN /tmp/install.bash
|
||||
|
||||
WORKDIR /src
|
||||
COPY docker/runtime/pid1.bash /usr/local/sbin/
|
||||
ENTRYPOINT ["/usr/local/sbin/pid1.bash"]
|
||||
COPY docker/shared/my_init docker/runtime/pid1.bash /usr/local/sbin/
|
||||
ENTRYPOINT ["/usr/local/sbin/my_init", "/usr/local/sbin/pid1.bash"]
|
||||
CMD ["bash"]
|
||||
EXPOSE 6119
|
||||
EXPOSE 6120
|
||||
|
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
set -euxo pipefail
|
||||
|
||||
latest_release() {
|
||||
curl -sSL "https://api.github.com/repos/$1/releases/latest" | jq -r .tag_name
|
||||
}
|
||||
|
||||
pushd /tmp
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
apt-get update
|
||||
|
@ -28,7 +34,12 @@ apt-get install -y dctrl-tools
|
|||
libicu="$(grep-aptavail -wF Package 'libicu[0-9]+' -s Package -n | head -n1)"
|
||||
|
||||
apt-get update
|
||||
apt-get install -y less "${libicu}" make man nodejs sudo yarn
|
||||
apt-get install -y less clang jq "${libicu}" make man nodejs sudo tmux wget yarn
|
||||
|
||||
ver="$(latest_release watchexec/watchexec)"
|
||||
wget "https://github.com/watchexec/watchexec/releases/download/${ver}/watchexec-${ver}-x86_64-unknown-linux-gnu.deb"
|
||||
apt-get install -y ./watchexec-*.deb
|
||||
rm watchexec-*.deb
|
||||
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
@ -36,4 +47,6 @@ tee /etc/sudoers.d/90-riju >/dev/null <<"EOF"
|
|||
%sudo ALL=(ALL:ALL) NOPASSWD: ALL
|
||||
EOF
|
||||
|
||||
popd
|
||||
|
||||
rm "$0"
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
set -euo pipefail
|
||||
|
||||
groupadd -g "$(stat -c %g "$PWD")" -o -p '!' -r riju
|
||||
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -m -N -l -s /usr/bin/bash -G sudo riju
|
||||
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -p '!' -m -N -l -s /usr/bin/bash -G sudo riju
|
||||
|
||||
runuser -u riju touch /home/riju/.sudo_as_admin_successful
|
||||
runuser -u riju -- yarn install
|
||||
|
|
|
@ -0,0 +1,424 @@
|
|||
#!/usr/bin/python3 -u
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# From https://github.com/phusion/baseimage-docker/blob/5078b027ba58cce8887acb5c1add0bb8d56f5d38/image/bin/my_init
|
||||
# Copyright 2013-2015 Phusion Holding B.V. under MIT License
|
||||
# See https://github.com/phusion/baseimage-docker/blob/5078b027ba58cce8887acb5c1add0bb8d56f5d38/LICENSE.txt
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
|
||||
ENV_INIT_DIRECTORY = os.environ.get('ENV_INIT_DIRECTORY', '/etc/my_init.d')
|
||||
|
||||
KILL_PROCESS_TIMEOUT = int(os.environ.get('KILL_PROCESS_TIMEOUT', 30))
|
||||
KILL_ALL_PROCESSES_TIMEOUT = int(os.environ.get('KILL_ALL_PROCESSES_TIMEOUT', 30))
|
||||
|
||||
LOG_LEVEL_ERROR = 1
|
||||
LOG_LEVEL_WARN = 1
|
||||
LOG_LEVEL_INFO = 2
|
||||
LOG_LEVEL_DEBUG = 3
|
||||
|
||||
SHENV_NAME_WHITELIST_REGEX = re.compile('\W')
|
||||
|
||||
log_level = None
|
||||
|
||||
terminated_child_processes = {}
|
||||
|
||||
_find_unsafe = re.compile(r'[^\w@%+=:,./-]').search
|
||||
|
||||
|
||||
class AlarmException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def error(message):
|
||||
if log_level >= LOG_LEVEL_ERROR:
|
||||
sys.stderr.write("*** %s\n" % message)
|
||||
|
||||
|
||||
def warn(message):
|
||||
if log_level >= LOG_LEVEL_WARN:
|
||||
sys.stderr.write("*** %s\n" % message)
|
||||
|
||||
|
||||
def info(message):
|
||||
if log_level >= LOG_LEVEL_INFO:
|
||||
sys.stderr.write("*** %s\n" % message)
|
||||
|
||||
|
||||
def debug(message):
|
||||
if log_level >= LOG_LEVEL_DEBUG:
|
||||
sys.stderr.write("*** %s\n" % message)
|
||||
|
||||
|
||||
def ignore_signals_and_raise_keyboard_interrupt(signame):
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
raise KeyboardInterrupt(signame)
|
||||
|
||||
|
||||
def raise_alarm_exception():
|
||||
raise AlarmException('Alarm')
|
||||
|
||||
|
||||
def listdir(path):
|
||||
try:
|
||||
result = os.stat(path)
|
||||
except OSError:
|
||||
return []
|
||||
if stat.S_ISDIR(result.st_mode):
|
||||
return sorted(os.listdir(path))
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
def is_exe(path):
|
||||
try:
|
||||
return os.path.isfile(path) and os.access(path, os.X_OK)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def import_envvars(clear_existing_environment=True, override_existing_environment=True):
|
||||
if not os.path.exists("/etc/container_environment"):
|
||||
return
|
||||
new_env = {}
|
||||
for envfile in listdir("/etc/container_environment"):
|
||||
name = os.path.basename(envfile)
|
||||
with open("/etc/container_environment/" + envfile, "r") as f:
|
||||
# Text files often end with a trailing newline, which we
|
||||
# don't want to include in the env variable value. See
|
||||
# https://github.com/phusion/baseimage-docker/pull/49
|
||||
value = re.sub('\n\Z', '', f.read())
|
||||
new_env[name] = value
|
||||
if clear_existing_environment:
|
||||
os.environ.clear()
|
||||
for name, value in new_env.items():
|
||||
if override_existing_environment or name not in os.environ:
|
||||
os.environ[name] = value
|
||||
|
||||
|
||||
def export_envvars(to_dir=True):
|
||||
if not os.path.exists("/etc/container_environment"):
|
||||
return
|
||||
shell_dump = ""
|
||||
for name, value in os.environ.items():
|
||||
if name in ['HOME', 'USER', 'GROUP', 'UID', 'GID', 'SHELL']:
|
||||
continue
|
||||
if to_dir:
|
||||
with open("/etc/container_environment/" + name, "w") as f:
|
||||
f.write(value)
|
||||
shell_dump += "export " + sanitize_shenvname(name) + "=" + shquote(value) + "\n"
|
||||
with open("/etc/container_environment.sh", "w") as f:
|
||||
f.write(shell_dump)
|
||||
with open("/etc/container_environment.json", "w") as f:
|
||||
f.write(json.dumps(dict(os.environ)))
|
||||
|
||||
|
||||
def shquote(s):
|
||||
"""Return a shell-escaped version of the string *s*."""
|
||||
if not s:
|
||||
return "''"
|
||||
if _find_unsafe(s) is None:
|
||||
return s
|
||||
|
||||
# use single quotes, and put single quotes into double quotes
|
||||
# the string $'b is then quoted as '$'"'"'b'
|
||||
return "'" + s.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def sanitize_shenvname(s):
|
||||
"""Return string with [0-9a-zA-Z_] characters"""
|
||||
return re.sub(SHENV_NAME_WHITELIST_REGEX, "_", s)
|
||||
|
||||
|
||||
# Waits for the child process with the given PID, while at the same time
|
||||
# reaping any other child processes that have exited (e.g. adopted child
|
||||
# processes that have terminated).
|
||||
|
||||
def waitpid_reap_other_children(pid):
|
||||
global terminated_child_processes
|
||||
|
||||
status = terminated_child_processes.get(pid)
|
||||
if status:
|
||||
# A previous call to waitpid_reap_other_children(),
|
||||
# with an argument not equal to the current argument,
|
||||
# already waited for this process. Return the status
|
||||
# that was obtained back then.
|
||||
del terminated_child_processes[pid]
|
||||
return status
|
||||
|
||||
done = False
|
||||
status = None
|
||||
while not done:
|
||||
try:
|
||||
# https://github.com/phusion/baseimage-docker/issues/151#issuecomment-92660569
|
||||
this_pid, status = os.waitpid(pid, os.WNOHANG)
|
||||
if this_pid == 0:
|
||||
this_pid, status = os.waitpid(-1, 0)
|
||||
if this_pid == pid:
|
||||
done = True
|
||||
else:
|
||||
# Save status for later.
|
||||
terminated_child_processes[this_pid] = status
|
||||
except OSError as e:
|
||||
if e.errno == errno.ECHILD or e.errno == errno.ESRCH:
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
return status
|
||||
|
||||
|
||||
def stop_child_process(name, pid, signo=signal.SIGTERM, time_limit=KILL_PROCESS_TIMEOUT):
|
||||
info("Shutting down %s (PID %d)..." % (name, pid))
|
||||
try:
|
||||
os.kill(pid, signo)
|
||||
except OSError:
|
||||
pass
|
||||
signal.alarm(time_limit)
|
||||
try:
|
||||
try:
|
||||
waitpid_reap_other_children(pid)
|
||||
except OSError:
|
||||
pass
|
||||
except AlarmException:
|
||||
warn("%s (PID %d) did not shut down in time. Forcing it to exit." % (name, pid))
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
waitpid_reap_other_children(pid)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
signal.alarm(0)
|
||||
|
||||
|
||||
def run_command_killable(*argv):
|
||||
filename = argv[0]
|
||||
status = None
|
||||
pid = os.spawnvp(os.P_NOWAIT, filename, argv)
|
||||
try:
|
||||
status = waitpid_reap_other_children(pid)
|
||||
except BaseException:
|
||||
warn("An error occurred. Aborting.")
|
||||
stop_child_process(filename, pid)
|
||||
raise
|
||||
if status != 0:
|
||||
if status is None:
|
||||
error("%s exited with unknown status\n" % filename)
|
||||
else:
|
||||
error("%s failed with status %d\n" % (filename, os.WEXITSTATUS(status)))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_command_killable_and_import_envvars(*argv):
|
||||
run_command_killable(*argv)
|
||||
import_envvars()
|
||||
export_envvars(False)
|
||||
|
||||
|
||||
def kill_all_processes(time_limit):
|
||||
info("Killing all processes...")
|
||||
try:
|
||||
os.kill(-1, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
signal.alarm(time_limit)
|
||||
try:
|
||||
# Wait until no more child processes exist.
|
||||
done = False
|
||||
while not done:
|
||||
try:
|
||||
os.waitpid(-1, 0)
|
||||
except OSError as e:
|
||||
if e.errno == errno.ECHILD:
|
||||
done = True
|
||||
else:
|
||||
raise
|
||||
except AlarmException:
|
||||
warn("Not all processes have exited in time. Forcing them to exit.")
|
||||
try:
|
||||
os.kill(-1, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
signal.alarm(0)
|
||||
|
||||
|
||||
def run_startup_files():
|
||||
# Run ENV_INIT_DIRECTORY/*
|
||||
for name in listdir(ENV_INIT_DIRECTORY):
|
||||
filename = os.path.join(ENV_INIT_DIRECTORY, name)
|
||||
if is_exe(filename):
|
||||
info("Running %s..." % filename)
|
||||
run_command_killable_and_import_envvars(filename)
|
||||
|
||||
# Run /etc/rc.local.
|
||||
if is_exe("/etc/rc.local"):
|
||||
info("Running /etc/rc.local...")
|
||||
run_command_killable_and_import_envvars("/etc/rc.local")
|
||||
|
||||
|
||||
def run_pre_shutdown_scripts():
|
||||
debug("Running pre-shutdown scripts...")
|
||||
|
||||
# Run /etc/my_init.pre_shutdown.d/*
|
||||
for name in listdir("/etc/my_init.pre_shutdown.d"):
|
||||
filename = "/etc/my_init.pre_shutdown.d/" + name
|
||||
if is_exe(filename):
|
||||
info("Running %s..." % filename)
|
||||
run_command_killable(filename)
|
||||
|
||||
|
||||
def run_post_shutdown_scripts():
|
||||
debug("Running post-shutdown scripts...")
|
||||
|
||||
# Run /etc/my_init.post_shutdown.d/*
|
||||
for name in listdir("/etc/my_init.post_shutdown.d"):
|
||||
filename = "/etc/my_init.post_shutdown.d/" + name
|
||||
if is_exe(filename):
|
||||
info("Running %s..." % filename)
|
||||
run_command_killable(filename)
|
||||
|
||||
|
||||
def start_runit():
|
||||
info("Booting runit daemon...")
|
||||
pid = os.spawnl(os.P_NOWAIT, "/usr/bin/runsvdir", "/usr/bin/runsvdir",
|
||||
"-P", "/etc/service")
|
||||
info("Runit started as PID %d" % pid)
|
||||
return pid
|
||||
|
||||
|
||||
def wait_for_runit_or_interrupt(pid):
|
||||
status = waitpid_reap_other_children(pid)
|
||||
return (True, status)
|
||||
|
||||
|
||||
def shutdown_runit_services(quiet=False):
|
||||
if not quiet:
|
||||
debug("Begin shutting down runit services...")
|
||||
os.system("/usr/bin/sv -w %d force-stop /etc/service/* > /dev/null" % KILL_PROCESS_TIMEOUT)
|
||||
|
||||
|
||||
def wait_for_runit_services():
|
||||
debug("Waiting for runit services to exit...")
|
||||
done = False
|
||||
while not done:
|
||||
done = os.system("/usr/bin/sv status /etc/service/* | grep -q '^run:'") != 0
|
||||
if not done:
|
||||
time.sleep(0.1)
|
||||
# According to https://github.com/phusion/baseimage-docker/issues/315
|
||||
# there is a bug or race condition in Runit, causing it
|
||||
# not to shutdown services that are already being started.
|
||||
# So during shutdown we repeatedly instruct Runit to shutdown
|
||||
# services.
|
||||
shutdown_runit_services(True)
|
||||
|
||||
|
||||
def install_insecure_key():
|
||||
info("Installing insecure SSH key for user root")
|
||||
run_command_killable("/usr/sbin/enable_insecure_key")
|
||||
|
||||
|
||||
def main(args):
|
||||
import_envvars(False, False)
|
||||
export_envvars()
|
||||
|
||||
if args.enable_insecure_key:
|
||||
install_insecure_key()
|
||||
|
||||
if not args.skip_startup_files:
|
||||
run_startup_files()
|
||||
|
||||
runit_exited = False
|
||||
exit_code = None
|
||||
|
||||
if not args.skip_runit:
|
||||
runit_pid = start_runit()
|
||||
try:
|
||||
exit_status = None
|
||||
if len(args.main_command) == 0:
|
||||
runit_exited, exit_code = wait_for_runit_or_interrupt(runit_pid)
|
||||
if runit_exited:
|
||||
if exit_code is None:
|
||||
info("Runit exited with unknown status")
|
||||
exit_status = 1
|
||||
else:
|
||||
exit_status = os.WEXITSTATUS(exit_code)
|
||||
info("Runit exited with status %d" % exit_status)
|
||||
else:
|
||||
info("Running %s..." % " ".join(args.main_command))
|
||||
pid = os.spawnvp(os.P_NOWAIT, args.main_command[0], args.main_command)
|
||||
try:
|
||||
exit_code = waitpid_reap_other_children(pid)
|
||||
if exit_code is None:
|
||||
info("%s exited with unknown status." % args.main_command[0])
|
||||
exit_status = 1
|
||||
else:
|
||||
exit_status = os.WEXITSTATUS(exit_code)
|
||||
info("%s exited with status %d." % (args.main_command[0], exit_status))
|
||||
except KeyboardInterrupt:
|
||||
stop_child_process(args.main_command[0], pid)
|
||||
raise
|
||||
except BaseException:
|
||||
warn("An error occurred. Aborting.")
|
||||
stop_child_process(args.main_command[0], pid)
|
||||
raise
|
||||
sys.exit(exit_status)
|
||||
finally:
|
||||
if not args.skip_runit:
|
||||
run_pre_shutdown_scripts()
|
||||
shutdown_runit_services()
|
||||
if not runit_exited:
|
||||
stop_child_process("runit daemon", runit_pid)
|
||||
wait_for_runit_services()
|
||||
run_post_shutdown_scripts()
|
||||
|
||||
# Parse options.
|
||||
parser = argparse.ArgumentParser(description='Initialize the system.')
|
||||
parser.add_argument('main_command', metavar='MAIN_COMMAND', type=str, nargs='*',
|
||||
help='The main command to run. (default: runit)')
|
||||
parser.add_argument('--enable-insecure-key', dest='enable_insecure_key',
|
||||
action='store_const', const=True, default=False,
|
||||
help='Install the insecure SSH key')
|
||||
parser.add_argument('--skip-startup-files', dest='skip_startup_files',
|
||||
action='store_const', const=True, default=False,
|
||||
help='Skip running /etc/my_init.d/* and /etc/rc.local')
|
||||
parser.add_argument('--skip-runit', dest='skip_runit',
|
||||
action='store_const', const=True, default=False,
|
||||
help='Do not run runit services')
|
||||
parser.add_argument('--no-kill-all-on-exit', dest='kill_all_on_exit',
|
||||
action='store_const', const=False, default=True,
|
||||
help='Don\'t kill all processes on the system upon exiting')
|
||||
parser.add_argument('--quiet', dest='log_level',
|
||||
action='store_const', const=LOG_LEVEL_WARN, default=LOG_LEVEL_INFO,
|
||||
help='Only print warnings and errors')
|
||||
args = parser.parse_args()
|
||||
log_level = args.log_level
|
||||
|
||||
if args.skip_runit and len(args.main_command) == 0:
|
||||
error("When --skip-runit is given, you must also pass a main command.")
|
||||
sys.exit(1)
|
||||
|
||||
# Run main function.
|
||||
signal.signal(signal.SIGTERM, lambda signum, frame: ignore_signals_and_raise_keyboard_interrupt('SIGTERM'))
|
||||
signal.signal(signal.SIGINT, lambda signum, frame: ignore_signals_and_raise_keyboard_interrupt('SIGINT'))
|
||||
signal.signal(signal.SIGALRM, lambda signum, frame: raise_alarm_exception())
|
||||
try:
|
||||
main(args)
|
||||
except KeyboardInterrupt:
|
||||
warn("Init system aborted.")
|
||||
exit(2)
|
||||
finally:
|
||||
if args.kill_all_on_exit:
|
||||
kill_all_processes(KILL_ALL_PROCESSES_TIMEOUT)
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title><%= config.name %> - Riju</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
|
||||
integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="editor" class="column"></div>
|
||||
<div id="terminal" class="column"></div>
|
||||
<button type="button" class="btn btn-success" id="runButton">Run</button>
|
||||
<button type="button" class="btn btn-info" id="formatButton">Prettify</button>
|
||||
<a href="/" class="btn btn-secondary" id="backButton">Switch to a different language</a>
|
||||
</div>
|
||||
<script>
|
||||
window.rijuConfig = <%- JSON.stringify(config) %>;
|
||||
</script>
|
||||
<script src="/js/app.js"></script>
|
||||
<% if (analyticsEnabled) { %>
|
||||
<script src="https://cdn.usefathom.com/script.js" site="JOBZEHJE" defer></script>
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Riju</title>
|
||||
<link rel="stylesheet" href="/css/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>Riju: <i>fast</i> online playground for every programming language</h1>
|
||||
<i>Pick your favorite language to get started:</i>
|
||||
<div class="grid">
|
||||
<% for (const [id, {name}] of Object.entries(langs).sort(
|
||||
([id1, {name: name1}], [id2, {name: name2}]) => name1.toLowerCase().localeCompare(name2.toLowerCase()))) { %>
|
||||
<a href=<%= "/" + encodeURIComponent(id) %> class="language">
|
||||
<div class="language">
|
||||
<%= name %>
|
||||
</div>
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<p>
|
||||
<i>
|
||||
Created by
|
||||
<a href="https://github.com/raxod502">Radon Rosborough</a>.
|
||||
Check out the project
|
||||
<a href="https://github.com/raxod502/riju">on GitHub</a>.
|
||||
</i>
|
||||
</p>
|
||||
<% if (analyticsEnabled) { %>
|
||||
<script src="https://cdn.usefathom.com/script.js" site="JOBZEHJE" defer></script>
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,309 @@
|
|||
import monaco from "monaco-editor";
|
||||
import {
|
||||
createConnection,
|
||||
MonacoLanguageClient,
|
||||
MonacoServices,
|
||||
Services,
|
||||
} from "monaco-languageclient";
|
||||
import { Disposable } from "vscode";
|
||||
import { createMessageConnection } from "vscode-jsonrpc";
|
||||
import {
|
||||
AbstractMessageReader,
|
||||
DataCallback,
|
||||
} from "vscode-jsonrpc/lib/messageReader.js";
|
||||
import { AbstractMessageWriter } from "vscode-jsonrpc/lib/messageWriter.js";
|
||||
import { Message } from "vscode-jsonrpc/lib/messages.js";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
const DEBUG = window.location.hash === "#debug";
|
||||
const config = window.rijuConfig;
|
||||
|
||||
class RijuMessageReader extends AbstractMessageReader {
|
||||
constructor(socketSocket) {
|
||||
super();
|
||||
this.state = "initial";
|
||||
this.callback = null;
|
||||
this.messageQueue = [];
|
||||
this.socket = socket;
|
||||
this.socket.addEventListener("message", (event) => {
|
||||
this.readMessage(event.data);
|
||||
});
|
||||
}
|
||||
|
||||
listen(callback) {
|
||||
if (this.state === "initial") {
|
||||
this.state = "listening";
|
||||
this.callback = callback;
|
||||
while (this.messageQueue.length > 0) {
|
||||
this.readMessage(this.messageQueue.pop());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readMessage(rawMessage) {
|
||||
if (this.state === "initial") {
|
||||
this.messageQueue.splice(0, 0, rawMessage);
|
||||
} else if (this.state === "listening") {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(rawMessage);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
switch (message && message.event) {
|
||||
case "lspOutput":
|
||||
if (DEBUG) {
|
||||
console.log("RECEIVE LSP:", message.output);
|
||||
}
|
||||
this.callback(message.output);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RijuMessageWriter extends AbstractMessageWriter {
|
||||
constructor(socket) {
|
||||
super();
|
||||
this.socket = socket;
|
||||
}
|
||||
|
||||
write(msg) {
|
||||
switch (msg.method) {
|
||||
case "initialize":
|
||||
msg.params.processId = null;
|
||||
if (config.lsp.disableDynamicRegistration) {
|
||||
this.disableDynamicRegistration(msg);
|
||||
}
|
||||
break;
|
||||
case "textDocument/didOpen":
|
||||
if (config.lsp.lang) {
|
||||
msg.params.textDocument.languageId = config.lsp.lang;
|
||||
}
|
||||
}
|
||||
if (DEBUG) {
|
||||
console.log("SEND LSP:", msg);
|
||||
}
|
||||
this.socket.send(JSON.stringify({ event: "lspInput", input: msg }));
|
||||
}
|
||||
|
||||
disableDynamicRegistration(msg) {
|
||||
if (!msg || typeof msg !== "object") return;
|
||||
for (const [key, val] of Object.entries(msg)) {
|
||||
if (key === "dynamicRegistration" && val === true)
|
||||
msg.dynamicRegistration = false;
|
||||
this.disableDynamicRegistration(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const term = new Terminal();
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById("terminal"));
|
||||
|
||||
fitAddon.fit();
|
||||
window.addEventListener("resize", () => fitAddon.fit());
|
||||
|
||||
await new Promise((resolve) =>
|
||||
term.write("Connecting to server...", resolve)
|
||||
);
|
||||
|
||||
const initialRetryDelayMs = 200;
|
||||
let retryDelayMs = initialRetryDelayMs;
|
||||
|
||||
function sendMessage(message) {
|
||||
if (DEBUG) {
|
||||
console.log("SEND:", message);
|
||||
}
|
||||
if (socket) {
|
||||
socket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function tryConnect() {
|
||||
let clientDisposable = null;
|
||||
let servicesDisposable = null;
|
||||
const serviceLogBuffers = {};
|
||||
console.log("Connecting to server...");
|
||||
socket = new WebSocket(
|
||||
(document.location.protocol === "http:" ? "ws://" : "wss://") +
|
||||
document.location.host +
|
||||
`/api/v1/ws?lang=${encodeURIComponent(config.id)}`
|
||||
);
|
||||
socket.addEventListener("open", () => {
|
||||
console.log("Successfully connected to server");
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (err) {
|
||||
console.error("Malformed message from server:", event.data);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
DEBUG &&
|
||||
message &&
|
||||
message.event !== "lspOutput" &&
|
||||
message.event !== "serviceLog"
|
||||
) {
|
||||
console.log("RECEIVE:", message);
|
||||
}
|
||||
if (message && message.event && message.event !== "error") {
|
||||
retryDelayMs = initialRetryDelayMs;
|
||||
}
|
||||
switch (message && message.event) {
|
||||
case "terminalClear":
|
||||
term.reset();
|
||||
return;
|
||||
case "terminalOutput":
|
||||
if (typeof message.output !== "string") {
|
||||
console.error("Unexpected message from server:", message);
|
||||
return;
|
||||
}
|
||||
term.write(message.output);
|
||||
return;
|
||||
case "formattedCode":
|
||||
if (
|
||||
typeof message.code !== "string" ||
|
||||
typeof message.originalCode !== "string"
|
||||
) {
|
||||
console.error("Unexpected message from server:", message);
|
||||
return;
|
||||
}
|
||||
if (editor.getValue() === message.originalCode) {
|
||||
editor.setValue(message.code);
|
||||
}
|
||||
return;
|
||||
case "lspStarted":
|
||||
if (typeof message.root !== "string") {
|
||||
console.error("Unexpected message from server:", message);
|
||||
return;
|
||||
}
|
||||
const services = MonacoServices.create(editor, {
|
||||
rootUri: `file://${message.root}`,
|
||||
});
|
||||
servicesDisposable = Services.install(services);
|
||||
editor.setModel(
|
||||
monaco.editor.createModel(
|
||||
editor.getModel().getValue(),
|
||||
undefined,
|
||||
monaco.Uri.parse(`file://${message.root}/${config.main}`)
|
||||
)
|
||||
);
|
||||
const connection = createMessageConnection(
|
||||
new RijuMessageReader(socket),
|
||||
new RijuMessageWriter(socket)
|
||||
);
|
||||
const client = new MonacoLanguageClient({
|
||||
name: "Riju",
|
||||
clientOptions: {
|
||||
documentSelector: [{ pattern: "**" }],
|
||||
middleware: {
|
||||
workspace: {
|
||||
configuration: (params, token, configuration) => {
|
||||
return Array(configuration(params, token).length).fill(
|
||||
config.lsp.config !== undefined ? config.lsp.config : {}
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
initializationOptions: config.lsp.init || {},
|
||||
},
|
||||
connectionProvider: {
|
||||
get: (errorHandler, closeHandler) =>
|
||||
Promise.resolve(
|
||||
createConnection(connection, errorHandler, closeHandler)
|
||||
),
|
||||
},
|
||||
});
|
||||
clientDisposable = client.start();
|
||||
return;
|
||||
case "lspOutput":
|
||||
// Should be handled by RijuMessageReader
|
||||
return;
|
||||
case "serviceLog":
|
||||
if (
|
||||
typeof message.service !== "string" ||
|
||||
typeof message.output !== "string"
|
||||
) {
|
||||
console.error("Unexpected message from server:", message);
|
||||
return;
|
||||
}
|
||||
if (DEBUG) {
|
||||
let buffer = serviceLogBuffers[message.service] || "";
|
||||
buffer += message.output;
|
||||
while (buffer.includes("\n")) {
|
||||
const idx = buffer.indexOf("\n");
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
console.log(`${message.service.toUpperCase()} || ${line}`);
|
||||
}
|
||||
serviceLogBuffers[message.service] = buffer;
|
||||
}
|
||||
return;
|
||||
case "serviceCrashed":
|
||||
return;
|
||||
default:
|
||||
console.error("Unexpected message from server:", message);
|
||||
return;
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (event.wasClean) {
|
||||
console.log("Connection closed cleanly");
|
||||
} else {
|
||||
console.error("Connection died");
|
||||
}
|
||||
if (clientDisposable) {
|
||||
clientDisposable.dispose();
|
||||
clientDisposable = null;
|
||||
}
|
||||
if (servicesDisposable) {
|
||||
servicesDisposable.dispose();
|
||||
servicesDisposable = null;
|
||||
}
|
||||
scheduleConnect();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleConnect() {
|
||||
const delay = retryDelayMs * Math.random();
|
||||
console.log(`Trying to reconnect in ${Math.floor(delay)}ms`);
|
||||
setTimeout(tryConnect, delay);
|
||||
retryDelayMs *= 2;
|
||||
}
|
||||
|
||||
let socket = null;
|
||||
tryConnect();
|
||||
|
||||
term.onData((data) => sendMessage({ event: "terminalInput", input: data }));
|
||||
|
||||
const editor = monaco.editor.create(document.getElementById("editor"), {
|
||||
minimap: { enabled: false },
|
||||
scrollbar: { verticalScrollbarSize: 0 },
|
||||
});
|
||||
window.addEventListener("resize", () => editor.layout());
|
||||
editor.getModel().setValue(config.template);
|
||||
monaco.editor.setModelLanguage(
|
||||
editor.getModel(),
|
||||
config.monacoLang || "plaintext"
|
||||
);
|
||||
|
||||
document.getElementById("runButton").addEventListener("click", () => {
|
||||
sendMessage({ event: "runCode", code: editor.getValue() });
|
||||
});
|
||||
if (config.format) {
|
||||
document.getElementById("formatButton").classList.add("visible");
|
||||
document.getElementById("formatButton").addEventListener("click", () => {
|
||||
sendMessage({ event: "formatCode", code: editor.getValue() });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
|
@ -0,0 +1,45 @@
|
|||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#editor {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#terminal {
|
||||
background: black;
|
||||
}
|
||||
|
||||
#runButton {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: calc(50% + 25px);
|
||||
}
|
||||
|
||||
#formatButton {
|
||||
position: absolute;
|
||||
bottom: 25px;
|
||||
right: calc(50% + 25px);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#formatButton.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
#backButton {
|
||||
position: absolute;
|
||||
left: 25px;
|
||||
bottom: 25px;
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
div.language {
|
||||
width: 140px;
|
||||
height: 60px;
|
||||
border: solid;
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
a.language {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
23
package.json
23
package.json
|
@ -5,8 +5,31 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.12.10",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"async-lock": "^1.2.6",
|
||||
"babel-loader": "^8.2.2",
|
||||
"commander": "^6.2.1",
|
||||
"css-loader": "^5.0.1",
|
||||
"ejs": "^3.1.5",
|
||||
"express": "^4.17.1",
|
||||
"express-ws": "^4.0.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"lodash": "^4.17.20",
|
||||
"monaco-editor": "^0.21.2",
|
||||
"monaco-editor-webpack-plugin": "^2.1.0",
|
||||
"monaco-languageclient": "^0.13.0",
|
||||
"node-pty": "^0.9.0",
|
||||
"p-queue": "^6.6.2",
|
||||
"parse-passwd": "^1.0.0",
|
||||
"shell-quote": "^1.7.2",
|
||||
"style-loader": "^2.0.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vscode-languageserver-protocol": "3.15.3",
|
||||
"webpack": "^5.11.0",
|
||||
"webpack-cli": "^4.2.0",
|
||||
"xterm": "^4.9.0",
|
||||
"xterm-addon-fit": "^0.4.0",
|
||||
"yaml": "^1.10.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! -d system/src ]]; then
|
||||
echo "compile.bash: no system/src directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
function verbosely {
|
||||
echo "$@"
|
||||
"$@"
|
||||
}
|
||||
|
||||
mkdir -p system/out
|
||||
rm -f system/out/*
|
||||
for src in system/src/*.c; do
|
||||
out="${src/src/out}"
|
||||
out="${out/.c}"
|
||||
verbosely clang -Wall -Wextra -Werror -std=c11 "${src}" -o "${out}"
|
||||
if [[ "${out}" == *-privileged && -n "${RIJU_PRIVILEGED:-}" ]]; then
|
||||
sudo chown root:riju "${out}"
|
||||
sudo chmod a=,g=rx,u=rwxs "${out}"
|
||||
fi
|
||||
done
|
|
@ -0,0 +1,178 @@
|
|||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <grp.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// Keep in sync with backend/src/users.ts
|
||||
const int MIN_UID = 2000;
|
||||
const int MAX_UID = 65000;
|
||||
|
||||
int privileged;
|
||||
|
||||
void __attribute__ ((noreturn)) die(char *msg)
|
||||
{
|
||||
fprintf(stderr, "%s\n", msg);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
void die_with_usage()
|
||||
{
|
||||
die("usage:\n"
|
||||
" riju-system-privileged useradd UID\n"
|
||||
" riju-system-privileged setup UID UUID\n"
|
||||
" riju-system-privileged spawn UID UUID CMDLINE...\n"
|
||||
" riju-system-privileged teardown UID UUID");
|
||||
}
|
||||
|
||||
int parseUID(char *str)
|
||||
{
|
||||
if (!privileged)
|
||||
return -1;
|
||||
char *endptr;
|
||||
long uid = strtol(str, &endptr, 10);
|
||||
if (!*str || *endptr)
|
||||
die("uid must be an integer");
|
||||
if (uid < MIN_UID || uid >= MAX_UID)
|
||||
die("uid is out of range");
|
||||
return uid;
|
||||
}
|
||||
|
||||
char *parseUUID(char *uuid)
|
||||
{
|
||||
if (!*uuid)
|
||||
die("illegal uuid");
|
||||
for (char *ptr = uuid; *ptr; ++ptr)
|
||||
if (!((*ptr >= 'a' && *ptr <= 'z') || (*ptr >= '0' && *ptr <= '9') || *ptr == '-'))
|
||||
die("illegal uuid");
|
||||
return uuid;
|
||||
}
|
||||
|
||||
void useradd(int uid)
|
||||
{
|
||||
if (!privileged)
|
||||
die("useradd not allowed without root privileges");
|
||||
char *cmdline;
|
||||
if (asprintf(&cmdline, "groupadd -g %1$d riju%1$d", uid) < 0)
|
||||
die("asprintf failed");
|
||||
int status = system(cmdline);
|
||||
if (status != 0)
|
||||
die("groupadd failed");
|
||||
if (asprintf(&cmdline, "useradd -M -N -l -r -u %1$d -g %1$d -p '!' -s /usr/bin/bash riju%1$d", uid) < 0)
|
||||
die("asprintf failed");
|
||||
status = system(cmdline);
|
||||
if (status != 0)
|
||||
die("useradd failed");
|
||||
}
|
||||
|
||||
void spawn(int uid, char *uuid, char **cmdline)
|
||||
{
|
||||
char *cwd;
|
||||
if (asprintf(&cwd, "/tmp/riju/%s", uuid) < 0)
|
||||
die("asprintf failed");
|
||||
if (chdir(cwd) < 0)
|
||||
die("chdir failed");
|
||||
if (privileged) {
|
||||
if (setgid(uid) < 0)
|
||||
die("setgid failed");
|
||||
if (setgroups(0, NULL) < 0)
|
||||
die("setgroups failed");
|
||||
if (setuid(uid) < 0)
|
||||
die("setuid failed");
|
||||
}
|
||||
umask(077);
|
||||
execvp(cmdline[0], cmdline);
|
||||
die("execvp failed");
|
||||
}
|
||||
|
||||
void setup(int uid, char *uuid)
|
||||
{
|
||||
char *cmdline;
|
||||
if (asprintf(&cmdline, privileged
|
||||
? "install -d -o riju%1$d -g riju%1$d -m 700 /tmp/riju/%2$s"
|
||||
: "install -d -m 700 /tmp/riju/%2$s", uid, uuid) < 0)
|
||||
die("asprintf failed");
|
||||
int status = system(cmdline);
|
||||
if (status != 0)
|
||||
die("install failed");
|
||||
}
|
||||
|
||||
void teardown(int uid, char *uuid)
|
||||
{
|
||||
char *cmdline;
|
||||
int status;
|
||||
char *users;
|
||||
if (uid >= MIN_UID && uid < MAX_UID) {
|
||||
if (asprintf(&users, "%d", uid) < 0)
|
||||
die("asprintf failed");
|
||||
} else {
|
||||
cmdline = "getent passwd | grep -Eo '^riju[0-9]{4}' | paste -s -d, - | tr -d '\n'";
|
||||
FILE *fp = popen(cmdline, "r");
|
||||
if (fp == NULL)
|
||||
die("popen failed");
|
||||
static char buf[(MAX_UID - MIN_UID) * 9];
|
||||
if (fgets(buf, sizeof(buf), fp) == NULL) {
|
||||
if (feof(fp))
|
||||
users = NULL;
|
||||
else {
|
||||
die("fgets failed");
|
||||
}
|
||||
} else
|
||||
users = buf;
|
||||
}
|
||||
if (users != NULL) {
|
||||
if (asprintf(&cmdline, "while pkill -9 --uid %1$s; do sleep 0.01; done", users) < 0)
|
||||
die("asprintf failed");
|
||||
status = system(cmdline);
|
||||
if (status != 0 && status != 256)
|
||||
die("pkill failed");
|
||||
}
|
||||
if (asprintf(&cmdline, "rm -rf /tmp/riju/%s", uuid) < 0)
|
||||
die("asprintf failed");
|
||||
status = system(cmdline);
|
||||
if (status != 0)
|
||||
die("rm failed");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
int code = setuid(0);
|
||||
if (code != 0 && code != -EPERM)
|
||||
die("setuid failed");
|
||||
privileged = code == 0;
|
||||
if (argc < 2)
|
||||
die_with_usage();
|
||||
if (!strcmp(argv[1], "useradd")) {
|
||||
if (argc != 3)
|
||||
die_with_usage();
|
||||
useradd(parseUID(argv[2]));
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(argv[1], "spawn")) {
|
||||
if (argc < 5)
|
||||
die_with_usage();
|
||||
spawn(parseUID(argv[2]), parseUUID(argv[3]), &argv[4]);
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(argv[1], "setup")) {
|
||||
if (argc != 4)
|
||||
die_with_usage();
|
||||
int uid = parseUID(argv[2]);
|
||||
char *uuid = parseUUID(argv[3]);
|
||||
setup(uid, uuid);
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(argv[1], "teardown")) {
|
||||
if (argc != 4)
|
||||
die_with_usage();
|
||||
int uid = strcmp(argv[2], "*") ? parseUID(argv[2]) : -1;
|
||||
char *uuid = strcmp(argv[3], "*") ? parseUUID(argv[3]) : "*";
|
||||
teardown(uid, uuid);
|
||||
return 0;
|
||||
}
|
||||
die_with_usage();
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
const path = require("path");
|
||||
|
||||
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
|
||||
|
||||
function isProduction(argv) {
|
||||
return !argv.development;
|
||||
}
|
||||
|
||||
module.exports = (_, argv) => ({
|
||||
devtool: isProduction(argv) ? undefined : "source-map",
|
||||
entry: "./frontend/src/app.js",
|
||||
mode: isProduction(argv) ? "production" : "development",
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ["style-loader", "css-loader"],
|
||||
},
|
||||
{
|
||||
test: /\.ttf$/,
|
||||
use: ["file-loader"],
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
presets: ["@babel/preset-env"],
|
||||
},
|
||||
},
|
||||
include: /vscode-jsonrpc/,
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "frontend/out"),
|
||||
publicPath: "/js/",
|
||||
filename: "app.js",
|
||||
},
|
||||
performance: {
|
||||
hints: false,
|
||||
},
|
||||
plugins: [new MonacoWebpackPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
vscode: require.resolve("monaco-languageclient/lib/vscode-compatibility"),
|
||||
},
|
||||
fallback: {
|
||||
crypto: false,
|
||||
net: false,
|
||||
os: false,
|
||||
path: false,
|
||||
},
|
||||
},
|
||||
});
|
Loading…
Reference in New Issue