fix(proxy): review fixes for the Console proxy setup page (#1873)
Security review: - Release verification cache keys on ctime as well as mtime, in nanoseconds. A same-size rewrite with mtime set back now re-hashes and reports proxy-install-invalid. Build review: - The setup page hides the account panels on any failed accounts read, not only proxy-down, and brings them back once the proxy answers. - The organizations table shows a dash, not a made-up 0, when a bus does not answer or sends no model list (orgCatalogue in proxy-admin.mjs). - A fence clear the bus did not answer keeps the confirmed restart for a retry. - A fence with no readable time names `mosaic proxy fence clear --proxy-restarted` on the page, in the refusal table and in the README. - CLI `init --generate-key` goes through the setup controller and writes the same init receipt as the page. - Status carries the last ten receipts, so the page shows them after a reload. An initialized proxy keeps the init form folded. - README: proxy-admin-busy is per process; the cross-process locks that serialize the writes are named.
This commit was merged in pull request #1943.
This commit is contained in:
1 parent
f0e11cb342
commit
75f6d5533a
13 files changed
+162
-22
No files matched your search
@@ -63,7 +63,7 @@ group("proxy-install-invalid proxy-fetch-hash proxy-fetch-archive proxy-fetch-ex
|
||||
group("proxy-already-initialized proxy-port-in-use proxy-management-key-invalid", "R1/R4: proxy init is exclusive creation", "Init never rewrites proxy.json. Read the Proxy page status; pick a free port, or keep the existing setup and continue from the status's next step.");
|
||||
group("proxy-init-busy proxy-init-lock-held proxy-init-lock-absent", "R4: init lock clears only when provably stale", "Wait for a running init to finish. A stale lock clears from the Proxy page or with mosaic proxy init-lock clear only when the bus is stopped and no proxy.json, key file or proxy dir exists; the refusal lists what is still there.");
|
||||
group("proxy-key-transport", "R1/R6: a pasted key travels only over loopback or HTTPS", "Use the generated key, which never leaves the host, or open the Console over HTTPS or on this machine to paste one.");
|
||||
group("proxy-restart-required proxy-restart-unconfirmed proxy-lock-uncertain", "R4/R6: fence clears only after a confirmed proxy restart", "Restart the proxy from the Proxy page or with mosaic proxy fence clear --restart, so the new process is confirmed on the proxy port, then clear the fence.");
|
||||
group("proxy-restart-required proxy-restart-unconfirmed proxy-lock-uncertain", "R4/R6: fence clears only after a confirmed proxy restart", "Restart the proxy from the Proxy page or with mosaic proxy fence clear --restart, so the new process is confirmed on the proxy port, then clear the fence. A fence file with no readable time cannot be proven against: restart the proxy service yourself and run mosaic proxy fence clear --proxy-restarted.");
|
||||
group("proxy-admin-busy", "R4: one proxy setup action at a time", "Wait for the running fetch, init or unit action to finish (the Proxy page status shows it), then retry.");
|
||||
group("proxy-key-mismatch", "R1: a pasted management key is confirmed before init", "Type the same key in both fields, or leave them empty to have the bus generate the key.");
|
||||
group("proxy-lock-busy proxy-lock-invalid", "R4: one proxy key write at a time", "Wait for the running proxy write to finish and retry. An invalid lock file needs the owner to inspect the proxy state directory.");
|
||||
|
||||
@@ -173,9 +173,11 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
|
||||
// #1873: the bus generates the key and stores it where init stores a typed key; no prompt,
|
||||
// no terminal needed, and the key is never printed or returned.
|
||||
if (parsed.flags.has('--generate-key')) {
|
||||
// Through the page's controller, so the same init receipt lands in proxy-admin.jsonl.
|
||||
const call = callFor(parsed);
|
||||
const result = await call('proxy.init', { generate: true, ...(port === undefined ? {} : { port }), ...timing });
|
||||
print(io, { port: result.port, rotateGraceMinutes: result.rotateGraceMinutes ?? null, firstOutputSeconds: result.firstOutputSeconds ?? null, generated: true });
|
||||
const { settings, receipt } = await adminFor(io, system, deps).init(call, { generate: true, ...(port === undefined ? {} : { port }), ...timing });
|
||||
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
|
||||
print(io, { ...settings, generated: true });
|
||||
return;
|
||||
}
|
||||
if (!io.stdin.isTTY || !io.stdout.isTTY) throw failure('proxy-terminal-required', 3);
|
||||
|
||||
@@ -82,12 +82,16 @@ test('a controller refusal reaches the shell as its proxy code only, with no pat
|
||||
test('proxy init --generate-key needs no terminal, never prompts, and sends generate to the bus', async () => {
|
||||
const x = human();
|
||||
assert.notEqual(x.stdin.isTTY, true);
|
||||
const calls = [];
|
||||
const calls = [], system = fakeSystem();
|
||||
await main(['proxy', 'init', '--generate-key', '--business', 'demo', '--port', '18440', '--rotate-grace-minutes', '5'], x, {
|
||||
system: fakeSystem(), promptProxyKey: () => { throw Error('must not prompt'); },
|
||||
system, promptProxyKey: () => { throw Error('must not prompt'); },
|
||||
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { port: 18440, rotateGraceMinutes: 5, binary: '/fixture' }; } });
|
||||
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } }]);
|
||||
assert.deepEqual(JSON.parse(x.out.text), { port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null, generated: true });
|
||||
// The same init receipt the page writes, settings only.
|
||||
const [receipt] = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.deepEqual({ ...receipt, time: 'T' }, { time: 'T', op: 'init', generated: true, port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null });
|
||||
assert.match(x.err.text, /^receipt: \{"time":/m);
|
||||
await assert.rejects(main(['proxy', 'init', '--generate-key', '--generate-key', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-usage'));
|
||||
await assert.rejects(main(['proxy', 'init', '--generate-key', '--port', '8317', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-config-invalid'));
|
||||
});
|
||||
|
||||
@@ -158,7 +158,17 @@ state offers its next step as one button. CLI parity:
|
||||
confirms a new process that started after the fence and listens on the port,
|
||||
then clears the fence with that evidence under the lock. The receipt holds the
|
||||
before and after pids. Without a confirmed restart it refuses
|
||||
(`proxy-restart-unconfirmed`). `fence clear --proxy-restarted` is unchanged.
|
||||
(`proxy-restart-unconfirmed`). A fence file with no readable time also refuses,
|
||||
because a restart cannot be shown to come after it; restart the service
|
||||
yourself and use `fence clear --proxy-restarted`, which is unchanged.
|
||||
- `proxy-admin-busy` (one setup action at a time) is per process: the Console
|
||||
and a `mosaic proxy` command each hold their own. The writes that matter are
|
||||
still serialized across processes: init by the bus's init lock and
|
||||
exclusive create, key writes by the instance-wide proxy lock, and unit
|
||||
actions by systemd.
|
||||
- The page and `status --json` carry the last ten receipts from
|
||||
`proxy-admin.jsonl`, so they survive a reload. CLI `init --generate-key`
|
||||
writes the same init receipt as the page.
|
||||
- The init lock clears only when every bus on the instance is stopped and no
|
||||
`proxy.json`, key file or proxy state directory exists. Otherwise the panel
|
||||
and `init-lock status` list why not.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { fetchPinned } from './fetch.mjs';
|
||||
import { PIN } from './common.mjs';
|
||||
import { releaseState, initState, fenceState, initLockStatus, clearInitLock, appendReceipt, createUnitControl, FILE_LABELS } from './admin.mjs';
|
||||
import { releaseState, initState, fenceState, initLockStatus, clearInitLock, appendReceipt, recentReceipts, createUnitControl, FILE_LABELS } from './admin.mjs';
|
||||
|
||||
export const proxyCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
|
||||
const failure = code => Object.assign(new Error(code), { code });
|
||||
@@ -66,6 +66,7 @@ export function createProxyAdmin({ dataRoot, configDir, instance = null, unitDir
|
||||
fetch: fetchJob,
|
||||
restart: restarted ? { confirmed: true, observedAt: restarted.observedAt, pid: restarted.after.pid } : null,
|
||||
busy,
|
||||
receipts: recentReceipts(dataRoot),
|
||||
organizations,
|
||||
// Ruling 8: the system config holds one proxy per instance; an organization's own proxy
|
||||
// needs a config format that does not exist yet.
|
||||
@@ -126,8 +127,9 @@ export function createProxyAdmin({ dataRoot, configDir, instance = null, unitDir
|
||||
return exclusive('fence-clear', async () => {
|
||||
if (!restarted) throw failure('proxy-restart-required');
|
||||
const evidence = restarted;
|
||||
restarted = null;
|
||||
// Kept until the bus answers: a bus that is down or busy leaves the confirmed restart for a retry.
|
||||
const result = await call('proxy.fence.clear', { evidence });
|
||||
if (restarted === evidence) restarted = null;
|
||||
return { cleared: result.cleared === true, receipt: receipt({ op: 'fence.clear', cleared: result.cleared === true, evidence: result.evidence ?? null }) };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -27,15 +27,16 @@ export function initFiles(configDir, dataRoot) {
|
||||
function present(file) { try { fs.lstatSync(file); return true; } catch (error) { return error.code !== 'ENOENT'; } }
|
||||
|
||||
const verified = new Map();
|
||||
// The pinned release: hashes are checked once per file identity (dev, inode, size, mtime, mode).
|
||||
// The pinned release: hashes are checked once per file identity (dev, inode, size, mtime, ctime, mode),
|
||||
// in nanoseconds. mtime can be set back by the file's owner; ctime cannot, so an in-place rewrite re-hashes.
|
||||
export function releaseState(dataRoot, pin = PIN) {
|
||||
const paths = installPaths(dataRoot, pin);
|
||||
const out = { version: pin.version, panelVersion: pin.panel.version, fetched: false, verified: false, problem: null };
|
||||
let stats;
|
||||
try { stats = [paths.binary, paths.panel].map(file => fs.lstatSync(file)); }
|
||||
try { stats = [paths.binary, paths.panel].map(file => fs.lstatSync(file, { bigint: true })); }
|
||||
catch (error) { out.problem = error.code === 'ENOENT' ? 'proxy-install-missing' : 'proxy-install-invalid'; return out; }
|
||||
out.fetched = true;
|
||||
const identity = stats.map(s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}:${s.mode}`).join('|');
|
||||
const identity = stats.map(s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.mode}`).join('|');
|
||||
if (verified.get(paths.dir) === identity) { out.verified = true; return out; }
|
||||
try { verifyInstall(dataRoot, pin); verified.set(paths.dir, identity); out.verified = true; }
|
||||
catch (error) { out.problem = /^proxy-install-(missing|invalid)$/.test(error.code ?? '') ? error.code : 'proxy-install-invalid'; }
|
||||
@@ -106,6 +107,21 @@ export function appendReceipt(dataRoot, record) {
|
||||
return record;
|
||||
}
|
||||
|
||||
// The last few receipts, oldest first, for the page after a reload. Reads only the file's tail and
|
||||
// skips a line that does not parse (a torn last write). Receipts never hold a key or a body.
|
||||
export function recentReceipts(dataRoot, limit = 10) {
|
||||
let fd;
|
||||
try { fd = fs.openSync(receiptFile(dataRoot), 'r'); } catch { return []; }
|
||||
try {
|
||||
const size = fs.fstatSync(fd).size, length = Math.min(size, 65536), buffer = Buffer.alloc(length);
|
||||
fs.readSync(fd, buffer, 0, length, size - length);
|
||||
const lines = buffer.toString('utf8').split('\n').slice(size > length ? 1 : 0).filter(Boolean);
|
||||
const out = [];
|
||||
for (const line of lines) { try { const value = JSON.parse(line); if (value && typeof value === 'object' && typeof value.op === 'string') out.push(value); } catch { /* torn line */ } }
|
||||
return out.slice(-limit);
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
export function unitName(instance) {
|
||||
if (instance !== null && instance !== undefined && instance !== 'dev' && !INSTANCE_NAME.test(instance)) refuse('proxy-unit-usage');
|
||||
return instance && instance !== 'dev' ? `mosaic-proxy-${instance}.service` : 'mosaic-proxy.service';
|
||||
|
||||
@@ -142,6 +142,17 @@ test('fence clear after a confirmed restart sends that evidence once; another un
|
||||
assert.deepEqual(receipts(dataRoot).map(r => r.op), ['unit.restart', 'fence.clear', 'unit.restart', 'unit.stop']);
|
||||
});
|
||||
|
||||
test('a fence clear the bus did not answer keeps the confirmed restart for a retry', async t => {
|
||||
const { admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
|
||||
let fail = true;
|
||||
const call = async () => { if (fail) { fail = false; throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); } return { cleared: true }; };
|
||||
await admin.unitAction('restart');
|
||||
await assert.rejects(admin.fenceClear(call), /bus down/);
|
||||
assert.equal((await admin.status()).restart?.confirmed, true, 'the restart is still confirmed');
|
||||
assert.equal((await admin.fenceClear(call)).cleared, true);
|
||||
assert.equal((await admin.status()).restart, null);
|
||||
});
|
||||
|
||||
test('next step walks fetch, init lock, repair, init, unit install, start, restart, fence and accounts', async t => {
|
||||
const sha = text => createHash('sha256').update(text).digest('hex');
|
||||
const pin = { ...PIN, archive: { ...PIN.archive, sha256: sha('a') }, binary: { sha256: sha('b') }, panel: { ...PIN.panel, sha256: sha('p') } };
|
||||
@@ -206,3 +217,13 @@ test('render replaces the home directory with ~', async t => {
|
||||
assert.equal(unit, 'mosaic-proxy-zz.service');
|
||||
if (process.env.HOME && process.env.HOME !== '/') assert.equal(text.includes(process.env.HOME), false);
|
||||
});
|
||||
|
||||
test('status carries the last receipts from proxy-admin.jsonl, so the page shows them after a reload', async t => {
|
||||
const { dataRoot, admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
|
||||
assert.deepEqual((await admin.status()).receipts, []);
|
||||
for (let i = 0; i < 12; i++) await admin.unitAction('stop');
|
||||
fs.appendFileSync(receiptFile(dataRoot), '{"op":"torn');
|
||||
const { receipts } = await admin.status();
|
||||
assert.equal(receipts.length, 10);
|
||||
assert.ok(receipts.every(r => r.op === 'unit.stop'));
|
||||
});
|
||||
@@ -5,6 +5,9 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PIN } from '../src/common.mjs';
|
||||
import { installPaths } from '../src/settings.mjs';
|
||||
import {
|
||||
processTree, pidListensOn, processStartMs, verifyRestart, initLockStatus, clearInitLock, initFiles, initState,
|
||||
fenceState, createUnitControl, releaseState, receiptFile, unitName,
|
||||
@@ -121,6 +124,27 @@ test('releaseState: nothing fetched names the next step without a path', t => {
|
||||
assert.equal(JSON.stringify(state).includes(root), false);
|
||||
});
|
||||
|
||||
test('releaseState re-hashes a release file rewritten in place with its mtime set back', async t => {
|
||||
const { dataRoot } = dirs(t);
|
||||
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
const binary = Buffer.from('made-up proxy binary 0001'), panel = Buffer.from('<html>made-up panel</html>');
|
||||
const pin = { ...PIN, version: '0.0.0-test', binary: { ...PIN.binary, sha256: sha(binary) }, panel: { ...PIN.panel, sha256: sha(panel) } };
|
||||
const paths = installPaths(dataRoot, pin);
|
||||
fs.mkdirSync(join(paths.dir, 'static'), { recursive: true });
|
||||
fs.writeFileSync(paths.binary, binary, { mode: 0o755 }); fs.writeFileSync(paths.panel, panel);
|
||||
const when = 1700000000;
|
||||
for (const file of [paths.binary, paths.panel]) fs.utimesSync(file, when, when);
|
||||
assert.equal(releaseState(dataRoot, pin).verified, true);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const before = fs.statSync(paths.binary, { bigint: true });
|
||||
fs.writeFileSync(paths.binary, Buffer.from('made-up proxy binary 0002'), { flag: 'r+' });
|
||||
fs.utimesSync(paths.binary, when, when);
|
||||
const after = fs.statSync(paths.binary, { bigint: true });
|
||||
assert.deepEqual([after.ino, after.size, after.mtimeNs, after.mode], [before.ino, before.size, before.mtimeNs, before.mode], 'only ctime tells the files apart');
|
||||
const state = releaseState(dataRoot, pin);
|
||||
assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-invalid');
|
||||
});
|
||||
|
||||
test('unit names follow the instance and refuse a bad instance', () => {
|
||||
assert.equal(unitName(null), 'mosaic-proxy.service');
|
||||
assert.equal(unitName('dev'), 'mosaic-proxy.service');
|
||||
|
||||
@@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { actionTrail } from './action-trail.mjs';
|
||||
import { reviveBuses } from './bus-revive.mjs';
|
||||
import { createProxyAdmin } from './proxy-admin.mjs';
|
||||
import { createProxyAdmin, orgCatalogue } from './proxy-admin.mjs';
|
||||
import { busesStopped } from '../../cli/src/proxy-admin-host.mjs';
|
||||
import { agentLevelsWriteRoutes } from './agent-levels-write.mjs';
|
||||
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
|
||||
@@ -207,15 +207,12 @@ try {
|
||||
dataRoot: system.dataRoot, configDir: configDir(), instance, unitDir: options.proxyUnitDir ?? null,
|
||||
buses: () => busesStopped({ dataRoot: system.dataRoot, configDir: configDir() }),
|
||||
orgs: async () => {
|
||||
const catalogue = async call => {
|
||||
try { const value = await call('routes'); return { status: value?.catalogue?.status ?? 'unknown', models: Array.isArray(value?.catalogue?.models) ? value.catalogue.models.length : 0 }; }
|
||||
catch { return { status: 'bus-unavailable', models: 0 }; }
|
||||
};
|
||||
const catalogue = orgCatalogue;
|
||||
if (!organizations) return [{ id: options.business ?? null, proxy: 'shared', catalogue: await catalogue(ownerCall ?? (async () => { throw new Error(); })) }];
|
||||
const out = [];
|
||||
for (const org of await listOrgs({ configDir: configDir(), dataRoot: system.dataRoot, instance })) {
|
||||
if (org.state !== 'active') continue;
|
||||
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: 0 } }); continue; }
|
||||
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: null } }); continue; }
|
||||
out.push({ id: org.id, proxy: 'shared', catalogue: await catalogue(busCaller({ dataRoot: org.root, socket: paths.socket, business: org.id, timeoutMs: 5000 })) });
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -8,6 +8,15 @@ import { createProxyAdmin, proxyCode } from '../../model-proxy/src/admin-control
|
||||
|
||||
export { createProxyAdmin };
|
||||
|
||||
// #1873: one organization's catalogue row for the setup page. A count is shown only when the bus
|
||||
// answered with a model list; otherwise models is null and the page shows a dash, never a made-up 0.
|
||||
export async function orgCatalogue(call) {
|
||||
try {
|
||||
const value = await call('routes');
|
||||
return { status: value?.catalogue?.status ?? 'unknown', models: Array.isArray(value?.catalogue?.models) ? value.catalogue.models.length : null };
|
||||
} catch { return { status: 'bus-unavailable', models: null }; }
|
||||
}
|
||||
|
||||
const ROUTES = Object.freeze({
|
||||
'/api/proxy/admin/status': ['GET', []],
|
||||
'/api/proxy/admin/unit/render': ['GET', []],
|
||||
|
||||
@@ -318,6 +318,11 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
li.append(op, ` ${window.MosaicLocalTime(record.time)}${details ? ' — ' + details : ''}`);
|
||||
list.prepend(li);
|
||||
}
|
||||
// #1873: receipts survive a reload: status carries the last few from proxy-admin.jsonl.
|
||||
function paintReceipts() {
|
||||
if (!Array.isArray(state.receipts) || !state.receipts.length) return;
|
||||
$('proxy-setup-receipts').replaceChildren(); state.receipts.forEach(addReceipt);
|
||||
}
|
||||
function row(dl, term, text) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = term; dd.textContent = text; dl.append(dt, dd); }
|
||||
function paintState() {
|
||||
const { release, init, unit, fence, initLock } = state, dl = $('proxy-setup-state'); dl.replaceChildren();
|
||||
@@ -339,7 +344,7 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
init: ['Initialize the proxy', 'Next: initialize. The bus generates the management key unless you paste your own.', () => { $('proxy-setup-init-box').open = true; form.elements.port.focus(); }],
|
||||
'unit-install': ['Install the proxy service', `Next: install ${state.unit.unit}.`, () => unitAction('install')],
|
||||
'unit-start': ['Start the proxy service', `Next: start ${state.unit.unit}.`, () => unitAction('start')],
|
||||
'unit-restart': ['Restart the proxy service', state.fence.set ? 'A key write had an uncertain outcome, so key writes are fenced. Restart the proxy; after this page confirms the new process, clear the fence.' : 'The service is running but does not answer on its port. Restart it.', () => unitAction('restart')],
|
||||
'unit-restart': ['Restart the proxy service', state.fence.set ? (fence => fence.invalid || !fence.time ? 'A key write had an uncertain outcome, so key writes are fenced. The fence file has no readable time, so a restart from this page cannot be proven against it: restart the proxy service, confirm it answers, then run mosaic proxy fence clear --proxy-restarted.' : 'A key write had an uncertain outcome, so key writes are fenced. Restart the proxy; after this page confirms the new process, clear the fence.')(state.fence) : 'The service is running but does not answer on its port. Restart it.', () => unitAction('restart')],
|
||||
'fence-clear': ['Clear the fence', 'The proxy was restarted from this page and the new process answers on its port. Clear the fence with that evidence.', clearFence],
|
||||
accounts: ['Add an account', 'The proxy is running. Sign in to an account or add an API key below.', () => $('proxy-signin-title')?.scrollIntoView({ block: 'start' })],
|
||||
};
|
||||
@@ -366,11 +371,13 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
$('proxy-setup-own').textContent = state.ownProxy?.available ? 'An organization can run its own proxy.' : `Own proxy per organization: ${state.ownProxy?.state ?? 'not built'}. ${state.ownProxy?.reason ?? ''}`;
|
||||
}
|
||||
function paint() {
|
||||
paintState(); paintNext(); paintControls();
|
||||
paintState(); paintNext(); paintControls(); paintReceipts();
|
||||
// An initialized proxy keeps the init form folded after a reload too.
|
||||
if (state.init.initialized) $('proxy-setup-init-box').open = false;
|
||||
const job = state.fetch;
|
||||
if (job.state === 'running') say(`Fetching: ${STEPS[job.step] ?? job.step ?? 'starting'}…`);
|
||||
else if (job.state === 'failed' && seenFetch !== 'failed') { say(`Fetch failed. ${explain(job.error)}`, 'error'); seenFetch = 'failed'; }
|
||||
else if (job.state === 'done' && job.receipt && seenFetch !== job.receipt.time) { seenFetch = job.receipt.time; addReceipt(job.receipt); say(`Fetched ${job.receipt.version}; archive, panel and executable hashes verified.`, 'success'); }
|
||||
else if (job.state === 'done' && job.receipt && seenFetch !== job.receipt.time) { seenFetch = job.receipt.time; if (!state.receipts?.some(r => r.op === 'fetch' && r.time === job.receipt.time)) addReceipt(job.receipt); say(`Fetched ${job.receipt.version}; archive, panel and executable hashes verified.`, 'success'); }
|
||||
clearTimeout(timer); timer = null;
|
||||
if (enabled && (job.state === 'running' || state.busy)) timer = setTimeout(load, 1000);
|
||||
}
|
||||
@@ -451,7 +458,9 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message);
|
||||
// #1873: on the setup page a stopped proxy hides the account panels like an unconfigured one,
|
||||
// and the setup load brings them back once the service answers.
|
||||
if (proxyAdmin && error.code === 'proxy-down') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true); message('The model proxy is not running yet. Follow the next step under Proxy setup.'); return; }
|
||||
// Any failed read hides them, not only proxy-down: a proxy that is initialized but not yet answering
|
||||
// can fail with another code, and a stale error over an empty table would stay after Start.
|
||||
if (proxyAdmin && error.message !== 'The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true); message(error.code === 'proxy-down' ? 'The model proxy is not running yet. Follow the next step under Proxy setup.' : `Accounts could not be read${error.code ? ` (${error.code})` : ''}. Follow the next step under Proxy setup; the account panels return once the proxy answers.`); return; }
|
||||
if(error.message==='The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true);
|
||||
// #1873: the owner sets the proxy up on this page (Proxy setup above); no terminal note.
|
||||
if (proxyAdmin) { message('The model proxy is not set up yet. Follow the next step under Proxy setup.'); return; }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { PROXY_ADMIN_PATHS, keyTransportOk } from '../src/proxy-admin.mjs';
|
||||
import { PROXY_ADMIN_PATHS, keyTransportOk, orgCatalogue } from '../src/proxy-admin.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { createSsoSession } from './helpers/sso-session.mjs';
|
||||
import { REFUSALS } from '../../bus/src/refusals.mjs';
|
||||
@@ -196,3 +196,10 @@ test('every refusal code a setup route can answer has a refusal-table entry with
|
||||
for (const code of ['human-required', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-key-invalid', 'proxy-config-invalid', 'proxy-rotate-grace-invalid', 'proxy-first-output-invalid', 'proxy-admin-busy', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'not-configured', 'outcome-unknown', 'invalid-request'])
|
||||
assert.equal(typeof REFUSALS[code]?.nextStep, 'string', code);
|
||||
});
|
||||
|
||||
test('an organization whose bus does not answer shows no model count, not a made-up 0', async () => {
|
||||
assert.deepEqual(await orgCatalogue(async () => { throw new Error('down'); }), { status: 'bus-unavailable', models: null });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'disabled' } })), { status: 'disabled', models: null });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [{ id: 'a' }, { id: 'b' }] } })), { status: 'enabled', models: 2 });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [] } })), { status: 'enabled', models: 0 });
|
||||
});
|
||||
@@ -105,3 +105,42 @@ test('a stopped proxy hides the account panels with a setup note, and starting t
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-apikey-title]").hidden'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
|
||||
});
|
||||
|
||||
test('an initialized proxy whose accounts read fails with another code also hides the panels until it answers', { timeout: 90000 }, async t => {
|
||||
const installed = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'inactive' }, next: ['unit-start'] };
|
||||
const failing = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'outcome-unknown' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'outcome-unknown' } } };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(installed), ...failing, 'POST /api/proxy/admin/unit/start': { body: { receipt: { op: 'unit.start', time: T, pid: 4242 } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-start"');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("the account panels return once the proxy answers")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), true);
|
||||
await b.evaluate(`Object.assign(window.__scenario, {
|
||||
'GET /api/proxy/accounts': { body: { accounts: [] } }, 'GET /api/proxy/keys': { body: { keys: [] } },
|
||||
'/api/proxy/admin/status': ${JSON.stringify({ ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true }, next: ['accounts'] })} })`);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("Accounts and client keys loaded")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
|
||||
});
|
||||
|
||||
test('a fence with no readable time names the --proxy-restarted path instead of a page restart', { timeout: 90000 }, async t => {
|
||||
const timeless = { ...fenced, fence: { set: true, invalid: true }, restart: null, next: ['unit-restart'] };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: scenario(timeless) });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-restart"');
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-next p").textContent'), /no readable time.*mosaic proxy fence clear --proxy-restarted/);
|
||||
});
|
||||
|
||||
test('after a reload the receipts come from status, and an initialized proxy folds the init form', { timeout: 90000 }, async t => {
|
||||
const initialized = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
receipts: [{ op: 'fetch', time: T, version: '8.0.42' }, { op: 'init', time: T, generated: true, port: 18432 }], next: ['unit-install'] };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(initialized), 'POST /api/proxy/admin/unit/install': { body: { receipt: { op: 'unit.install', time: T } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-install"');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#proxy-setup-receipts li strong")].map(s=>s.textContent)'), ['init', 'fetch'], 'newest first, from proxy-admin.jsonl');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-init-box").open = true');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/unit/install")');
|
||||
await wait('document.querySelector("#proxy-setup-init-box").open===false');
|
||||
});
|
||||
Reference in new issue
Block a user