feat(console): Stop bus stops one organization's bus cleanly (#1873) #1941
No files matched your search
@@ -18,6 +18,7 @@
|
||||
// POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach
|
||||
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped;
|
||||
// busRevive for a host that died, dev-local owner only, D2)
|
||||
// POST /api/orgs/ID/stop/bus C3 busStop mosaic bus stop (a stopped bus answers state stopped)
|
||||
//
|
||||
// GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this
|
||||
// handler returns false for them.
|
||||
@@ -35,7 +36,7 @@ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const MAX_BODY = 64 * 1024;
|
||||
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
|
||||
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart|stop)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
|
||||
// The org-template placeholder (packages/organization/src/org-service.mjs templateRender).
|
||||
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
|
||||
// Project fields the server owns. The browser never sees or sends them. `agents` (#1873 A1) is
|
||||
@@ -54,6 +55,7 @@ export function verbFor(step, id, opts = {}) {
|
||||
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
|
||||
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
|
||||
if (step === 'bus-restart') return `mosaic bus restart ${id}`;
|
||||
if (step === 'bus-stop') return `mosaic bus stop ${id}`;
|
||||
if (step === 'bus-revive') return `mosaic bus recover ${id}; mosaic bus start ${id} --dev-local-owner --detach`;
|
||||
throw new Error(`unknown step ${step}`);
|
||||
}
|
||||
@@ -522,6 +524,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log
|
||||
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
|
||||
}
|
||||
if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
|
||||
if (kind === 'stop') return stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
|
||||
if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' });
|
||||
|
||||
// Every setup write reads the step back first and never overwrites.
|
||||
@@ -613,6 +616,64 @@ async function restartBus(req, res, { sub, write, id, ctx, service, admin, log,
|
||||
return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) });
|
||||
}
|
||||
|
||||
// Stop bus: the same owner, origin, --send and trail rules as Restart bus. C3 busStop sends SIGTERM
|
||||
// to the proven host and waits for it to exit; the host's close() removes host.json and host.lock,
|
||||
// and the broker's store removes writer.lock, so a clean stop leaves no lock behind (an offline
|
||||
// migrate-schema run can follow). The bus state is read back after the stop: a host or lock still
|
||||
// there, or state that does not check out, is a failure, not a done receipt. A host whose state
|
||||
// cannot be proven refuses before anything changes; there is no recovery path here, unlike Restart
|
||||
// bus on the dev Console. A bus that is already stopped answers done with state stopped.
|
||||
async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
|
||||
const step = 'bus-stop';
|
||||
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
|
||||
const body = await readBody(req); strictKeys(body, []);
|
||||
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
|
||||
const bus = await need(admin, 'bus', res, step); if (!bus) return;
|
||||
if (typeof bus.busStop !== 'function') return json(res, 503, { error: 'not-configured', step, message: 'bus control cannot stop a bus on this Console yet' });
|
||||
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not stop buses' });
|
||||
// The dev Console's start-up revive may be bringing this bus back; the press acts on the result.
|
||||
await revivePending(id);
|
||||
let status;
|
||||
try { status = await bus.busStatus(ctx, id); }
|
||||
catch (e) {
|
||||
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
|
||||
if (isCliError(e)) {
|
||||
// Refused before anything changed, but the owner asked: the trail records the attempt.
|
||||
try { trail({ by, action: 'bus.stop', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus stop: trail write failed after refused`); }
|
||||
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
|
||||
}
|
||||
return refusal(res, e, { step, id });
|
||||
}
|
||||
const running = Number.isSafeInteger(status?.pid);
|
||||
try { trail({ by, action: 'bus.stop', org: id, outcome: 'requested', running }); }
|
||||
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was stopped' }); }
|
||||
const note = outcome => { try { trail({ by, action: 'bus.stop', org: id, outcome }); } catch { log(`org-admin: ${id} bus stop: trail write failed after ${outcome}`); } };
|
||||
if (!running) {
|
||||
note('done');
|
||||
return json(res, 200, { outcome: 'done', step, stopped: false, state: 'stopped', receipt: { org: id, stopped: false }, verb: verbFor('bus-stop', id) });
|
||||
}
|
||||
let receipt;
|
||||
try { receipt = await bus.busStop(ctx, id); }
|
||||
catch (e) {
|
||||
// Only exit 3 comes before the signal; a timeout or an unexpected error may follow SIGTERM.
|
||||
note(isCliError(e) && e.exitCode === 3 ? 'refused' : 'failed');
|
||||
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
|
||||
return json(res, 409, { error: e.exitCode === 3 ? 'bus-refused' : 'bus-failed', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}.` });
|
||||
}
|
||||
// Read the state back: the stop is done only when no host and no lock is left.
|
||||
let after = null, left = 'a bus host still runs';
|
||||
try { after = await bus.busStatus(ctx, id); }
|
||||
catch (e) { if (!isCliError(e)) { note('failed'); return refusal(res, e, { step, id, unknownOk: true }); } left = plainMessage(ctx, e.message); }
|
||||
if (!after || Number.isSafeInteger(after.pid)) {
|
||||
note('failed');
|
||||
return json(res, 409, { error: 'bus-failed', step, message: `the bus host was stopped, but ${left}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
|
||||
}
|
||||
note('done');
|
||||
log(`org-admin: ${id} bus stopped`);
|
||||
// A host that exited on its own between the read and the stop was not stopped by this press.
|
||||
return json(res, 200, { outcome: 'done', step, stopped: receipt?.stopped === true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) });
|
||||
}
|
||||
|
||||
// The dev-local owner exactly (packages/cli/src/dev-local-owner.mjs). A signed-in session carries
|
||||
// its provider's issuer, so no SSO user matches.
|
||||
const isDevOwner = by => by?.provider === DEV_OWNER.provider && by.iss === DEV_OWNER.iss && by.sub === DEV_OWNER.sub;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
let config = null, boot = null, reconnecting = false, planned = false;
|
||||
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false;
|
||||
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false, busAction = 'restart';
|
||||
|
||||
const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })
|
||||
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
|
||||
@@ -119,11 +119,17 @@
|
||||
else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole();
|
||||
});
|
||||
|
||||
// The org bus restart control, offered on the organization's own pages to its owner.
|
||||
// The org bus restart control, offered on the organization's own pages to its owner. With
|
||||
// { stop: true } (the organization header) a Stop bus button sits beside it: one press, like
|
||||
// Restart bus, sharing its receipt and busy state. glyph is the caller's own icon markup, kept
|
||||
// inside the restart button so it stays on the button whatever width the receipt takes.
|
||||
const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true;
|
||||
function busButton(label = 'Restart bus') {
|
||||
function busButton(label = 'Restart bus', { stop = false, glyph = '' } = {}) {
|
||||
if (!busAllowed()) return '';
|
||||
return `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
|
||||
const off = busBusy ? ' disabled' : '';
|
||||
const restart = `<button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${stop ? ' data-stop' : ''}${off}>${glyph}${esc(busBusy && busAction === 'restart' ? 'Restarting…' : label)}</button>`;
|
||||
const buttons = stop ? `<span class="bus-buttons">${restart} <button type="button" class="btn" data-bus-stop${off}>${busBusy && busAction === 'stop' ? 'Stopping…' : 'Stop bus'}</button></span>` : restart;
|
||||
return `<span class="bus-restart">${buttons} <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
|
||||
}
|
||||
async function attachBus(box, label) {
|
||||
await ready;
|
||||
@@ -133,14 +139,16 @@
|
||||
box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`);
|
||||
}
|
||||
const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => {
|
||||
const label = el.querySelector('[data-bus-restart]')?.dataset.label;
|
||||
el.outerHTML = busButton(label);
|
||||
const button = el.querySelector('[data-bus-restart]');
|
||||
el.outerHTML = busButton(button?.dataset.label, { stop: button?.hasAttribute('data-stop'), glyph: button?.querySelector('svg')?.outerHTML || '' });
|
||||
});
|
||||
document.addEventListener('click', async e => {
|
||||
const button = e.target.closest?.('[data-bus-restart]');
|
||||
const button = e.target.closest?.('[data-bus-restart],[data-bus-stop]');
|
||||
if (!button || button.disabled || busBusy || !busAllowed()) return;
|
||||
const id = window.MosaicOrg.id;
|
||||
busAction = button.hasAttribute('data-bus-stop') ? 'stop' : 'restart';
|
||||
busBusy = true; busReceipt = ''; paintBus();
|
||||
if (busAction === 'stop') return stopBus(id);
|
||||
try {
|
||||
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
|
||||
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
|
||||
@@ -170,5 +178,23 @@
|
||||
} finally { busBusy = false; paintBus(); }
|
||||
});
|
||||
|
||||
// Stop bus: the bus host stops cleanly and leaves no lock behind. An already stopped bus answers
|
||||
// done with state stopped. The page reads the list and its send flags again, as after a restart.
|
||||
async function stopBus(id) {
|
||||
try {
|
||||
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/stop/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
|
||||
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-stop' || body.state !== 'stopped') throw new Error(body.message || 'The stop was refused. Nothing was retried.');
|
||||
busReceipt = body.stopped ? `Bus stopped at ${clock()}. Restart bus starts it again.` : `The bus was already stopped at ${clock()}. Nothing changed.`;
|
||||
$('bus-restart-done')?.remove();
|
||||
await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {});
|
||||
const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null);
|
||||
if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); }
|
||||
} catch (error) {
|
||||
busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message;
|
||||
} finally { busBusy = false; paintBus(); }
|
||||
}
|
||||
|
||||
window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } });
|
||||
})();
|
||||
@@ -431,16 +431,16 @@ html[data-organizations] .ws-org-row .ws-dot{font-size:12.5px;font-weight:500}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-offline,html[data-organizations] .ws-org-row .ws-dot.s-error{color:var(--text)}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-offline b{color:var(--muted)}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled){background:var(--raised)}
|
||||
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
|
||||
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart],html[data-organizations] .ws-busctl [data-bus-stop]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-stop]:hover:not(:disabled){background:var(--raised)}
|
||||
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled,html[data-organizations] .ws-busctl [data-bus-stop]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
|
||||
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
|
||||
/* The live bus restart is console-restart.js's own button; its icon sits beside it, outside the
|
||||
part that file repaints. */
|
||||
html[data-organizations] .ws-busctl{position:relative;display:inline-flex;flex-direction:column;align-items:flex-end}
|
||||
/* The live bus restart is console-restart.js's own button; it carries its icon through repaints,
|
||||
so the icon stays on the button however wide the receipt below it is. */
|
||||
html[data-organizations] .ws-busctl{display:inline-flex;flex-direction:column;align-items:flex-end}
|
||||
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart]{padding-left:31px}
|
||||
html[data-organizations] .ws-busctl .ws-busicon{position:absolute;left:11px;top:7px;width:15px;height:15px;pointer-events:none;color:var(--strong);z-index:1}
|
||||
html[data-organizations] .ws-busctl .bus-buttons{display:inline-flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart] svg.i{width:15px;height:15px}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{font-size:12px;color:var(--muted);max-width:28em;text-align:right}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]:empty{display:none}
|
||||
html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
@@ -689,6 +689,7 @@ html[data-organizations] #copy-status:empty{display:none}
|
||||
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
|
||||
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left}
|
||||
html[data-organizations] .ws-busctl .bus-buttons{justify-content:flex-start}
|
||||
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0}
|
||||
html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important}
|
||||
html[data-organizations] .ws-levels>div{border-right:0}
|
||||
|
||||
@@ -219,9 +219,10 @@
|
||||
|
||||
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
|
||||
// the same control, present and disabled, with the reason.
|
||||
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus') || '';
|
||||
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus', { stop: true, glyph: icon('restart') }) || '';
|
||||
const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail";
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button></span>`;
|
||||
const stopTitle = pane ? "Stop the bus from this organization's full view" : "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail";
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button> <button type="button" class="ws-btn" data-bus-stop-off data-focus-key="bus-stop-off" disabled title="${esc(stopTitle)}">Stop bus</button></span>`;
|
||||
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
|
||||
const [bcls, bglyph] = state(org);
|
||||
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
|
||||
|
||||
@@ -828,7 +828,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
for (const registerRoutes of routes) registerRoutes(registered);
|
||||
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
|
||||
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail });
|
||||
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
|
||||
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart|stop)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
|
||||
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
|
||||
}
|
||||
registered.add('POST', '/api/orgs', adminRoute);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Stop bus (#1873, bus-stop): the Console's POST /api/orgs/ID/stop/bus stops one organization's bus
|
||||
// host the way `mosaic bus stop` does, with SIGTERM and a clean close, so no host.lock or writer.lock
|
||||
// is left and an offline schema migration can open the store straight after. A real scratch instance
|
||||
// (bootstrap.sh), a real bus host and a real dev-owner Console on port 0.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { humanEnv } from '../../cli/tests/helpers.mjs';
|
||||
import { busStatus, busStop } from '../../cli/src/org-bus.mjs';
|
||||
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
|
||||
import { createOrg, templateRender } from '../../organization/src/org-service.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
const MOSAIC = join(REPO, 'scripts/mosaic');
|
||||
const MIGRATE = join(REPO, 'packages/bus/src/migrate-schema.mjs');
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
|
||||
|
||||
function mosaic(env, args, timeout = 60000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
|
||||
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
|
||||
child.on('error', reject);
|
||||
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
|
||||
});
|
||||
}
|
||||
// A request the way the page sends it to its own Console.
|
||||
function post(port, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const host = `127.0.0.1:${port}`;
|
||||
const req = request({ host: '127.0.0.1', port, path, method: 'POST', headers: { host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } }, res => {
|
||||
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
||||
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body, text }); });
|
||||
});
|
||||
req.on('error', reject); req.end('{}');
|
||||
});
|
||||
}
|
||||
// Every file under the bus state directories, with its content hash.
|
||||
function snapshot(root) {
|
||||
const out = {};
|
||||
for (const dir of ['bus-host', 'bus']) {
|
||||
const base = join(root, dir);
|
||||
if (!existsSync(base)) continue;
|
||||
for (const name of readdirSync(base).sort()) {
|
||||
const p = join(base, name), st = statSync(p);
|
||||
out[`${dir}/${name}`] = st.isFile() ? createHash('sha256').update(readFileSync(p)).digest('hex') : st.isDirectory() ? 'dir' : 'other';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('Stop bus stops a live bus cleanly: no locks, an offline migration runs, a second press answers stopped, setup starts it again', { timeout: 180000 }, async t => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'bs-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-bstop/config.json') });
|
||||
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'bstop'], { env, encoding: 'utf8' });
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const ctx = { configDir: join(home, '.config/mosaic-bstop'), dataRoot: join(home, '.mosaic-bstop'), instance: 'bstop', env };
|
||||
t.after(async () => {
|
||||
try { await busStop(ctx, 'alpha'); } catch {}
|
||||
try { await consoleStop(ctx, { warn: () => {} }); } catch {}
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
await createOrg({ ...ctx, principal: DEV }, 'alpha', await templateRender('alpha', { fixture: 'local' }));
|
||||
const started = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--dev-local-owner', '--detach']);
|
||||
assert.equal(started.status, 0, started.stderr);
|
||||
const first = await busStatus(ctx, 'alpha');
|
||||
assert.equal(first.healthy, true);
|
||||
const root = first.root;
|
||||
const hostLock = join(root, 'bus-host', 'host.lock'), writerLock = join(root, 'bus', 'writer.lock');
|
||||
// A live bus holds both locks, so their absence afterwards is the stop's doing.
|
||||
assert.ok(existsSync(hostLock), 'a live bus holds host.lock'); assert.ok(existsSync(writerLock), 'a live bus holds writer.lock');
|
||||
const run = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
const port = Number(new URL(run.url).port);
|
||||
assert.ok(port > 0 && !FORBIDDEN.includes(port), String(port));
|
||||
|
||||
const stop = await post(port, '/api/orgs/alpha/stop/bus');
|
||||
assert.equal(stop.status, 200, stop.text);
|
||||
assert.equal(stop.body.step, 'bus-stop'); assert.equal(stop.body.state, 'stopped'); assert.equal(stop.body.stopped, true);
|
||||
assert.equal(stop.body.receipt.pid, first.pid);
|
||||
assert.ok(!stop.text.includes(home), 'the receipt names no host path');
|
||||
assert.ok(!existsSync(`/proc/${first.pid}`) || !readFileSync(`/proc/${first.pid}/cmdline`, 'utf8').includes('--internal-host'), 'the host process is gone');
|
||||
assert.ok(!existsSync(hostLock), 'no host.lock left'); assert.ok(!existsSync(writerLock), 'no writer.lock left');
|
||||
const status = await busStatus(ctx, 'alpha');
|
||||
assert.equal(status.healthy, false); assert.equal(status.pid ?? null, null);
|
||||
|
||||
// The offline schema migration opens the store straight after (it refuses writer-locked otherwise).
|
||||
const migrate = spawnSync(process.execPath, [MIGRATE, root], { env: { PATH: process.env.PATH }, encoding: 'utf8' });
|
||||
assert.equal(migrate.status, 0, migrate.stderr);
|
||||
assert.match(migrate.stdout, /bus schema \S+ ready/);
|
||||
assert.ok(!existsSync(writerLock), 'the migration released the store');
|
||||
|
||||
// A second press finds it stopped and changes nothing.
|
||||
const quiet = snapshot(root);
|
||||
const again = await post(port, '/api/orgs/alpha/stop/bus');
|
||||
assert.equal(again.status, 200, again.text);
|
||||
assert.deepEqual([again.body.step, again.body.state, again.body.stopped], ['bus-stop', 'stopped', false]);
|
||||
assert.deepEqual(snapshot(root), quiet);
|
||||
|
||||
// A host lock this Console cannot prove (a live pid that is not the recorded process) refuses,
|
||||
// and every bus file stays as it was.
|
||||
writeFileSync(hostLock, JSON.stringify({ pid: process.pid, startTime: '1', business: 'alpha', startedAt: new Date().toISOString() }));
|
||||
const unproven = snapshot(root);
|
||||
const refused = await post(port, '/api/orgs/alpha/stop/bus');
|
||||
assert.equal(refused.status, 409, refused.text); assert.equal(refused.body.error, 'bus-refused');
|
||||
assert.match(refused.body.message, /^stale bus host lock\. Inspect it with mosaic bus status alpha/);
|
||||
assert.deepEqual(snapshot(root), unproven);
|
||||
rmSync(hostLock);
|
||||
|
||||
// The setup step starts it again.
|
||||
const up = await post(port, '/api/orgs/alpha/setup/bus');
|
||||
assert.equal(up.status, 201, up.text); assert.equal(up.body.step, 'bus');
|
||||
const back = await busStatus(ctx, 'alpha');
|
||||
assert.equal(back.healthy, true); assert.notEqual(back.pid, first.pid);
|
||||
assert.ok(existsSync(hostLock)); assert.ok(existsSync(writerLock));
|
||||
// And stops again from the page.
|
||||
const last = await post(port, '/api/orgs/alpha/stop/bus');
|
||||
assert.equal(last.status, 200, last.text); assert.equal(last.body.stopped, true);
|
||||
assert.ok(!existsSync(hostLock)); assert.ok(!existsSync(writerLock));
|
||||
});
|
||||
@@ -39,6 +39,7 @@ function fakes() {
|
||||
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
|
||||
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
busStop: async (ctx, id) => { calls.push(['busStop', id]); const was = running === id; running = null; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
|
||||
};
|
||||
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
return { admin, orgs, calls, fail, service };
|
||||
@@ -686,3 +687,197 @@ test('D2: a press during the Console start-up revive waits for it and acts on th
|
||||
assert.equal(r.status, 200, r.raw); assert.equal(r.body.restarted, true);
|
||||
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
|
||||
});
|
||||
|
||||
// Stop bus: the owner stops one organization's bus from its page. Same owner, origin, --send and
|
||||
// trail rules as Restart bus; a stopped bus answers state stopped and nothing changes.
|
||||
const STOP = '/api/orgs/acme/stop/bus';
|
||||
const STOP_DONE = { outcome: 'done', step: 'bus-stop', stopped: true, state: 'stopped', receipt: { org: 'acme', stopped: true, business: 'acme', pid: 4242 }, verb: 'mosaic bus stop acme' };
|
||||
|
||||
test('stop: the owner stops a running bus, a second press answers stopped, and setup starts it again', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/setup/bus', {})).status, 201);
|
||||
let r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(r.body, STOP_DONE);
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-stop', stopped: false, state: 'stopped', receipt: { org: 'acme', stopped: false }, verb: 'mosaic bus stop acme' });
|
||||
// The second press changed nothing: busStop ran once.
|
||||
assert.deepEqual(c.calls.filter(x => x[0] === 'busStop'), [['busStop', 'acme']]);
|
||||
const up = await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
assert.equal(up.status, 201, up.raw); assert.equal(up.body.step, 'bus');
|
||||
assert.equal(c.calls.filter(x => x[0] === 'busStart').length, 2);
|
||||
assert.deepEqual(tr.lines(), [
|
||||
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: true },
|
||||
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' },
|
||||
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: false },
|
||||
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('stop: another origin or site, no owner or --send, a body, GET or no organization is refused like Restart bus', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) {
|
||||
const [stop, restart] = [await c.call('POST', STOP, {}, headers), await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers)];
|
||||
assert.equal(stop.status, 403, JSON.stringify(headers));
|
||||
assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error], JSON.stringify(headers));
|
||||
}
|
||||
assert.equal((await c.call('POST', STOP, {}, { 'content-type': 'text/plain' })).status, 415);
|
||||
assert.equal((await c.call('POST', STOP, { now: true })).status, 400);
|
||||
assert.equal((await c.call('GET', STOP)).status, 404);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/stop/queue', {})).status, 404);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/stop', {})).status, 404);
|
||||
const unknown = await c.call('POST', '/api/orgs/ghost/stop/bus', {});
|
||||
assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable');
|
||||
const readOnly = await console_(t, { trail: tr.trail, send: null });
|
||||
const ro = await readOnly.call('POST', STOP, {});
|
||||
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
|
||||
const anon = await console_(t, { trail: tr.trail, sso: null, send: null });
|
||||
const a = await anon.call('POST', STOP, {});
|
||||
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
|
||||
assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
});
|
||||
|
||||
test('stop: on a deployed (signed-in) Console a granted user is refused like Restart bus, and the owner stops the bus', async t => {
|
||||
const { createSsoSession } = await import('./helpers/sso-session.mjs');
|
||||
const { consoleAccess } = await import('../src/access.mjs');
|
||||
const tr = trailIn(t);
|
||||
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'acme', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
|
||||
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'acme' }) });
|
||||
const f = fakes();
|
||||
await f.service.createOrg({}, 'acme', template('acme'));
|
||||
const bus = await f.admin.bus();
|
||||
await bus.busStart({}, 'acme', {});
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: tr.trail });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port;
|
||||
const post = (path, cookie, headers = {}) => new Promise((resolve, reject) => {
|
||||
const all = { ...s.writeHeaders(cookie), 'content-length': 2, ...headers };
|
||||
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers: all }, res => { let raw = ''; res.on('data', x => raw += x); res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, body: json, raw }); }); });
|
||||
req.on('error', reject); req.end('{}');
|
||||
});
|
||||
const alice = await s.signIn(port, { sub: 'alice-sub' });
|
||||
for (const cookie of [null, alice]) {
|
||||
const [stop, restart] = [await post(STOP, cookie), await post('/api/orgs/acme/restart/bus', cookie)];
|
||||
assert.ok(stop.status === 401 || stop.status === 403 || stop.status === 404, stop.raw);
|
||||
assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error]);
|
||||
}
|
||||
assert.equal(f.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
// The signed-in owner from another origin is refused too; from the Console's own origin the bus stops.
|
||||
const owner = await s.signIn(port);
|
||||
const cross = await post(STOP, owner, { origin: 'https://evil.example' });
|
||||
assert.equal(cross.status, 403);
|
||||
const ok = await post(STOP, owner);
|
||||
assert.equal(ok.status, 200, ok.raw); assert.equal(ok.body.state, 'stopped'); assert.equal(ok.body.stopped, true);
|
||||
assert.deepEqual(tr.lines().map(e => [e.by.provider, e.by.sub, e.action, e.outcome]), [['writer', 'owner-sub', 'bus.stop', 'requested'], ['writer', 'owner-sub', 'bus.stop', 'done']]);
|
||||
});
|
||||
|
||||
test('stop: a signed-in operator who is not the owner is refused before any bus call', async t => {
|
||||
const { Readable } = await import('node:stream');
|
||||
const tr = trailIn(t);
|
||||
const f = fakes();
|
||||
await f.service.createOrg({}, 'acme', template('acme'));
|
||||
for (const path of [STOP, '/api/orgs/acme/restart/bus']) {
|
||||
const req = Object.assign(Readable.from([Buffer.from('{}')]), { method: 'POST', headersDistinct: { origin: ['http://console.example'], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'] } });
|
||||
const res = { writeHead(status) { this.status = status; }, end(body) { this.body = JSON.parse(body); } };
|
||||
await orgAdminHTTP(req, res, { path, admin: f.admin, session: { provider: 'fixture', iss: 'https://issuer.example', sub: 'op', rootOwner: false, instanceRole: 'operator' }, owner: false, send: true, origin: 'http://console.example', trail: tr.trail });
|
||||
assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only', path);
|
||||
}
|
||||
assert.equal(f.calls.filter(x => x[0].startsWith('bus')).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
});
|
||||
|
||||
test('stop: no trail, or a trail that cannot be written, stops nothing', async t => {
|
||||
const c = await console_(t);
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
const r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable');
|
||||
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
|
||||
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await broken.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
const b = await broken.call('POST', STOP, {});
|
||||
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
|
||||
for (const x of [c, broken]) assert.equal(x.calls.filter(y => y[0] === 'busStop').length, 0);
|
||||
});
|
||||
|
||||
test('stop: a host that cannot be proven refuses with nothing changed, on the dev Console too', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
const bus = await c.admin.bus();
|
||||
bus.busRevive = async () => assert.fail('Stop bus reached busRevive');
|
||||
for (const reason of ['stale bus host lock', 'bus host identity mismatch', 'bus host lock does not match host state', 'stale bus writer lock']) {
|
||||
bus.busStatus = async () => { throw cliError(reason); };
|
||||
const r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409, reason); assert.equal(r.body.error, 'bus-refused');
|
||||
assert.equal(r.body.message, `${reason}. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.`);
|
||||
}
|
||||
assert.equal(c.calls.filter(x => x[0] === 'busStop').length, 0);
|
||||
assert.deepEqual(tr.lines(), Array(4).fill({ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'refused', running: null }));
|
||||
});
|
||||
|
||||
test('stop: a stop that is refused, times out or leaves state behind is a visible failure', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
const bus = await c.admin.bus();
|
||||
const live = async () => ({ org: 'acme', healthy: true, pid: 4242, root: '/data/org-runtime/acme' });
|
||||
bus.busStatus = live;
|
||||
bus.busStop = async () => { throw cliError('pid 4242 is not a bus host; refusing to signal it'); };
|
||||
let r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-refused');
|
||||
assert.equal(r.body.message, 'pid 4242 is not a bus host; refusing to signal it. Inspect it with mosaic bus status acme.');
|
||||
bus.busStop = async () => { throw Object.assign(new Error('bus host pid 4242 did not stop within 60 s'), { name: 'CliError', exitCode: 1 }); };
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
|
||||
// An unexpected error may come after the signal: the outcome is unknown and the trail says failed.
|
||||
bus.busStop = async () => { throw new Error('EPERM'); };
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 502, r.raw); assert.equal(r.body.error, 'outcome-unknown');
|
||||
// The host was signalled and exited, but a lock is left, or the host is still recorded.
|
||||
let after;
|
||||
bus.busStop = async () => { bus.busStatus = after; return { stopped: true, business: 'acme', pid: 4242 }; };
|
||||
after = async () => { throw cliError('stale bus writer lock'); };
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
|
||||
assert.equal(r.body.message, 'the bus host was stopped, but stale bus writer lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
|
||||
bus.busStatus = live; after = live;
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
|
||||
assert.match(r.body.message, /^the bus host was stopped, but a bus host still runs\./);
|
||||
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed']);
|
||||
// The host exited on its own between the read and the stop: done, but this press stopped nothing.
|
||||
bus.busStatus = live;
|
||||
bus.busStop = async () => { bus.busStatus = async () => ({ org: 'acme', healthy: false, pid: null }); return { stopped: false, reason: 'no host runs' }; };
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual([r.body.outcome, r.body.state, r.body.stopped, r.body.receipt.stopped], ['done', 'stopped', false, false]);
|
||||
});
|
||||
|
||||
test('stop: an archived organization, or a Console whose bus control has no stop, refuses', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
const bus = await c.admin.bus();
|
||||
const busStop = bus.busStop; delete bus.busStop;
|
||||
const n = await c.call('POST', STOP, {});
|
||||
assert.equal(n.status, 503); assert.equal(n.body.error, 'not-configured');
|
||||
bus.busStop = busStop;
|
||||
bus.busStatus = async () => { throw new OrgError('org-archived', 'organization acme is archived'); };
|
||||
const a = await c.call('POST', STOP, {});
|
||||
const ra = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.deepEqual([a.status, a.body.error], [422, 'org-archived'], a.raw);
|
||||
assert.deepEqual([ra.status, ra.body.error], [422, 'org-archived'], ra.raw);
|
||||
assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { createOrgAdmin } from '../src/org-admin.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -202,7 +206,7 @@ test('organization dashboard presents sections and honest unavailable controls',
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
|
||||
});
|
||||
|
||||
test('each header control is present, reload is disabled, and restart is refused without the owner', async t => {
|
||||
test('each header control is present, reload is disabled, and restart and stop are refused without the owner', async t => {
|
||||
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner));
|
||||
const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly));
|
||||
const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut));
|
||||
@@ -210,27 +214,69 @@ test('each header control is present, reload is disabled, and restart is refused
|
||||
const wait = waitFor(b);
|
||||
const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => {
|
||||
const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]');
|
||||
const stop = h.querySelector('[data-bus-stop]'), stopOff = h.querySelector('[data-bus-stop-off]');
|
||||
return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null,
|
||||
stop: stop ? [stop.textContent, stop.disabled, stop.closest('.bus-restart') === live.closest('.bus-restart')] : null, stopOff: stopOff ? [stopOff.textContent, stopOff.disabled, !!stopOff.title] : null,
|
||||
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
|
||||
})()`));
|
||||
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
|
||||
// The owner on a Console started with --send gets console-restart.js's restart button.
|
||||
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD });
|
||||
// Stop bus sits beside it, in the same control, and is one press like Restart bus.
|
||||
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, stop: ['Stop bus', false, true], stopOff: null, reload: RELOAD });
|
||||
// Without --send, or with nobody signed in, the control is present and disabled, and the route refuses.
|
||||
const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])';
|
||||
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD });
|
||||
const stopPost = post.replace('/restart/bus', '/stop/bus');
|
||||
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], stop: null, stopOff: ['Stop bus', true, true], reload: RELOAD });
|
||||
assert.deepEqual(await b.evaluate(post), [403, 'read-only']);
|
||||
assert.deepEqual(await b.evaluate(stopPost), [403, 'read-only']);
|
||||
// With nobody signed in the organization list is not readable, so the dashboard reads nothing and
|
||||
// shows no controls at all.
|
||||
await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-unavailable]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-bus-stop], [data-bus-stop-off], [data-org-reload]")'), null);
|
||||
// With nobody signed in, the server refuses every per-org route before the admin hooks run.
|
||||
assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']);
|
||||
assert.deepEqual(await b.evaluate(stopPost), [404, 'org-unavailable']);
|
||||
// There is no reload route.
|
||||
assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false);
|
||||
});
|
||||
|
||||
test('the owner presses Stop bus once: the bus stops, a second press says it was already stopped, and the restart icon stays on its button', async t => {
|
||||
// A fake bus behind the real route: busStop is the only thing a press reaches.
|
||||
let running = true; const stops = [];
|
||||
const bus = {
|
||||
busStatus: async (ctx, id) => ({ org: id, healthy: running, pid: running ? 4242 : null }),
|
||||
busStop: async (ctx, id) => { stops.push(id); const was = running; running = false; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
|
||||
};
|
||||
const orgAdmin = createOrgAdmin({ service: { showOrg: async (ctx, id) => ({ id, revision: 1, doc: {} }) }, registry: { listOrgs: async () => [] }, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-stop-trail-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const trail = actionTrail(join(root, 'console'));
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ send: true }), orgAdmin, trail, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-bus-stop]")');
|
||||
// The icon is part of the restart button, so it moves with it whatever width the receipt takes.
|
||||
const icon = () => b.evaluate(`(() => { const r = document.querySelector('[data-org-dashboard] [data-bus-restart]'), i = r.querySelector('svg'); if (!i) return null; const a = r.getBoundingClientRect(), c = i.getBoundingClientRect(); return c.left >= a.left && c.right <= a.right && c.top >= a.top && c.bottom <= a.bottom; })()`);
|
||||
assert.equal(await icon(), true);
|
||||
await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-stop]").click()');
|
||||
await wait('/^Bus stopped at \\d/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")');
|
||||
assert.deepEqual(stops, ['ddigital']);
|
||||
assert.equal(await icon(), true, 'the repaint keeps the icon on the restart button');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-org-dashboard] .bus-buttons button")].map(x => [x.textContent, x.disabled])'), [['Restart bus', false], ['Stop bus', false]]);
|
||||
await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-stop]").click()');
|
||||
await wait('/^The bus was already stopped at \\d/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")');
|
||||
// The second press reached the route, which found no host and stopped nothing.
|
||||
assert.deepEqual(stops, ['ddigital']);
|
||||
assert.equal(await icon(), true);
|
||||
// Each press is the owner's trailed action.
|
||||
const lines = readFileSync(join(root, 'console', 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, by, ...e } = JSON.parse(l); return e; });
|
||||
assert.deepEqual(lines, [
|
||||
{ action: 'bus.stop', org: 'ddigital', outcome: 'requested', running: true }, { action: 'bus.stop', org: 'ddigital', outcome: 'done' },
|
||||
{ action: 'bus.stop', org: 'ddigital', outcome: 'requested', running: false }, { action: 'bus.stop', org: 'ddigital', outcome: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
// Tab from the first sidebar link through the dashboard and return each stop's region and box.
|
||||
async function tabStops(b, start) {
|
||||
await b.evaluate(`(${start}).focus()`);
|
||||
@@ -268,8 +314,9 @@ test('keyboard focus follows visual order and survives a data refresh', async t
|
||||
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
|
||||
const keys = wide.map(s => s.key);
|
||||
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
|
||||
// The disabled reload is not a tab stop; the restart button is.
|
||||
// The disabled reload is not a tab stop; the restart button is, and Stop bus follows it.
|
||||
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
|
||||
assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1);
|
||||
await b.viewport(390, 844);
|
||||
await wait('document.querySelector("[data-org-dashboard]")');
|
||||
const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other');
|
||||
|
||||
Reference in new issue
Block a user