riju/backend/util.js

249 lines
6.6 KiB
JavaScript

import { spawn } from "child_process";
import * as fsBase from "fs";
import { promises as fs } from "fs";
import process from "process";
import * as Sentry from "@sentry/node";
import * as tmp from "tmp-promise";
import { v4 as getUUIDOrig } from "uuid";
tmp.setGracefulCleanup();
let sentryEnabled = false;
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
});
sentryEnabled = true;
}
export function logError(err) {
console.error(err);
if (sentryEnabled) {
Sentry.captureException(err);
}
}
function computeImageHashes() {
let deployConfig = process.env.RIJU_DEPLOY_CONFIG;
if (!deployConfig) return {};
deployConfig = JSON.parse(deployConfig);
const imageHashes = {};
for (const [lang, tag] of Object.entries(deployConfig.langImageTags)) {
const prefix = `lang-${lang}-`;
if (!tag.startsWith(prefix)) {
throw new Error(`malformed tag ${tag}`);
}
const imageHash = tag.slice(prefix.length);
if (imageHash.length !== 40) {
throw new Error(`malformed tag ${tag}`);
}
imageHashes[lang] = imageHash;
}
return imageHashes;
}
const imageHashes = computeImageHashes();
export function quote(str) {
return "'" + str.replace(/'/g, `'"'"'`) + "'";
}
export const rijuSystemPrivileged = "system/out/riju-system-privileged";
export function getUUID() {
return getUUIDOrig().replace(/-/g, "");
}
export async function run(args, log, options) {
options = options || {};
const input = options.input;
const check = options.check === undefined ? true : options.check;
const suppressOutput = options.suppressOutput || false;
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 && !suppressOutput) {
log(`Output from ${args[0]}:\n` + output);
}
if (code === 0 || !check) {
resolve({ code, output });
} else {
reject(`command ${args[0]} failed with error code ${signal || code}`);
}
});
});
}
export function privilegedList() {
return [rijuSystemPrivileged, "list"];
}
export function privilegedPull({ repo, tag }) {
return [rijuSystemPrivileged, "pull", repo, tag];
}
export function privilegedSession({ uuid, lang }) {
const cmdline = [rijuSystemPrivileged, "session", uuid, lang];
if (imageHashes[lang]) {
cmdline.push(imageHashes[lang]);
}
return cmdline;
}
export function privilegedExec({ uuid }, args) {
return [rijuSystemPrivileged, "exec", uuid].concat(args);
}
export function privilegedPty({ uuid }, args) {
return [rijuSystemPrivileged, "pty", uuid].concat(args);
}
export function privilegedTeardown(options) {
options = options || {};
const { uuid } = options;
const cmdline = [rijuSystemPrivileged, "teardown"];
if (uuid) {
cmdline.push(uuid);
}
return cmdline;
}
export function bash(cmdline, opts) {
const stty = opts && opts.stty;
if (!cmdline.match(/[;|&(){}=\n]/)) {
// Reduce number of subshells we generate, if we're just running a
// single command (no shell logic).
cmdline = "exec " + cmdline;
}
if (stty) {
// Workaround https://github.com/moby/moby/issues/25450 (the issue
// thread claims the bug is resolved and released, but not in my
// testing).
cmdline = "stty cols 80 rows 24; " + cmdline;
}
return ["bash", "-c", `set -euo pipefail; ${cmdline}`];
}
export const log = {
trace: console.error,
debug: console.error,
info: console.error,
warn: console.error,
error: console.error,
};
export function asBool(value, def) {
if (def === undefined) {
throw new Error("asBool needs an explicit default value");
}
if (!value) {
return def;
}
value = value.toLowerCase().trim();
if (["y", "yes", "1", "on"].includes(value)) {
return true;
}
if (["n", "no", "0", "off"].includes(value)) {
return false;
}
throw new Error(`asBool doesn't understand value: ${value}`);
}
export function deptyify({ handlePtyInput, handlePtyExit }) {
return new Promise((resolve, reject) => {
const done = false;
let triggerDone = () => {
// Calling the function stored in this variable should have the
// effect of terminating the tmp-promise callback and getting
// the temporary directory cleaned up.
done = true;
};
tmp
.withDir(
async (dir) => {
const mkfifo = spawn("mkfifo", ["input", "output"], {
cwd: dir.path,
});
await new Promise((resolve, reject) => {
mkfifo.on("error", reject);
mkfifo.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(code);
}
});
});
const proc = spawn(
`${process.cwd()}/system/out/riju-pty`,
// Order is important, stdin can't be read properly from
// the background without more configuration
["-f", "sh", "-c", "cat output & cat > input"],
{
cwd: dir.path,
stdio: "inherit",
}
);
await new Promise((resolve, reject) => {
proc.on("spawn", resolve);
proc.on("error", reject);
});
proc.on("exit", (status) => {
handlePtyExit(status);
triggerDone();
});
const output = await new Promise((resolve, reject) => {
setTimeout(() => reject("timed out"), 5000);
resolve(fs.open(`${dir.path}/output`, "w"));
});
const input = fsBase.createReadStream(`${dir.path}/input`);
setTimeout(async () => {
try {
for await (const data of input) {
handlePtyInput(data);
}
} catch (err) {
logError(err);
}
}, 0);
resolve({
handlePtyOutput: (data) => {
output.write(data);
},
});
// Wait before deleting tmpdir...
await new Promise((resolve) => {
if (done) {
resolve();
} else {
triggerDone = resolve;
}
});
},
{
unsafeCleanup: true,
}
)
.catch((err) => {
logError(err);
reject(err);
});
});
}