Fix StartOS 0.4 TypeScript packaging to match SDK API

This commit is contained in:
MacPro
2026-04-09 15:10:44 -05:00
parent 68ec875ee7
commit 8298c083c7
3436 changed files with 867051 additions and 92 deletions
+582
View File
@@ -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