"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