const { execFileSync } = require("child_process");
const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
const IMAGE = "localhost/mobux-fleet-node:dev";
const FLEET_DIR = __dirname;
const CONTAINERFILE = path.join(FLEET_DIR, "Containerfile.fleet-node");
const NODE_USER = "node";
function waitForSsh(sshArgs, timeoutMs) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
execFileSync(
"ssh",
[...sshArgs, "-o", "ConnectTimeout=2", "--", "true"],
{ stdio: "pipe" },
);
return;
} catch (err) {
if (Date.now() > deadline)
throw new Error(
`ssh not ready after ${timeoutMs}ms: ${err.stderr || err.message}`,
);
}
}
}
function keygen(file) {
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", file]);
}
let imageReady;
function ensureImage() {
if (!imageReady) {
imageReady = Promise.resolve().then(() => {
try {
execFileSync(
"podman",
["build", "-t", IMAGE, "-f", CONTAINERFILE, FLEET_DIR],
{ stdio: "pipe" },
);
} catch (err) {
throw new Error(
`podman build (fleet-node image) failed:\n${err.stderr}`,
);
}
});
}
return imageReady;
}
async function startNode({ name = "node" } = {}) {
await ensureImage();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `mobux-fleet-${name}-`));
const identity = path.join(dir, "client_key");
keygen(identity);
const authorizedKeys = path.join(dir, "authorized_keys");
fs.copyFileSync(identity + ".pub", authorizedKeys);
const knownHosts = path.join(dir, "known_hosts");
fs.writeFileSync(knownHosts, "");
const container = `mobux-fleet-${name}-${process.pid}-${crypto
.randomBytes(4)
.toString("hex")}`;
execFileSync(
"podman",
[
"run",
"-d",
"--name",
container,
"-p",
"127.0.0.1::22",
"-v",
`${authorizedKeys}:/home/${NODE_USER}/.ssh/authorized_keys:ro`,
IMAGE,
],
{ stdio: "pipe" },
);
function abort(err) {
const logs = execFileSync("podman", ["logs", container], {
stdio: "pipe",
}).toString();
execFileSync("podman", ["rm", "-f", container], { stdio: "ignore" });
fs.rmSync(dir, { recursive: true, force: true });
throw new Error(`${err.message}\ncontainer logs:\n${logs}`);
}
let port;
try {
const portOut = execFileSync("podman", ["port", container, "22"], {
stdio: "pipe",
})
.toString()
.trim();
port = Number(portOut.split(":").pop());
if (!port) throw new Error(`could not parse published port: ${portOut}`);
} catch (err) {
abort(err);
}
const sshArgs = [
"-o",
"BatchMode=yes",
"-o",
`UserKnownHostsFile=${knownHosts}`,
"-o",
"StrictHostKeyChecking=no",
"-o",
"IdentitiesOnly=yes",
"-p",
String(port),
"-i",
identity,
`${NODE_USER}@127.0.0.1`,
];
try {
waitForSsh(sshArgs, 10000);
} catch (err) {
abort(err);
}
let stopped = false;
return {
name,
dir,
container,
port,
user: NODE_USER,
identity,
knownHosts,
sshArgs,
ssh(remoteCmd) {
return execFileSync("ssh", [...sshArgs, "--", remoteCmd], {
stdio: "pipe",
}).toString();
},
tmux(args) {
const { TMUX, TMUX_PANE, ...env } = process.env;
return execFileSync(
"podman",
["exec", "-u", NODE_USER, container, "tmux", ...args],
{ stdio: "pipe", env },
).toString();
},
async stop() {
if (stopped) return;
stopped = true;
try {
execFileSync("podman", ["rm", "-f", container], { stdio: "ignore" });
} catch (_) {}
fs.rmSync(dir, { recursive: true, force: true });
},
};
}
module.exports = { startNode };