582 lines
20 KiB
JavaScript
582 lines
20 KiB
JavaScript
"use strict";
|
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
}
|
|
Object.defineProperty(o, k2, desc);
|
|
}) : (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
o[k2] = m[k];
|
|
}));
|
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
}) : function(o, v) {
|
|
o["default"] = v;
|
|
});
|
|
var __importStar = (this && this.__importStar) || (function () {
|
|
var ownKeys = function(o) {
|
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
var ar = [];
|
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
return ar;
|
|
};
|
|
return ownKeys(o);
|
|
};
|
|
return function (mod) {
|
|
if (mod && mod.__esModule) return mod;
|
|
var result = {};
|
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
__setModuleDefault(result, mod);
|
|
return result;
|
|
};
|
|
})();
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.ExitError = exports.SubContainerRc = exports.SubContainerOwned = exports.execFile = void 0;
|
|
const fs = __importStar(require("fs/promises"));
|
|
const cp = __importStar(require("child_process"));
|
|
const util_1 = require("util");
|
|
const node_buffer_1 = require("node:buffer");
|
|
const once_1 = require("../../../base/lib/util/once");
|
|
const Drop_1 = require("../../../base/lib/util/Drop");
|
|
exports.execFile = (0, util_1.promisify)(cp.execFile);
|
|
const False = () => false;
|
|
const TIMES_TO_WAIT_FOR_PROC = 100;
|
|
async function prepBind(from, to, type) {
|
|
const fromMeta = from ? await fs.stat(from).catch((_) => null) : null;
|
|
const toMeta = await fs.stat(to).catch((_) => null);
|
|
if (type === 'file' || (type === 'infer' && from && fromMeta?.isFile())) {
|
|
if (toMeta && toMeta.isDirectory())
|
|
await fs.rmdir(to, { recursive: false });
|
|
if (from && !fromMeta) {
|
|
await fs.mkdir(from.replace(/\/[^\/]*\/?$/, ''), { recursive: true });
|
|
await fs.writeFile(from, '');
|
|
}
|
|
if (!toMeta) {
|
|
await fs.mkdir(to.replace(/\/[^\/]*\/?$/, ''), { recursive: true });
|
|
await fs.writeFile(to, '');
|
|
}
|
|
}
|
|
else {
|
|
if (toMeta && toMeta.isFile() && !toMeta.size)
|
|
await fs.rm(to);
|
|
if (from && !fromMeta)
|
|
await fs.mkdir(from, { recursive: true });
|
|
if (!toMeta)
|
|
await fs.mkdir(to, { recursive: true });
|
|
}
|
|
}
|
|
async function bind(from, to, type, idmap) {
|
|
await prepBind(from, to, type);
|
|
const args = ['--bind'];
|
|
if (idmap.length) {
|
|
args.push(`-oX-mount.idmap=${idmap.map((i) => `b:${i.fromId}:${i.toId}:${i.range}`).join(' ')}`);
|
|
}
|
|
await (0, exports.execFile)('mount', [...args, from, to]);
|
|
}
|
|
/**
|
|
* Want to limit what we can do in a container, so we want to launch a container with a specific image and the mounts.
|
|
*/
|
|
class SubContainerOwned extends Drop_1.Drop {
|
|
constructor(effects, imageId, rootfs, guid) {
|
|
super();
|
|
this.effects = effects;
|
|
this.imageId = imageId;
|
|
this.rootfs = rootfs;
|
|
this.guid = guid;
|
|
this.destroyed = false;
|
|
this.rcs = 0;
|
|
this.leaderExited = false;
|
|
this.leaderExited = false;
|
|
this.leader = cp.spawn('start-container', ['subcontainer', 'launch', rootfs], {
|
|
killSignal: 'SIGKILL',
|
|
stdio: 'inherit',
|
|
});
|
|
this.leader.on('exit', () => {
|
|
this.leaderExited = true;
|
|
});
|
|
this.waitProc = (0, once_1.once)(() => new Promise(async (resolve, reject) => {
|
|
let count = 0;
|
|
while (!(await fs.stat(`${this.rootfs}/proc/1`).then((x) => !!x, False))) {
|
|
if (count++ > TIMES_TO_WAIT_FOR_PROC) {
|
|
console.debug('Failed to start subcontainer', {
|
|
guid: this.guid,
|
|
imageId: this.imageId,
|
|
rootfs: this.rootfs,
|
|
});
|
|
return reject(new Error(`Failed to start subcontainer ${this.imageId}`));
|
|
}
|
|
await wait(1);
|
|
}
|
|
resolve(null);
|
|
}));
|
|
}
|
|
static async of(effects, image, mounts, name) {
|
|
const { imageId, sharedRun } = image;
|
|
const [rootfs, guid] = await effects.subcontainer.createFs({
|
|
imageId,
|
|
name,
|
|
});
|
|
const res = new SubContainerOwned(effects, imageId, rootfs, guid);
|
|
try {
|
|
if (mounts) {
|
|
await res.mount(mounts);
|
|
}
|
|
const shared = ['dev', 'sys'];
|
|
if (!!sharedRun) {
|
|
shared.push('run');
|
|
}
|
|
await fs.mkdir(`${rootfs}/etc`, { recursive: true });
|
|
await fs.copyFile('/etc/resolv.conf', `${rootfs}/etc/resolv.conf`);
|
|
for (const dirPart of shared) {
|
|
const from = `/${dirPart}`;
|
|
const to = `${rootfs}/${dirPart}`;
|
|
await fs.mkdir(from, { recursive: true });
|
|
await fs.mkdir(to, { recursive: true });
|
|
await (0, exports.execFile)('mount', ['--rbind', from, to]);
|
|
}
|
|
return res;
|
|
}
|
|
catch (e) {
|
|
await res.destroy();
|
|
throw e;
|
|
}
|
|
}
|
|
static async withTemp(effects, image, mounts, name, fn) {
|
|
const subContainer = await SubContainerOwned.of(effects, image, mounts, name);
|
|
try {
|
|
return await fn(subContainer);
|
|
}
|
|
finally {
|
|
await subContainer.destroy();
|
|
}
|
|
}
|
|
subpath(path) {
|
|
return path.startsWith('/')
|
|
? `${this.rootfs}${path}`
|
|
: `${this.rootfs}/${path}`;
|
|
}
|
|
async mount(mounts) {
|
|
for (let mount of mounts.build()) {
|
|
let { options, mountpoint } = mount;
|
|
const path = mountpoint.startsWith('/')
|
|
? `${this.rootfs}${mountpoint}`
|
|
: `${this.rootfs}/${mountpoint}`;
|
|
if (options.type === 'volume') {
|
|
const subpath = options.subpath
|
|
? options.subpath.startsWith('/')
|
|
? options.subpath
|
|
: `/${options.subpath}`
|
|
: '/';
|
|
const from = `/media/startos/volumes/${options.volumeId}${subpath}`;
|
|
await bind(from, path, options.filetype, options.idmap);
|
|
}
|
|
else if (options.type === 'assets') {
|
|
const subpath = options.subpath
|
|
? options.subpath.startsWith('/')
|
|
? options.subpath
|
|
: `/${options.subpath}`
|
|
: '/';
|
|
const from = `/media/startos/assets/${subpath}`;
|
|
await bind(from, path, options.filetype, options.idmap);
|
|
}
|
|
else if (options.type === 'pointer') {
|
|
await prepBind(null, path, 'directory');
|
|
await this.effects.mount({ location: path, target: options });
|
|
}
|
|
else if (options.type === 'backup') {
|
|
const subpath = options.subpath
|
|
? options.subpath.startsWith('/')
|
|
? options.subpath
|
|
: `/${options.subpath}`
|
|
: '/';
|
|
const from = `/media/startos/backup${subpath}`;
|
|
await bind(from, path, options.filetype, options.idmap);
|
|
}
|
|
else {
|
|
throw new Error(`unknown type ${options.type}`);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
async killLeader() {
|
|
if (this.leaderExited) {
|
|
return;
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
let timeout = setTimeout(() => this.leader.kill('SIGKILL'), 30000);
|
|
this.leader.on('exit', () => {
|
|
clearTimeout(timeout);
|
|
resolve(null);
|
|
});
|
|
if (!this.leader.kill('SIGTERM')) {
|
|
reject(new Error('kill(2) failed'));
|
|
}
|
|
}
|
|
catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
}
|
|
get destroy() {
|
|
return async () => {
|
|
if (!this.destroyed) {
|
|
const guid = this.guid;
|
|
await this.killLeader();
|
|
await this.effects.subcontainer.destroyFs({ guid });
|
|
this.destroyed = true;
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
onDrop() {
|
|
console.log(`Cleaning up dangling subcontainer ${this.guid}`);
|
|
this.destroy();
|
|
}
|
|
/**
|
|
* @description run a command inside this subcontainer
|
|
* DOES NOT THROW ON NONZERO EXIT CODE (see execFail)
|
|
* @param commands an array representing the command and args to execute
|
|
* @param options
|
|
* @param timeoutMs how long to wait before killing the command in ms
|
|
* @returns
|
|
*/
|
|
async exec(command, options, timeoutMs = 30000, abort) {
|
|
await this.waitProc();
|
|
const imageMeta = await fs
|
|
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
|
encoding: 'utf8',
|
|
})
|
|
.catch(() => '{}')
|
|
.then(JSON.parse);
|
|
let extra = [];
|
|
let user = imageMeta.user || 'root';
|
|
if (options?.user) {
|
|
user = options.user;
|
|
delete options.user;
|
|
}
|
|
let workdir = imageMeta.workdir || '/';
|
|
if (options?.cwd) {
|
|
workdir = options.cwd;
|
|
delete options.cwd;
|
|
}
|
|
if (options?.env) {
|
|
for (let [k, v] of Object.entries(options.env)) {
|
|
extra.push(`--env=${k}=${v}`);
|
|
}
|
|
}
|
|
const child = cp.spawn('start-container', [
|
|
'subcontainer',
|
|
'exec',
|
|
`--env-file=/media/startos/images/${this.imageId}.env`,
|
|
`--user=${user}`,
|
|
`--workdir=${workdir}`,
|
|
...extra,
|
|
this.rootfs,
|
|
...command,
|
|
], options || {});
|
|
abort?.signal.addEventListener('abort', () => child.kill('SIGKILL'));
|
|
if (options?.input) {
|
|
await new Promise((resolve, reject) => {
|
|
try {
|
|
child.stdin.on('error', (e) => reject(e));
|
|
child.stdin.write(options.input, (e) => {
|
|
if (e) {
|
|
reject(e);
|
|
}
|
|
else {
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
try {
|
|
child.stdin.end(resolve);
|
|
}
|
|
catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
}
|
|
const stdout = { data: '' };
|
|
const stderr = { data: '' };
|
|
const appendData = (appendTo) => (chunk) => {
|
|
if (typeof chunk === 'string' || chunk instanceof node_buffer_1.Buffer) {
|
|
appendTo.data += chunk.toString();
|
|
}
|
|
else {
|
|
console.error('received unexpected chunk', chunk);
|
|
}
|
|
};
|
|
return new Promise((resolve, reject) => {
|
|
child.on('error', reject);
|
|
let killTimeout;
|
|
if (timeoutMs !== null && child.pid) {
|
|
killTimeout = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
}
|
|
child.stdout.on('data', appendData(stdout));
|
|
child.stderr.on('data', appendData(stderr));
|
|
child.on('exit', (code, signal) => {
|
|
clearTimeout(killTimeout);
|
|
const result = {
|
|
exitCode: code,
|
|
exitSignal: signal,
|
|
stdout: stdout.data,
|
|
stderr: stderr.data,
|
|
};
|
|
resolve({
|
|
throw: () => !code && !signal
|
|
? { stdout: stdout.data, stderr: stderr.data }
|
|
: (() => {
|
|
throw new ExitError(command[0], result);
|
|
})(),
|
|
...result,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @description run a command inside this subcontainer, throwing on non-zero exit status
|
|
* @param commands an array representing the command and args to execute
|
|
* @param options
|
|
* @param timeoutMs how long to wait before killing the command in ms
|
|
* @returns
|
|
*/
|
|
async execFail(command, options, timeoutMs, abort) {
|
|
return this.exec(command, options, timeoutMs, abort).then((res) => res.throw());
|
|
}
|
|
async launch(command, options) {
|
|
await this.waitProc();
|
|
const imageMeta = await fs
|
|
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
|
encoding: 'utf8',
|
|
})
|
|
.catch(() => '{}')
|
|
.then(JSON.parse);
|
|
let extra = [];
|
|
let user = imageMeta.user || 'root';
|
|
if (options?.user) {
|
|
user = options.user;
|
|
delete options.user;
|
|
}
|
|
let workdir = imageMeta.workdir || '/';
|
|
if (options?.cwd) {
|
|
workdir = options.cwd;
|
|
delete options.cwd;
|
|
}
|
|
if (options?.env) {
|
|
for (let [k, v] of Object.entries(options.env).filter(([_, v]) => v != undefined)) {
|
|
extra.push(`--env=${k}=${v}`);
|
|
}
|
|
}
|
|
await this.killLeader();
|
|
this.leaderExited = false;
|
|
this.leader = cp.spawn('start-container', [
|
|
'subcontainer',
|
|
'launch',
|
|
`--env-file=/media/startos/images/${this.imageId}.env`,
|
|
`--user=${user}`,
|
|
`--workdir=${workdir}`,
|
|
...extra,
|
|
this.rootfs,
|
|
...command,
|
|
], { ...options, stdio: 'inherit' });
|
|
this.leader.on('exit', () => {
|
|
this.leaderExited = true;
|
|
});
|
|
return this.leader;
|
|
}
|
|
async spawn(command, options = { stdio: 'inherit' }) {
|
|
await this.waitProc();
|
|
const imageMeta = await fs
|
|
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
|
encoding: 'utf8',
|
|
})
|
|
.catch(() => '{}')
|
|
.then(JSON.parse);
|
|
let extra = [];
|
|
let user = imageMeta.user || 'root';
|
|
if (options?.user) {
|
|
user = options.user;
|
|
delete options.user;
|
|
}
|
|
let workdir = imageMeta.workdir || '/';
|
|
if (options.cwd) {
|
|
workdir = options.cwd;
|
|
delete options.cwd;
|
|
}
|
|
if (options?.env) {
|
|
for (let [k, v] of Object.entries(options.env).filter(([_, v]) => v != undefined)) {
|
|
extra.push(`--env=${k}=${v}`);
|
|
}
|
|
}
|
|
return cp.spawn('start-container', [
|
|
'subcontainer',
|
|
'exec',
|
|
`--env-file=/media/startos/images/${this.imageId}.env`,
|
|
`--user=${user}`,
|
|
`--workdir=${workdir}`,
|
|
...extra,
|
|
this.rootfs,
|
|
...command,
|
|
], options);
|
|
}
|
|
/**
|
|
* @description Write a file to the subcontainer's filesystem
|
|
* @param path Path relative to the subcontainer rootfs (e.g. "/etc/config.json")
|
|
* @param data The data to write
|
|
* @param options Optional write options (same as node:fs/promises writeFile)
|
|
*/
|
|
async writeFile(path, data, options) {
|
|
const fullPath = this.subpath(path);
|
|
const dir = fullPath.replace(/\/[^/]*\/?$/, '');
|
|
await fs.mkdir(dir, { recursive: true });
|
|
return fs.writeFile(fullPath, data, options);
|
|
}
|
|
rc() {
|
|
return new SubContainerRc(this);
|
|
}
|
|
isOwned() {
|
|
return true;
|
|
}
|
|
}
|
|
exports.SubContainerOwned = SubContainerOwned;
|
|
/**
|
|
* A reference-counted handle to a {@link SubContainerOwned}.
|
|
*
|
|
* Multiple `SubContainerRc` instances can share one underlying subcontainer.
|
|
* The subcontainer is destroyed only when the last reference is released via `destroy()`.
|
|
*/
|
|
class SubContainerRc extends Drop_1.Drop {
|
|
get imageId() {
|
|
return this.subcontainer.imageId;
|
|
}
|
|
get rootfs() {
|
|
return this.subcontainer.rootfs;
|
|
}
|
|
get guid() {
|
|
return this.subcontainer.guid;
|
|
}
|
|
subpath(path) {
|
|
return this.subcontainer.subpath(path);
|
|
}
|
|
constructor(subcontainer) {
|
|
subcontainer.rcs++;
|
|
super();
|
|
this.subcontainer = subcontainer;
|
|
this.destroyed = false;
|
|
this.destroying = null;
|
|
}
|
|
static async of(effects, image, mounts, name) {
|
|
return new SubContainerRc(await SubContainerOwned.of(effects, image, mounts, name));
|
|
}
|
|
static async withTemp(effects, image, mounts, name, fn) {
|
|
const subContainer = await SubContainerRc.of(effects, image, mounts, name);
|
|
try {
|
|
return await fn(subContainer);
|
|
}
|
|
finally {
|
|
await subContainer.destroy();
|
|
}
|
|
}
|
|
async mount(mounts) {
|
|
await this.subcontainer.mount(mounts);
|
|
return this;
|
|
}
|
|
get destroy() {
|
|
return async () => {
|
|
if (!this.destroyed && !this.destroying) {
|
|
const rcs = --this.subcontainer.rcs;
|
|
if (rcs <= 0) {
|
|
this.destroying = this.subcontainer.destroy();
|
|
if (rcs < 0)
|
|
console.error(new Error('UNREACHABLE: rcs < 0').stack);
|
|
}
|
|
}
|
|
if (this.destroying) {
|
|
await this.destroying;
|
|
}
|
|
this.destroyed = true;
|
|
this.destroying = null;
|
|
return null;
|
|
};
|
|
}
|
|
onDrop() {
|
|
this.destroy();
|
|
}
|
|
/**
|
|
* @description run a command inside this subcontainer
|
|
* DOES NOT THROW ON NONZERO EXIT CODE (see execFail)
|
|
* @param commands an array representing the command and args to execute
|
|
* @param options
|
|
* @param timeoutMs how long to wait before killing the command in ms
|
|
* @returns
|
|
*/
|
|
async exec(command, options, timeoutMs, abort) {
|
|
return this.subcontainer.exec(command, options, timeoutMs, abort);
|
|
}
|
|
/**
|
|
* @description run a command inside this subcontainer, throwing on non-zero exit status
|
|
* @param commands an array representing the command and args to execute
|
|
* @param options
|
|
* @param timeoutMs how long to wait before killing the command in ms
|
|
* @returns
|
|
*/
|
|
async execFail(command, options, timeoutMs, abort) {
|
|
return this.subcontainer.execFail(command, options, timeoutMs, abort);
|
|
}
|
|
async launch(command, options) {
|
|
return this.subcontainer.launch(command, options);
|
|
}
|
|
async spawn(command, options = { stdio: 'inherit' }) {
|
|
return this.subcontainer.spawn(command, options);
|
|
}
|
|
/**
|
|
* @description Write a file to the subcontainer's filesystem
|
|
* @param path Path relative to the subcontainer rootfs (e.g. "/etc/config.json")
|
|
* @param data The data to write
|
|
* @param options Optional write options (same as node:fs/promises writeFile)
|
|
*/
|
|
async writeFile(path, data, options) {
|
|
return this.subcontainer.writeFile(path, data, options);
|
|
}
|
|
rc() {
|
|
return this.subcontainer.rc();
|
|
}
|
|
isOwned() {
|
|
return false;
|
|
}
|
|
}
|
|
exports.SubContainerRc = SubContainerRc;
|
|
function wait(time) {
|
|
return new Promise((resolve) => setTimeout(resolve, time));
|
|
}
|
|
/**
|
|
* Error thrown when a subcontainer command exits with a non-zero code or signal.
|
|
* Contains the full result including stdout, stderr, exit code, and exit signal.
|
|
*/
|
|
class ExitError extends Error {
|
|
constructor(command, result) {
|
|
let message;
|
|
if (result.exitCode) {
|
|
message = `${command} failed with exit code ${result.exitCode}: ${result.stderr}`;
|
|
}
|
|
else if (result.exitSignal) {
|
|
message = `${command} terminated with signal ${result.exitSignal}: ${result.stderr}`;
|
|
}
|
|
else {
|
|
message = `${command} succeeded: ${result.stdout}`;
|
|
}
|
|
super(message);
|
|
this.command = command;
|
|
this.result = result;
|
|
}
|
|
}
|
|
exports.ExitError = ExitError;
|
|
//# sourceMappingURL=SubContainer.js.map
|