logs.mjs --rotation reads the old fingerprints from the dated .old-* files once the .next files are gone; fresh-repo.sh scrubs the dated secret files too; ship-app.mjs mirrors payload-inputs.json.sig. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
132 lines
9.3 KiB
JavaScript
Executable file
132 lines
9.3 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
// Reader for the miner log intake (site/api/log.mjs). Runs on the Mac.
|
|
// node tools/logs.mjs list runs: label, machine, run_id, last received, total bytes
|
|
// node tools/logs.mjs <run_id> print the latest upload for that run
|
|
// node tools/logs.mjs <run_id> --all print every upload for that run, oldest first
|
|
// node tools/logs.mjs --rotation rotation phase 2 (docs/plans/rotation-phase-2.md): per app machine (labels mac-*,
|
|
// win-*), the version and the "config:" header line of its latest upload (the
|
|
// intake key's fingerprint and the downloads folder's fingerprint), against the
|
|
// fingerprints of ~/.config/igneum/log-intake-key.next and dl-token.next; exit 1
|
|
// while any machine still reports with the old values
|
|
// node tools/logs.mjs --self-test the header parser on sample lines
|
|
// Reads DATABASE_URL from ~/.config/igneum/env. No dependencies: Neon HTTP SQL over fetch.
|
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
// the two header lines the engine logs at every start (app/igneum-app/src/engine.rs run(), config.rs describe()):
|
|
// IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=...
|
|
// config: intake https://.../api/log key 477bb0ef (packaged); manifest https://.../dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)
|
|
// The LAST occurrence wins (the restart after an OTA apply logs them again). Fields missing from the upload read ''.
|
|
export function parseRotation(lines) {
|
|
const out = { version: '', keyFp: '', keySource: '', folderFp: '', manifestSource: '' };
|
|
for (const line of String(lines).split('\n')) {
|
|
let m = /IGNEUM-APP version=(\S+)/.exec(line);
|
|
if (m) out.version = m[1];
|
|
m = /config: intake \S+ (?:key ([0-9a-f]{8})|no key) \((packaged|file [^)]+|none)\); manifest \S+ (?:folder ([0-9a-f]{8})|no folder|custom) \((packaged|file [^)]+|none)\)/.exec(line);
|
|
if (m) { out.keyFp = m[1] || ''; out.keySource = m[2]; out.folderFp = m[3] || ''; out.manifestSource = m[4]; }
|
|
}
|
|
return out;
|
|
}
|
|
// the first 8 hex of sha256 over the trimmed file content; '' when the file is missing (the app's config::fingerprint8)
|
|
export function fingerprintFile(path) {
|
|
if (!existsSync(path)) return '';
|
|
const v = readFileSync(path, 'utf8').trim();
|
|
return v ? createHash('sha256').update(v).digest('hex').slice(0, 8) : '';
|
|
}
|
|
|
|
if (process.argv[2] === '--self-test') {
|
|
let fails = 0;
|
|
const check = (name, ok, detail = '') => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ': ' + detail : ''}`); if (!ok) fails++; };
|
|
const sample = ['1 Igneum Miner 0.3.6 on pc (machine id 1ccfe586abcdef01), devnet (run win-1ccfe586-x)',
|
|
'1 IGNEUM-APP version=0.3.5 machine=1ccfe586 platform=windows node=igneumd_0.3.5',
|
|
'1 config: intake https://igneum-six.vercel.app/api/log key e2005de8 (packaged); manifest https://dl.igneum.network/dl/<token>/igneum-app-latest.json folder df66a82c (packaged)',
|
|
'2 update: applied', '2 IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=igneumd_0.3.6',
|
|
'2 config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (file log-intake-key.next); manifest https://dl.igneum.network/dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)'].join('\n');
|
|
const r = parseRotation(sample);
|
|
check('the last header wins', r.version === '0.3.6' && r.keyFp === '477bb0ef' && r.folderFp === 'ed9c4d2e', JSON.stringify(r));
|
|
check('sources are read', r.keySource === 'file log-intake-key.next' && r.manifestSource === 'packaged', JSON.stringify(r));
|
|
const none = parseRotation('1 config: intake none no key (none); manifest none no folder (none)\n');
|
|
check('a build without key or folder reads empty fingerprints', none.keyFp === '' && none.folderFp === '' && none.keySource === 'none', JSON.stringify(none));
|
|
const custom = parseRotation('1 config: intake https://x/api/log key 01234567 (packaged); manifest http://127.0.0.1:9/dl/t/igneum-app-latest.json custom (packaged)\n');
|
|
check('a custom manifest URL reads no folder', custom.keyFp === '01234567' && custom.folderFp === '', JSON.stringify(custom));
|
|
check('an old upload without the config line reads version only', (() => { const o = parseRotation('1 IGNEUM-APP version=0.3.4 machine=a platform=mac node=x\n'); return o.version === '0.3.4' && o.keyFp === '' && o.keySource === ''; })());
|
|
check('fingerprintFile of a missing file is empty', fingerprintFile('/nonexistent/igneum/key') === '');
|
|
console.log(fails ? `${fails} check(s) failed` : 'all checks passed');
|
|
process.exit(fails ? 1 : 0);
|
|
}
|
|
|
|
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
|
|
|
|
const env = readFileSync(`${homedir()}/.config/igneum/env`, 'utf8');
|
|
const m = /^DATABASE_URL=(.*)$/m.exec(env);
|
|
if (!m) { console.error('DATABASE_URL not found in ~/.config/igneum/env'); process.exit(1); }
|
|
const url = m[1].trim().replace(/^['"]|['"]$/g, '');
|
|
const host = new URL(url).hostname.replace('-pooler', '');
|
|
|
|
async function sql(query, params = []) {
|
|
const r = await fetch(`https://${host}/sql`, {
|
|
method: 'POST',
|
|
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, params }),
|
|
});
|
|
const j = await r.json();
|
|
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
|
return j.rows;
|
|
}
|
|
|
|
const [runId, flag] = process.argv.slice(2);
|
|
|
|
if (runId === '--rotation') {
|
|
const cfg = `${homedir()}/.config/igneum`;
|
|
const want = { key: fingerprintFile(`${cfg}/log-intake-key.next`) || fingerprintFile(`${cfg}/log-intake-key`), folder: fingerprintFile(`${cfg}/dl-token.next`) || fingerprintFile(`${cfg}/dl-token`) };
|
|
// the old values: the plain files while the .next files exist (phase 2 before the rename); after the rename
|
|
// (plan section 5 step 2) the newest dated copy, log-intake-key.old-<date> and dl-token.old-<date>
|
|
const dated = name => { try { return readdirSync(cfg).filter(f => f.startsWith(`${name}.old-`)).sort().reverse().map(f => `${cfg}/${f}`)[0] || ''; } catch { return ''; } };
|
|
const renamed = !existsSync(`${cfg}/log-intake-key.next`) && !existsSync(`${cfg}/dl-token.next`);
|
|
const old = renamed
|
|
? { key: fingerprintFile(dated('log-intake-key')), folder: fingerprintFile(dated('dl-token')) }
|
|
: { key: fingerprintFile(`${cfg}/log-intake-key`), folder: fingerprintFile(`${cfg}/dl-token`) };
|
|
// the latest upload per app label (mac-<id8>, win-<id8>); the header lines sit in the first bytes, the config line
|
|
// may repeat after an OTA restart, so the whole upload is parsed
|
|
const rows = await sql(`
|
|
SELECT DISTINCT ON (label) label, machine, run_id, received_at, lines
|
|
FROM miner_logs WHERE label LIKE 'mac-%' OR label LIKE 'win-%'
|
|
ORDER BY label, received_at DESC`);
|
|
if (!rows.length) { console.log('No app uploads yet.'); process.exit(1); }
|
|
let moved = 0, stale = 0;
|
|
const table = rows.map(r => {
|
|
const p = parseRotation(r.lines);
|
|
const ok = p.keyFp === want.key && p.folderFp === want.folder;
|
|
if (ok) moved++; else stale++;
|
|
return { label: r.label, machine: r.machine, version: p.version || '?', key: p.keyFp || '-', folder: p.folderFp || '-', sources: [p.keySource, p.manifestSource].filter(Boolean).join(' / ') || '-', last_received: new Date(r.received_at).toISOString().replace('T', ' ').slice(0, 19) + ' UTC', state: ok ? 'moved' : p.keyFp === old.key || p.folderFp === old.folder ? 'OLD' : 'unknown' };
|
|
}).sort((a, b) => a.state.localeCompare(b.state) || a.label.localeCompare(b.label));
|
|
console.table(table);
|
|
console.log(`expected: key ${want.key || '?'} folder ${want.folder || '?'} (from the .next files when they exist, else the plain files); old: key ${old.key || '?'} folder ${old.folder || '?'} (${renamed ? 'from the dated .old-* files' : 'from the plain files'})`);
|
|
console.log(`${moved} machine(s) on the new key and folder, ${stale} not yet; a machine silent for over a day is listed by its last upload`);
|
|
process.exit(stale ? 1 : 0);
|
|
}
|
|
|
|
if (!runId) {
|
|
const rows = await sql(`
|
|
SELECT run_id, max(label) AS label, max(machine) AS machine, count(*)::int AS uploads,
|
|
max(received_at) AS last_received, sum(bytes)::bigint AS total_bytes
|
|
FROM miner_logs GROUP BY run_id ORDER BY max(received_at) DESC LIMIT 100`);
|
|
if (!rows.length) { console.log('No runs yet.'); process.exit(0); }
|
|
const fmt = rows.map(r => ({
|
|
run_id: r.run_id, label: r.label, machine: r.machine, uploads: r.uploads,
|
|
last_received: new Date(r.last_received).toISOString().replace('T', ' ').slice(0, 19) + ' UTC',
|
|
total_bytes: Number(r.total_bytes),
|
|
}));
|
|
console.table(fmt);
|
|
process.exit(0);
|
|
}
|
|
|
|
const rows = flag === '--all'
|
|
? await sql('SELECT id, received_at, label, machine, bytes, lines FROM miner_logs WHERE run_id = $1 ORDER BY received_at ASC', [runId])
|
|
: await sql('SELECT id, received_at, label, machine, bytes, lines FROM miner_logs WHERE run_id = $1 ORDER BY received_at DESC LIMIT 1', [runId]);
|
|
if (!rows.length) { console.error(`No uploads for run_id ${runId}`); process.exit(1); }
|
|
for (const r of rows) {
|
|
console.log(`===== id ${r.id} | ${r.label} | ${r.machine} | ${new Date(r.received_at).toISOString()} | ${r.bytes} bytes =====`);
|
|
process.stdout.write(r.lines.endsWith('\n') ? r.lines : r.lines + '\n');
|
|
}
|