459 lines
28 KiB
JavaScript
459 lines
28 KiB
JavaScript
// Instance-wide Console process; organization routing is owned by S5.
|
|
import { randomBytes } from 'node:crypto';
|
|
import { processState } from '../../seat/src/process-state.mjs';
|
|
import { spawn } from 'node:child_process';
|
|
import { request as httpRequest } from 'node:http';
|
|
import { constants, closeSync, fstatSync, ftruncateSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
|
|
import { basename, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
|
|
import { cmdlineOf, startTimeOf } from './host.mjs';
|
|
import { CliError } from './errors.mjs';
|
|
import { devLanOptions } from './dev-local-owner.mjs';
|
|
import { refuseInsideAgent } from './transport.mjs';
|
|
|
|
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
|
|
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
|
|
const fail = message => { throw new CliError(message, 3); };
|
|
const directory = ctx => join(ctx.dataRoot, 'console');
|
|
const stateFile = ctx => join(directory(ctx), 'console.json');
|
|
function state(ctx) {
|
|
let fd;
|
|
try { fd = openSync(stateFile(ctx), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
catch (e) { if (e.code === 'ENOENT') return null; fail('console state is unreadable'); }
|
|
let data;
|
|
try {
|
|
const st = fstatSync(fd);
|
|
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) fail('unsafe console state');
|
|
try { data = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('console state is unreadable'); }
|
|
} finally { closeSync(fd); }
|
|
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !data.startTime || typeof data.url !== 'string') fail('console state is invalid');
|
|
// org-lan-bind: a LAN-exposed Console records where it listens and the names it answers.
|
|
if (data.bind !== undefined || data.allowHosts !== undefined) {
|
|
try { if (!Array.isArray(data.allowHosts) || data.allowHosts.some(name => typeof name !== 'string' || name.includes(',')) || !devLanOptions({ devOwner: true, bind: data.bind, names: data.allowHosts })) throw new Error(); }
|
|
catch { fail('console state is invalid'); }
|
|
}
|
|
return data;
|
|
}
|
|
const lanWarning = (bind, port) => `WARNING: dev owner Console listens on ${bind}:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
|
|
function checkDir(ctx) {
|
|
mkdirSync(directory(ctx), { recursive: true, mode: 0o700 });
|
|
const st = lstatSync(directory(ctx));
|
|
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('unsafe console directory');
|
|
}
|
|
function liveConsole(s) {
|
|
let p;
|
|
try { p = processState(s.pid); } catch { fail('console process identity is uncertain'); }
|
|
return p.alive && p.startTime === s.startTime;
|
|
}
|
|
function removeStale(ctx, s, warn) {
|
|
if (liveConsole(s)) return false;
|
|
const file = stateFile(ctx), st = lstatSync(file);
|
|
const current = state(ctx);
|
|
if (!current || current.pid !== s.pid || current.startTime !== s.startTime || current.url !== s.url) fail('console state changed during recovery');
|
|
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('unsafe console state');
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const aside = `${file}.stale-${stamp}-${randomBytes(4).toString('hex')}`;
|
|
const currentStat = lstatSync(file);
|
|
if (currentStat.dev !== st.dev || currentStat.ino !== st.ino) fail('console state changed during recovery');
|
|
renameSync(file, aside);
|
|
warn(`mosaic: stale console state moved to ${aside}\n`);
|
|
return true;
|
|
}
|
|
// E2E6 fix 4: what a start.lock that already exists names. Null when it names no holder: unreadable,
|
|
// empty (a holder between its open and its write), not a regular file of this user, or a bad pid or
|
|
// start time. Such a lock is never cleared. A lock its holder marked released ({"released":true},
|
|
// written through the holder's own descriptor) names no live holder and is cleared (f6c review P3-1).
|
|
function lockHolder(path) {
|
|
let fd;
|
|
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
catch { return null; }
|
|
try {
|
|
const st = fstatSync(fd);
|
|
if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || st.size > 4096) return null;
|
|
const data = JSON.parse(readFileSync(fd, 'utf8'));
|
|
if (data?.released === true && Object.keys(data).length === 1) return { released: true, dev: st.dev, ino: st.ino };
|
|
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !/^\d+$/.test(data.startTime)) return null;
|
|
return { pid: data.pid, startTime: data.startTime, dev: st.dev, ino: st.ino };
|
|
} catch { return null; } finally { closeSync(fd); }
|
|
}
|
|
// True when the process the lock names (pid and start time) is gone. A /proc read that cannot settle
|
|
// it counts as live, so an uncertain holder is never cleared.
|
|
function holderGone(holder) {
|
|
if (holder.released) return true;
|
|
let p;
|
|
try { p = processState(holder.pid); } catch { return false; }
|
|
return !p.alive || p.startTime !== holder.startTime;
|
|
}
|
|
// Moves aside and removes the lock the holder check read, and only that file: a lock another start
|
|
// took in between is put back and the clear refuses.
|
|
function clearLock(path, holder) {
|
|
const aside = `${path}.dead-${randomBytes(4).toString('hex')}`;
|
|
try { renameSync(path, aside); } catch { fail('console start lock changed while it was being cleared; run the command again'); }
|
|
const st = lstatSync(aside);
|
|
if (st.dev !== holder.dev || st.ino !== holder.ino) {
|
|
// Put back without replacing: if a third start took the path meanwhile, the aside stays for a human.
|
|
let restored = false;
|
|
try { linkSync(aside, path); restored = true; } catch { /* left aside */ }
|
|
if (restored) rmSync(aside, { force: true });
|
|
fail('console start lock changed while it was being cleared; run the command again');
|
|
}
|
|
rmSync(aside, { force: true });
|
|
}
|
|
function takeLock(ctx, warn = () => {}) {
|
|
checkDir(ctx);
|
|
const path = join(directory(ctx), 'start.lock');
|
|
let fd;
|
|
for (let cleared = false; fd === undefined;) {
|
|
try { fd = openSync(path, 'wx', 0o600); }
|
|
catch (e) {
|
|
if (e?.code !== 'EEXIST' || cleared) fail('console start lock exists');
|
|
// E2E6 fix 4: a lock whose named holder (pid and start time) is gone is cleared once, and the
|
|
// take tried again. A live holder, or a lock that names none, refuses and stays.
|
|
const holder = lockHolder(path);
|
|
if (!holder) fail('console start lock exists and names no holder; remove console/start.lock under the data root once no console start or stop runs');
|
|
if (!holderGone(holder)) fail(`console start lock is held by process ${holder.pid}, which still runs; wait for its start or stop to finish`);
|
|
clearLock(path, holder);
|
|
warn(holder.released ? 'mosaic: cleared a console start lock its holder had released\n' : `mosaic: cleared a console start lock left by process ${holder.pid}, which is gone\n`);
|
|
cleared = true;
|
|
}
|
|
}
|
|
// D1: the lock names its holder, so a detached start that timed out can clear the lock of the
|
|
// supervisor it killed (clearDeadLock), and a later start or stop can clear it once that holder is gone.
|
|
try { writeSync(fd, JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) })); } catch { /* an unnamed lock is only never cleared */ }
|
|
// f6c review P3-1: the release marks its own file released, through its own descriptor, and removes
|
|
// the path only while the path is still that file. A clear that moved the lock aside and puts it back
|
|
// after this release puts back a released lock, which the next start or stop clears; and the release
|
|
// never removes a lock another start took meanwhile.
|
|
// A failed marker write (disk full) still removes the path: an empty lock would refuse forever.
|
|
return () => {
|
|
try {
|
|
try { ftruncateSync(fd, 0); writeSync(fd, JSON.stringify({ released: true }), 0); } catch { /* the matched unlink below still runs */ }
|
|
const own = fstatSync(fd), now = lstatSync(path);
|
|
if (own.dev === now.dev && own.ino === now.ino) rmSync(path, { force: true });
|
|
} catch { /* gone already, or moved aside by a clear */ } finally { closeSync(fd); }
|
|
};
|
|
}
|
|
// Removes start.lock only when it names this exact process (pid and start time) and that process
|
|
// is gone. A lock that is unreadable, unnamed or another holder's stays.
|
|
function clearDeadLock(ctx, pid, startTime) {
|
|
if (!startTime) return;
|
|
const path = join(directory(ctx), 'start.lock');
|
|
let owner;
|
|
try {
|
|
const st = lstatSync(path);
|
|
if (!st.isFile() || st.uid !== process.getuid()) return;
|
|
owner = JSON.parse(readFileSync(path, 'utf8'));
|
|
} catch { return; }
|
|
if (owner?.pid !== pid || owner.startTime !== startTime || startTimeOf(pid) === startTime) return;
|
|
rmSync(path, { force: true });
|
|
}
|
|
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
|
|
// own restart route (POST /api/console/restart, a human action) exits with it.
|
|
export const RESTART_EXIT = 75;
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
let lastStamp = 0;
|
|
// One Console process: spawn it, wait for the URL it prints, check the address and port.
|
|
async function launch(ctx, args, lan, detach) {
|
|
lastStamp = Math.max(Date.now(), lastStamp + 1);
|
|
const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`);
|
|
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
let child;
|
|
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
|
|
finally { closeSync(fd); }
|
|
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
|
|
let exited = false; done.then(() => { exited = true; });
|
|
try {
|
|
const deadline = Date.now() + 30000;
|
|
while (Date.now() < deadline && !exited) {
|
|
const text = readFileSync(log, 'utf8');
|
|
// The URL is loopback, or the specific address a LAN Console binds.
|
|
const match = /Mosaic Console: (http:\/\/([0-9.]+):(\d+)\/)/.exec(text);
|
|
if (match) {
|
|
if (match[2] !== '127.0.0.1' && match[2] !== lan?.bind) fail('console printed an unexpected address');
|
|
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
|
|
const startTime = startTimeOf(child.pid);
|
|
if (!startTime) fail('console exited during startup');
|
|
return { child, done, url: match[1], port: match[3], startTime };
|
|
}
|
|
await sleep(50);
|
|
}
|
|
// The log is named under the data root, never by host path (PR #1916).
|
|
fail(`console failed to start; see console/${basename(log)} under the data root`);
|
|
} catch (e) {
|
|
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
|
|
throw e;
|
|
}
|
|
}
|
|
const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n';
|
|
// F3: a relaunch keeps the arguments and the address. The state file must still name the process
|
|
// that asked for the restart; a stop that ran in between wins, and nothing is relaunched.
|
|
async function relaunch(ctx, old, args, lan) {
|
|
const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a));
|
|
let release;
|
|
for (let n = 0; !release; n++) {
|
|
try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); }
|
|
}
|
|
try {
|
|
const s = state(ctx);
|
|
if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart');
|
|
let fresh;
|
|
// The old listener has closed; a refused bind is retried briefly.
|
|
for (let n = 0; !fresh; n++) {
|
|
try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); }
|
|
}
|
|
try {
|
|
if (fresh.url !== old.url) fail('console restarted on another address');
|
|
const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`;
|
|
writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' });
|
|
renameSync(tmp, stateFile(ctx));
|
|
} catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; }
|
|
return fresh;
|
|
} finally { release(); }
|
|
}
|
|
// Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a
|
|
// stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end.
|
|
function supervise(ctx, first, args, lan, warn) {
|
|
let current = first, stopping = false;
|
|
const stop = () => {
|
|
if (stopping) return;
|
|
stopping = true;
|
|
const child = current.child;
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
child.kill('SIGTERM');
|
|
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref();
|
|
};
|
|
const done = (async () => {
|
|
for (;;) {
|
|
const code = await current.done;
|
|
if (stopping) return 0;
|
|
if (code !== RESTART_EXIT) return code;
|
|
warn('mosaic: console asked for a restart; relaunching on the same arguments\n');
|
|
try { current = await relaunch(ctx, current, args, lan); }
|
|
catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; }
|
|
if (stopping) { stopping = false; stop(); await current.done; return 0; }
|
|
}
|
|
})();
|
|
return { done, stop };
|
|
}
|
|
// D1 (#1873): `--dev-local-owner --detach` runs the foreground supervision above in a process of
|
|
// its own session (console-supervisor.mjs), so Restart Console works on a detached dev Console. The
|
|
// supervisor takes the start lock and writes the console state as a foreground start does. This
|
|
// process waits for the supervisor's one-line reply, passes its warnings on and leaves it running.
|
|
const SUPERVISOR = fileURLToPath(new URL('./console-supervisor.mjs', import.meta.url));
|
|
async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) {
|
|
checkDir(ctx);
|
|
lastStamp = Math.max(Date.now(), lastStamp + 1);
|
|
const name = `supervisor-${process.pid}-${lastStamp}.log`;
|
|
const fd = openSync(join(directory(ctx), name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
let child;
|
|
try {
|
|
child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance, port, bind, allowHosts })],
|
|
{ env: ctx.env ?? process.env, detached: true, stdio: ['ignore', 'pipe', fd] });
|
|
} finally { closeSync(fd); }
|
|
const supervisorStart = child.pid ? startTimeOf(child.pid) : null;
|
|
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
|
|
// The log is named under the data root, never by host path (PR #1916).
|
|
const unclear = `console failed to start; see console/${name} under the data root`;
|
|
let text = '', timer;
|
|
child.stdout.setEncoding('utf8');
|
|
const line = await Promise.race([
|
|
new Promise(resolve => {
|
|
child.stdout.on('data', c => { text += c; const n = text.indexOf('\n'); if (n >= 0) resolve(text.slice(0, n)); else if (text.length > 65536) resolve(null); });
|
|
child.stdout.once('end', () => resolve(null));
|
|
}),
|
|
new Promise(resolve => { timer = setTimeout(() => resolve(undefined), 60000); }),
|
|
]);
|
|
clearTimeout(timer);
|
|
child.stdout.destroy();
|
|
// The supervisor leads its own process group, with the Console it may have started: a timed-out
|
|
// start signals that group, then kills it if it is still there after 10 seconds. A supervisor
|
|
// killed while it held the start lock never released it, so the lock it named is cleared.
|
|
if (line === undefined) {
|
|
const group = signal => { try { process.kill(-child.pid, signal); } catch { /* already gone */ } };
|
|
group('SIGTERM');
|
|
let grace; const exited = await Promise.race([done.then(() => true), new Promise(r => { grace = setTimeout(() => r(false), 10000); })]);
|
|
clearTimeout(grace);
|
|
if (!exited) { group('SIGKILL'); await done; }
|
|
clearDeadLock(ctx, child.pid, supervisorStart);
|
|
fail(unclear);
|
|
}
|
|
let reply = null;
|
|
try { reply = JSON.parse(line); } catch { /* no reply */ }
|
|
for (const w of Array.isArray(reply?.warnings) ? reply.warnings : []) if (typeof w === 'string') warn(w);
|
|
child.unref();
|
|
if (typeof reply?.receipt?.url !== 'string' || !Number.isSafeInteger(reply.receipt.pid)) {
|
|
const code = [1, 2, 3, 4].includes(reply?.exitCode) ? reply.exitCode : 1;
|
|
throw new CliError(typeof reply?.error === 'string' ? reply.error : unclear, code);
|
|
}
|
|
const result = { ...reply.receipt, supervisor: child.pid };
|
|
Object.defineProperty(result, 'done', { value: done });
|
|
Object.defineProperty(result, 'stop', { value: () => child.kill('SIGTERM') });
|
|
return result;
|
|
}
|
|
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
|
|
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
|
|
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
|
|
// this process relaunches it in place (F3). A detached dev-local-owner Console runs under a
|
|
// detached supervisor (D1); any other detached Console has none.
|
|
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
|
|
const value = port === 'auto' ? 0 : Number(port);
|
|
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
|
|
let lan;
|
|
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
|
|
// D1 fix round (security review P1): a dev-local-owner Console acts as the owner with no sign-in,
|
|
// so an agent run never starts one, detached or not. agent-guard (#1873): nor any other Console.
|
|
// This process's own environment is checked as well as the one given for the Console (security
|
|
// re-check P1). A Restart Console relaunch goes through relaunch(), not here.
|
|
refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env);
|
|
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
|
|
const release = takeLock(ctx, warn);
|
|
let run, args;
|
|
try {
|
|
const prior = state(ctx);
|
|
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
|
|
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
|
|
if (org) await resolveOrg(ctx, org.id);
|
|
// The pre-S5 Console still accepts a business preselection.
|
|
args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
|
|
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : []), ...(detach ? [] : ['--supervised'])];
|
|
run = await launch(ctx, args, lan, detach);
|
|
try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); }
|
|
catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; }
|
|
} finally { release(); }
|
|
if (detach) run.child.unref();
|
|
// The child's stderr goes to the log; the person who started it sees the warning here.
|
|
if (lan) warn(lanWarning(lan.bind, run.port));
|
|
const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) };
|
|
const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn);
|
|
Object.defineProperty(result, 'done', { value: supervision.done });
|
|
Object.defineProperty(result, 'stop', { value: supervision.stop });
|
|
return result;
|
|
}
|
|
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
|
|
const release = takeLock(ctx, warn);
|
|
try {
|
|
const s = state(ctx);
|
|
if (!s || removeStale(ctx, s, warn)) return { stopped: false };
|
|
if (!liveConsole(s)) fail('console process changed before stop');
|
|
const argv = cmdlineOf(s.pid);
|
|
if (!argv || !argv.length) fail('console process identity is uncertain');
|
|
const i = argv.indexOf(WEBUI);
|
|
// The other entry script is a host path, so the refusal never names it (PR #1916).
|
|
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
|
|
process.kill(s.pid, 'SIGTERM');
|
|
const deadline = Date.now() + 20000;
|
|
while (Date.now() < deadline) {
|
|
let p; try { p = processState(s.pid); } catch { fail('console process identity is uncertain'); }
|
|
if (!p.alive || p.startTime !== s.startTime) { rmSync(stateFile(ctx)); return { stopped: true, pid: s.pid }; }
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
fail('console did not stop');
|
|
} finally { release(); }
|
|
}
|
|
// Read-only: what the state file says and whether that process still runs. It never moves a
|
|
// stale state file (start and stop do that under the start lock).
|
|
export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {}) {
|
|
const s = state(ctx);
|
|
if (!s) return { running: false };
|
|
const running = liveConsole(s);
|
|
const bind = s.bind ?? '127.0.0.1';
|
|
const port = /:(\d+)\/$/.exec(s.url)?.[1] ?? 'unknown';
|
|
// The same warning start printed, for as long as a Console started with the LAN flags runs.
|
|
if (running && s.bind) warn(lanWarning(bind, port));
|
|
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
|
|
}
|
|
|
|
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
|
|
// terminal. Each one is a request to the running Console named by console.json, sent as the page
|
|
// sends it: the Console's own Host and Origin, Sec-Fetch-Site: same-origin and a JSON `{}` body.
|
|
// The Console's route applies its owner, --send and origin checks and writes the action trail;
|
|
// nothing here writes the trail, signals a process or touches a bus. The CLI verbs refuse inside
|
|
// an agent run before they get here (transport.mjs refuseInsideAgent).
|
|
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
function consoleTarget(ctx) {
|
|
const s = state(ctx);
|
|
if (!s) fail('no Console runs for this instance; start one with mosaic console start');
|
|
if (!liveConsole(s)) fail('the Console in console state is not running; see mosaic console status');
|
|
const url = /^http:\/\/([0-9.]+):(\d+)\/$/.exec(s.url);
|
|
if (!url) fail('console state is invalid');
|
|
const port = Number(url[2]);
|
|
if (forbidden.has(port)) fail('console state names a forbidden port');
|
|
// A LAN Console on 0.0.0.0 answers on loopback too; one bound to an address answers only there.
|
|
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
|
|
return { address, port, host: `${address}:${port}` };
|
|
}
|
|
function consoleCall(target, { method = 'GET', path, timeoutMs, body = {} }) {
|
|
return new Promise((resolve, reject) => {
|
|
const payload = method === 'POST' ? JSON.stringify(body) : undefined;
|
|
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}) };
|
|
let sent = false;
|
|
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
|
|
let text = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', chunk => { text += chunk; if (text.length > 65536) req.destroy(new Error('the Console answer is too large')); });
|
|
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
|
|
res.on('error', e => reject(Object.assign(e, { sent: true })));
|
|
});
|
|
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
|
|
req.on('finish', () => { sent = true; });
|
|
req.on('error', e => reject(Object.assign(e, { sent })));
|
|
req.end(payload);
|
|
});
|
|
}
|
|
// A refused connection never reached the Console. Anything else after the request left may have
|
|
// changed something, so it is outcome-unknown and nothing is retried.
|
|
async function consolePost(target, path, { timeoutMs, unknown, body }) {
|
|
let answer;
|
|
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs, body }); }
|
|
catch (e) {
|
|
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
|
|
throw new CliError(`outcome unknown: ${unknown}`, 1);
|
|
}
|
|
return answer;
|
|
}
|
|
function refusal(answer, what) {
|
|
const code = typeof answer.body?.error === 'string' && /^[a-z-]{1,64}$/.test(answer.body.error) ? answer.body.error : `http ${answer.status}`;
|
|
const message = typeof answer.body?.message === 'string' ? `: ${answer.body.message.slice(0, 500)}` : '';
|
|
return new CliError(`${what} refused (${code})${message}`, answer.status >= 500 ? 1 : 3);
|
|
}
|
|
// POST /api/console/restart, then wait for the relaunched Console (a new boot id on /api/config).
|
|
export async function consoleRestart(ctx, { timeoutMs = 10000, waitMs = 30000 } = {}) {
|
|
const target = consoleTarget(ctx);
|
|
const answer = await consolePost(target, '/api/console/restart', { timeoutMs, unknown: 'the Console may be restarting; check mosaic console status and the action trail' });
|
|
if (answer.status !== 202 || answer.body?.outcome !== 'restarting' || typeof answer.body.boot !== 'string') throw refusal(answer, 'console restart');
|
|
const from = answer.body.boot, deadline = Date.now() + waitMs;
|
|
while (Date.now() < deadline) {
|
|
await sleep(250);
|
|
try {
|
|
const config = await consoleCall(target, { path: '/api/config', timeoutMs: 2000 });
|
|
if (config.status === 200 && typeof config.body?.boot === 'string' && config.body.boot !== from) return { outcome: 'restarted', from, boot: config.body.boot };
|
|
} catch { /* closed while it restarts */ }
|
|
}
|
|
throw new CliError('the Console accepted the restart but has not answered since; check mosaic console status', 1);
|
|
}
|
|
// POST /api/orgs/ID/restart/bus: a running bus restarts, a stopped one starts (org-admin.mjs restartBus).
|
|
export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
|
|
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
|
|
const target = consoleTarget(ctx);
|
|
const answer = await consolePost(target, `/api/orgs/${id}/restart/bus`, { timeoutMs, unknown: `the bus may be restarting; check mosaic bus status ${id} and the action trail` });
|
|
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
|
|
return answer.body;
|
|
}
|
|
// E2 (#1873, P1-3): the owner's org page actions from a terminal (org-owner.mjs): the hire form and
|
|
// the autonomy page, each through the running Console's own org route, sent as the page sends it.
|
|
// The route applies its owner, --send, origin, body and live-session checks and asks the org's own
|
|
// bus; nothing here decides or writes anything. A GET that fails changed nothing, so it is exit 1
|
|
// with no outcome-unknown wording; a POST that fails after it left is outcome-unknown.
|
|
export async function consoleOrgRequest(ctx, id, { method = 'GET', path, body, timeoutMs = 30000, unknown }) {
|
|
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
|
|
const target = consoleTarget(ctx);
|
|
const route = `/api/orgs/${id}/${path}`;
|
|
if (method === 'POST') return consolePost(target, route, { timeoutMs, unknown, body });
|
|
try { return await consoleCall(target, { path: route, timeoutMs }); }
|
|
catch (e) {
|
|
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
|
|
throw new CliError('the Console did not answer; see mosaic console status', 1);
|
|
}
|
|
}
|