"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