igneum/tools/fleet/first-share-gate.mjs

95 lines
6.8 KiB
JavaScript
Executable file

#!/usr/bin/env node
// Launch gate LG-4 as a job: "first share within 10 minutes of download in 9 of 10 fresh Windows installs"
// (docs/plans/miner-ui-5-first-share-runbook.md, docs/plans/miner-faults.md). Runs relay/playbooks/first-share.ps1 on
// Windows boxes over SSH, ten installs in all, and writes the evidence table the shipper's publish reads.
//
// node tools/fleet/first-share-gate.mjs run --version 0.3.20 --url https://dl.igneum.network/<token>/Igneum-Miner-Setup-0.3.20.exe
// [--boxes ~/igneum-fleet/windows-boxes.json] [--installs 10] [--allow-installed]
// node tools/fleet/first-share-gate.mjs check --version 0.3.20 exit 0 on a PASS line for that version, 1 otherwise
//
// Boxes file: a JSON array of the fleet registry's Windows rows ({kind:"windows", label, host, port, user, key, transport:"ssh"});
// with no row the gate writes "LG-4 not run: no Windows box" and `check` fails. Each box runs installs in turn; boxes run in
// parallel. Output: site/evidence/first-share-<version>.json (the runbook's keys per row, the medians, the pass count, the
// installer's sha256) and the gate line on stdout and in the file: "LG-4 PASS k/10" or "LG-4 FAIL k/10".
// The tenth install of a run is the KEPT-datadir case (MF-8): the installer runs over the ninth install with its datadir
// kept, and the node must come up on it; that row carries kept=true. The other nine are wiped.
// Never touches PC 1 (the project lead's desk) and refuses a box whose installed app would be replaced unless --allow-installed is given
// and the box's row says "job_box": true (PC 2's case needs the project lead's word, recorded in the row by the fleet lane).
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const args = process.argv.slice(2);
const cmd = args[0];
const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const flag = (n) => args.includes(n);
const version = opt('--version');
if (!version) { console.error('need --version'); process.exit(2); }
const evidence = join(ROOT, 'site', 'evidence', `first-share-${version}.json`);
function gateLine(rows, installs) {
const passed = rows.filter(r => r.pass).length;
return `LG-4 ${passed >= Math.ceil(installs * 0.9) ? 'PASS' : 'FAIL'} ${passed}/${installs}`;
}
if (cmd === 'check') {
if (!existsSync(evidence)) { console.log(`LG-4 not run for ${version}: no ${evidence}`); process.exit(1); }
const e = JSON.parse(readFileSync(evidence, 'utf8'));
console.log(e.gate);
process.exit(/^LG-4 PASS/.test(e.gate) ? 0 : 1);
}
if (cmd !== 'run') { console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 16).map(l => l.replace(/^\/\/ ?/, '')).join('\n')); process.exit(2); }
const url = opt('--url');
if (!url) { console.error('need --url (the public installer)'); process.exit(2); }
const installs = Number(opt('--installs', '10'));
const boxesFile = opt('--boxes', join(homedir(), 'igneum-fleet', 'windows-boxes.json')).replace(/^~/, homedir());
const boxes = existsSync(boxesFile) ? JSON.parse(readFileSync(boxesFile, 'utf8')).filter(b => b.kind === 'windows' && b.transport === 'ssh' && b.host) : [];
mkdirSync(dirname(evidence), { recursive: true });
if (!boxes.length) {
const out = { version, url, date: new Date().toISOString(), installs, rows: [], gate: 'LG-4 not run: no Windows box', note: 'the fleet lane has no rented Windows GPU box; PC 2 over the relay needs the owner\'s word (docs/plans/miner-faults.md)' };
writeFileSync(evidence, JSON.stringify(out, null, 2) + '\n');
console.log(out.gate);
process.exit(1);
}
for (const b of boxes) {
if (/ae432dc7|pc1|pc-1/i.test(`${b.label} ${b.machine || ''}`)) { console.error(`refusing ${b.label}: PC 1 is never a job box`); process.exit(2); }
}
const allowInstalled = flag('--allow-installed');
const parse = (text) => {
const r = { pass: false };
for (const l of text.split('\n')) {
const m = /^RESULT (.*)$/.exec(l.trim()); if (!m) continue;
for (const kv of m[1].split(/\s+/)) { const [k, v] = kv.split('='); if (k === 'step') r._step = v; else if (r._step && k !== 'step') r[`${r._step}_${k}`] = v; else r[k] = v; }
}
r.pass = r.pass === 'true';
r.total_s = Number(r.total_s || 0);
return r;
};
const rows = [];
const perBox = Math.ceil(installs / boxes.length);
const started = Date.now();
// boxes in parallel (one ssh each, installs in turn inside it), the Mac waits
const procs = boxes.map((b) => {
const n = Math.min(perBox, installs - rows.length);
// the last install on the first box keeps the previous install's datadir (-Keep): the MF-8 row
const remote = `$ErrorActionPreference='Continue'; $f=\"$env:TEMP\\first-share.ps1\"; [IO.File]::WriteAllText($f, [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(readFileSync(join(ROOT, 'relay', 'playbooks', 'first-share.ps1'))).toString('base64')}'))); 1..${n} | ForEach-Object { Write-Host \"INSTALL $_\"; $keep = if ($_ -eq ${n} -and ${b === boxes[0] ? '$true' : '$false'}) { '-Keep' } else { '' }; powershell -ExecutionPolicy Bypass -File $f -Url '${url}' ${allowInstalled && b.job_box ? '-AllowInstalled' : ''} $keep }`;
const key = (b.key || '~/.ssh/igneum-fleet').replace(/^~/, homedir());
const p = spawnSync('ssh', ['-o', 'ConnectTimeout=20', '-o', 'StrictHostKeyChecking=accept-new', '-i', key, '-p', String(b.port || 22), `${b.user}@${b.host}`, 'powershell', '-NoProfile', '-Command', remote], { encoding: 'utf8', timeout: (n * 1900 + 120) * 1000 });
return { box: b, out: (p.stdout || '') + (p.stderr || ''), status: p.status };
});
for (const { box, out } of procs) {
for (const chunk of out.split(/^INSTALL \d+\r?$/m).slice(1)) {
const r = parse(chunk); r.box = box.label; r.gpu = box.gpu || ''; r.kept = /kept=true/.test(chunk); rows.push(r);
}
if (!out.includes('RESULT')) rows.push({ box: box.label, gpu: box.gpu || '', pass: false, total_s: 0, error: out.trim().split('\n').slice(-3).join(' | ') });
}
const med = (k) => { const v = rows.map(r => Number(r[k])).filter(x => x > 0).sort((a, b) => a - b); return v.length ? v[Math.floor(v.length / 2)] : null; };
const out = { version, url, date: new Date().toISOString(), installs, boxes: boxes.map(b => b.label), rows, median_total_s: med('total_s'), pass_count: rows.filter(r => r.pass).length, sha256: rows.find(r => r.download_sha256)?.download_sha256 || '', gate: gateLine(rows, installs), took_s: Math.round((Date.now() - started) / 1000) };
writeFileSync(evidence, JSON.stringify(out, null, 2) + '\n');
console.log(out.gate);
process.exit(/^LG-4 PASS/.test(out.gate) ? 0 : 1);