110 lines
6.3 KiB
JavaScript
Executable file
110 lines
6.3 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
// Mac side of the remote jobs (app/igneum-app/src/jobs.rs, published by packaging/ota/publish-jobs.sh).
|
|
// node tools/jobs.mjs the published jobs file: fetched from the downloads host, signature checked
|
|
// node tools/jobs.mjs status per machine, from the log intake: the latest job run and its SUMMARY line
|
|
// node tools/jobs.mjs <job id> the result: the newest upload per label under run_id job-<id>-<machine>
|
|
// node tools/jobs.mjs <job id> --all every upload, oldest first (the 5-minute progress reports of a long job)
|
|
// node tools/jobs.mjs watch <job id> poll the intake every 30 s until every reporting machine is final
|
|
// Reads ~/.config/igneum/env (DATABASE_URL, the same Neon HTTP SQL as tools/logs.mjs), dl-token and
|
|
// ota-signing-key.pub. No dependencies.
|
|
import { readFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { createPublicKey, verify } from 'node:crypto';
|
|
|
|
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
|
|
const cfg = n => { try { return readFileSync(`${homedir()}/.config/igneum/${n}`, 'utf8').trim(); } catch { return ''; } };
|
|
|
|
// the same intake reader as tools/logs.mjs (Neon HTTP SQL over fetch)
|
|
function db() {
|
|
const m = /^DATABASE_URL=(.*)$/m.exec(cfg('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', '');
|
|
return async (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 when = ts => new Date(ts).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
|
const summaryOf = lines => { const l = (lines || '').split('\n')[0]; if (!l.startsWith('SUMMARY ')) return null; try { return JSON.parse(l.slice(8)); } catch { return null; } };
|
|
|
|
const [a, b] = process.argv.slice(2);
|
|
|
|
if (!a) {
|
|
const tok = cfg('dl-token');
|
|
if (!tok) { console.error('no ~/.config/igneum/dl-token'); process.exit(1); }
|
|
const base = `https://dl.igneum.network/dl/${tok}`;
|
|
const r = await fetch(`${base}/igneum-jobs.json`);
|
|
if (r.status === 404) { console.log('no jobs file published'); process.exit(0); }
|
|
if (!r.ok) { console.error(`jobs file: http ${r.status}`); process.exit(1); }
|
|
const bytes = Buffer.from(await r.arrayBuffer());
|
|
const sig = (await (await fetch(`${base}/igneum-jobs.json.sig`)).text()).trim();
|
|
const pub = cfg('ota-signing-key.pub');
|
|
let verified = 'not checked (no ~/.config/igneum/ota-signing-key.pub)';
|
|
if (pub) {
|
|
const key = createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(pub, 'hex')]), format: 'der', type: 'spki' });
|
|
verified = verify(null, bytes, key, Buffer.from(sig, 'hex')) ? 'signature OK' : 'SIGNATURE DOES NOT VERIFY (the apps refuse this file)';
|
|
}
|
|
const f = JSON.parse(bytes.toString('utf8'));
|
|
console.log(`${base}/igneum-jobs.json: published ${f.published_at}, ${f.jobs.length} job(s), ${verified}`);
|
|
const now = Date.now();
|
|
for (const j of f.jobs) {
|
|
const exp = Date.parse(j.expires_at);
|
|
const t = j.target || {};
|
|
console.log(` ${exp < now ? 'EXPIRED ' : ''}${j.id} ${j.kind} to ${Array.isArray(t.machine_ids) ? t.machine_ids.join(',') : t.machine_ids} on ${t.platform || 'any'}${t.requires && t.requires.length ? ' needs ' + t.requires.join(',') : ''} until ${j.expires_at} ${j.title || ''}`);
|
|
const p = JSON.stringify(j.params || {});
|
|
console.log(` ${p.length > 180 ? p.slice(0, 180) + '...' : p}`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
const sql = db();
|
|
|
|
if (a === 'status') {
|
|
const rows = await sql(`
|
|
SELECT DISTINCT ON (machine) machine, run_id, label, received_at, lines FROM miner_logs
|
|
WHERE run_id LIKE 'job-%' AND label LIKE 'job-%' ORDER BY machine, received_at DESC`);
|
|
if (!rows.length) { console.log('no job reports in the intake yet'); process.exit(0); }
|
|
for (const r of rows) {
|
|
const s = summaryOf(r.lines);
|
|
console.log(`${r.machine.padEnd(28)} ${r.run_id.padEnd(44)} ${when(r.received_at)} ${s ? `${s.status} exit ${s.exit} after ${s.duration_s} s: ${s.summary}` : '(no SUMMARY line)'}`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
async function show(id, all) {
|
|
const rows = all
|
|
? await sql('SELECT id, run_id, received_at, label, machine, bytes, lines FROM miner_logs WHERE run_id LIKE $1 ORDER BY received_at ASC', [`job-${id}-%`])
|
|
: await sql('SELECT DISTINCT ON (run_id, label) id, run_id, received_at, label, machine, bytes, lines FROM miner_logs WHERE run_id LIKE $1 ORDER BY run_id, label, received_at DESC', [`job-${id}-%`]);
|
|
if (!rows.length) return null;
|
|
const final = [];
|
|
for (const r of rows) {
|
|
console.log(`===== id ${r.id} | ${r.run_id} | ${r.label} | ${r.machine} | ${when(r.received_at)} | ${r.bytes} bytes =====`);
|
|
const s = summaryOf(r.lines);
|
|
if (s) {
|
|
console.log(`SUMMARY: ${s.status} exit ${s.exit}, started ${s.started_at}${s.finished_at ? ', finished ' + s.finished_at : ''}, ${s.duration_s} s: ${s.summary}`);
|
|
if (s.results && s.results.length) for (const l of s.results) console.log(` ${l}`);
|
|
final.push(['done', 'failed', 'timeout', 'aborted'].includes(s.status));
|
|
if (!all) { const rest = r.lines.split('\n').slice(1); const tail = rest.slice(-30); console.log(tail.join('\n')); if (rest.length > 30) console.log(` (... ${rest.length - 30} more lines; --all prints every upload)`); }
|
|
else process.stdout.write(r.lines.endsWith('\n') ? r.lines : r.lines + '\n');
|
|
} else {
|
|
process.stdout.write(r.lines.endsWith('\n') ? r.lines : r.lines + '\n');
|
|
}
|
|
}
|
|
return final;
|
|
}
|
|
|
|
if (a === 'watch') {
|
|
if (!b) { console.error('watch <job id>'); process.exit(1); }
|
|
for (;;) {
|
|
const f = await show(b, false);
|
|
if (f && f.length && f.every(Boolean)) { console.log('final'); process.exit(0); }
|
|
console.log(`${f ? 'still running' : 'nothing yet'}; again in 30 s (Ctrl+C to stop)`);
|
|
await new Promise(r => setTimeout(r, 30000));
|
|
}
|
|
}
|
|
|
|
const f = await show(a, b === '--all');
|
|
if (!f) { console.error(`no uploads for job ${a} (run_id job-${a}-<machine>); the app reports when the job starts and every 5 minutes`); process.exit(1); }
|