Fix StartOS 0.4 TypeScript packaging to match SDK API
This commit is contained in:
+582
@@ -0,0 +1,582 @@
|
||||
"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
|
||||
Reference in New Issue
Block a user