@a11ign/screenreader-fleet 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/code-drift.d.mts +4 -3
- package/dist/compare-workers.d.mts +17 -1
- package/dist/compare-workers.mjs +30 -9
- package/dist/doctor.d.mts +19 -1
- package/dist/doctor.mjs +28 -7
- package/dist/fleet-env.d.mts +22 -1
- package/dist/fleet-env.mjs +43 -8
- package/dist/index.mjs +3 -1
- package/dist/worker-code-check.mjs +2 -2
- package/package.json +2 -3
- package/src/local-worker/worker-ctl.sh +1 -1
- package/src/provisioning/bootstrap-windows-worker.ps1 +1 -1
- package/dist/deploy-worker.d.mts +0 -2
- package/dist/deploy-worker.mjs +0 -192
- package/dist/src_utm-deprecated_mjs.mjs +0 -4
package/dist/code-drift.d.mts
CHANGED
|
@@ -31,9 +31,10 @@ export function codeDrift(expected: string, readings: Array<{
|
|
|
31
31
|
/**
|
|
32
32
|
* Name the deploy route that can actually reach these workers.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
* `utmctl file push` plus a `utmctl` reboot
|
|
36
|
-
*
|
|
34
|
+
* Only bare-metal workers have one: they are git-cloned and deploy by PULLING, through Ansible. The UTM
|
|
35
|
+
* route (`a11ign-worker-deploy`, `utmctl file push` plus a `utmctl` reboot, keyed on a VM UUID) is gone: it
|
|
36
|
+
* pushed from the worker package's `src`, which a built install does not have (#3765), and no fleet box
|
|
37
|
+
* was ever reachable by it.
|
|
37
38
|
*
|
|
38
39
|
* This printed the utmctl advice unconditionally, including to a fleet of four mini PCs where none of it
|
|
39
40
|
* applies — a tool confidently prescribing a remedy for a different kind of machine. Which kind a worker is
|
|
@@ -1,2 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Where the report is written: under the caller's `--runs-dir=`, else the monorepo's own `runs/`, else a refusal naming
|
|
4
|
+
* the directory and the flag (a11ign/a11ign#3767).
|
|
5
|
+
*
|
|
6
|
+
* Resolved from THIS module, never the cwd -- same reason as `doctor.mjs`'s `runsDirFor`, which this duplicates because
|
|
7
|
+
* this package cannot import `@a11ign/lab`'s canonical `runs/` resolution without a dependency cycle. From an INSTALLED
|
|
8
|
+
* package `../../../` is `node_modules`, so the default would write the report under the dependency. A supplied
|
|
9
|
+
* directory need not exist: the report's own `mkdirSync` makes it, which is what asking for one means.
|
|
10
|
+
* `baseDir` is INJECTED so a test can stand in for either layout.
|
|
11
|
+
*
|
|
12
|
+
* @param {{ argv?: readonly string[], baseDir?: string }} [options]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function outDirFor({ argv, baseDir }?: {
|
|
16
|
+
argv?: readonly string[];
|
|
17
|
+
baseDir?: string;
|
|
18
|
+
}): string;
|
package/dist/compare-workers.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
6
|
import { describeProbe, probeHealth, WORKER_PROBE_TIMEOUT_MS } from "./probe-outcome.mjs";
|
|
7
7
|
import { CAPTURE_CLIENT_TIMEOUT_MS, requestJson } from "./worker-http.mjs";
|
|
8
|
-
import { refuseUnknownFlags } from "./cli-flags.mjs";
|
|
8
|
+
import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
|
|
9
9
|
async function refuseIfBusy(workers, { what, timeoutMs = WORKER_PROBE_TIMEOUT_MS, request }) {
|
|
10
10
|
const states = await Promise.all(workers.map(async (worker)=>{
|
|
11
11
|
const probe = await probeHealth(worker, {
|
|
@@ -221,12 +221,22 @@ function diffHost(before, after) {
|
|
|
221
221
|
}
|
|
222
222
|
refuseUnknownFlags([
|
|
223
223
|
"--rounds=",
|
|
224
|
-
"--runs="
|
|
224
|
+
"--runs=",
|
|
225
|
+
"--runs-dir="
|
|
225
226
|
], {
|
|
226
227
|
entry: import.meta.url,
|
|
227
228
|
command: "npm run worker:compare"
|
|
228
229
|
});
|
|
229
|
-
const
|
|
230
|
+
const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
231
|
+
const isMonorepoRoot = (root)=>existsSync(resolve(root, "packages")) && existsSync(resolve(root, "package.json"));
|
|
232
|
+
function outDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
|
|
233
|
+
const supplied = flagValue(argv, "runs-dir");
|
|
234
|
+
if ("" === supplied) throw new Error("worker:compare: --runs-dir= is empty. Pass --runs-dir=<the runs directory>.");
|
|
235
|
+
if (void 0 !== supplied) return resolve(supplied, "worker-compare");
|
|
236
|
+
const root = resolve(baseDir, "..", "..", "..");
|
|
237
|
+
if (isMonorepoRoot(root)) return resolve(root, "runs", "worker-compare");
|
|
238
|
+
throw new Error(`worker:compare: no runs directory: ${root} is not a checkout (this is an installed package), so there is no runs/ to write worker-compare/ under. Pass --runs-dir=<the runs directory>.`);
|
|
239
|
+
}
|
|
230
240
|
const MS_PER_S = 1000;
|
|
231
241
|
function refuseIfRunsReadonly() {
|
|
232
242
|
if ("1" !== process.env.A11Y_RUNS_READONLY) return;
|
|
@@ -234,6 +244,14 @@ function refuseIfRunsReadonly() {
|
|
|
234
244
|
console.error(" Unset A11Y_RUNS_READONLY to run this for real.");
|
|
235
245
|
process.exit(3);
|
|
236
246
|
}
|
|
247
|
+
function refuseUnlessOutDir() {
|
|
248
|
+
try {
|
|
249
|
+
return outDirFor();
|
|
250
|
+
} catch (error) {
|
|
251
|
+
process.stderr.write(`${error.message}\n`);
|
|
252
|
+
return process.exit(2);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
237
255
|
const compare_workers_args = process.argv.slice(2);
|
|
238
256
|
const runs = Number(compare_workers_args.find((a)=>a.startsWith("--rounds="))?.slice(9) ?? compare_workers_args.find((a)=>a.startsWith("--runs="))?.slice(7) ?? 6);
|
|
239
257
|
const [page, ...compare_workers_workers] = compare_workers_args.filter((a)=>!a.startsWith("--"));
|
|
@@ -297,6 +315,7 @@ async function main() {
|
|
|
297
315
|
process.stderr.write("usage: npm run worker:compare -- <page-url> <worker> <worker> [--rounds=6]\n");
|
|
298
316
|
process.exit(2);
|
|
299
317
|
}
|
|
318
|
+
const outDir = refuseUnlessOutDir();
|
|
300
319
|
refuseIfRunsReadonly();
|
|
301
320
|
await refuseIfBusy(compare_workers_workers, {
|
|
302
321
|
what: `worker:compare against ${page}`
|
|
@@ -334,10 +353,11 @@ async function main() {
|
|
|
334
353
|
results,
|
|
335
354
|
vitalsBefore,
|
|
336
355
|
vitalsAfter,
|
|
337
|
-
hostBefore
|
|
356
|
+
hostBefore,
|
|
357
|
+
outDir
|
|
338
358
|
});
|
|
339
359
|
}
|
|
340
|
-
function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
|
|
360
|
+
function report({ results, vitalsBefore, vitalsAfter, hostBefore, outDir }) {
|
|
341
361
|
const allPhases = [
|
|
342
362
|
...new Set(Object.values(results).flatMap((r)=>r.phases.flatMap(Object.keys)))
|
|
343
363
|
];
|
|
@@ -355,10 +375,10 @@ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
|
|
|
355
375
|
const { foundations, hostAfter } = reportFoundations({
|
|
356
376
|
hostBefore
|
|
357
377
|
});
|
|
358
|
-
mkdirSync(
|
|
378
|
+
mkdirSync(outDir, {
|
|
359
379
|
recursive: true
|
|
360
380
|
});
|
|
361
|
-
writeFileSync(resolve(
|
|
381
|
+
writeFileSync(resolve(outDir, "compare.json"), JSON.stringify({
|
|
362
382
|
page: page,
|
|
363
383
|
rounds: runs,
|
|
364
384
|
results,
|
|
@@ -368,7 +388,7 @@ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
|
|
|
368
388
|
hostBefore,
|
|
369
389
|
hostAfter
|
|
370
390
|
}, null, 2) + "\n", "utf8");
|
|
371
|
-
process.stdout.write(`\nReport: ${resolve(
|
|
391
|
+
process.stdout.write(`\nReport: ${resolve(outDir, "compare.json")}\n`);
|
|
372
392
|
}
|
|
373
393
|
function reportWallTime({ results, vitalsBefore, vitalsAfter }) {
|
|
374
394
|
const wallSeconds = Object.fromEntries(compare_workers_workers.map((w)=>[
|
|
@@ -458,3 +478,4 @@ function reportFoundations({ hostBefore }) {
|
|
|
458
478
|
};
|
|
459
479
|
}
|
|
460
480
|
if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href) await main();
|
|
481
|
+
export { outDirFor };
|
package/dist/doctor.d.mts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `runs/` directory doctor reads the dataset from: the caller's `--runs-dir=`, else the monorepo's own `runs/`,
|
|
4
|
+
* else a refusal naming the directory and the flag (a11ign/a11ign#3767).
|
|
5
|
+
*
|
|
6
|
+
* `@a11ign/lab` owns the canonical `runs/` resolution (`packages/lab/src/dataset-paths.mjs`), but `lab` depends on
|
|
7
|
+
* `worker-fleet`, so this package cannot import it without a cycle: the same computation, duplicated (and again in
|
|
8
|
+
* `compare-workers.mjs`) rather than left cwd-anchored. From an INSTALLED package `../../../` is `node_modules`, so the
|
|
9
|
+
* default is taken only when that directory is a checkout; a missing `runs/` inside one is normal (a fresh clone).
|
|
10
|
+
* `baseDir` is INJECTED so a test can stand in for either layout.
|
|
11
|
+
*
|
|
12
|
+
* @param {{ argv?: readonly string[], baseDir?: string }} [options]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function runsDirFor({ argv, baseDir }?: {
|
|
16
|
+
argv?: readonly string[];
|
|
17
|
+
baseDir?: string;
|
|
18
|
+
}): string;
|
|
2
19
|
/**
|
|
3
20
|
* The remedy for a failed local-pool query, as a runnable command plus the advice that is not one.
|
|
4
21
|
*
|
|
@@ -198,7 +215,7 @@ export function errorDocument(error: unknown): {
|
|
|
198
215
|
* The whole run, as a function of its steps and its streams, returning an exit code.
|
|
199
216
|
*
|
|
200
217
|
* @param {{ steps?: (() => unknown)[], json?: boolean, out?: (line: string) => void,
|
|
201
|
-
* err?: (line: string) => void }} [deps]
|
|
218
|
+
* err?: (line: string) => void, runsDir?: () => string }} [deps]
|
|
202
219
|
* @returns {Promise<number>}
|
|
203
220
|
*/
|
|
204
221
|
export function doctorRun(deps?: {
|
|
@@ -206,6 +223,7 @@ export function doctorRun(deps?: {
|
|
|
206
223
|
json?: boolean;
|
|
207
224
|
out?: (line: string) => void;
|
|
208
225
|
err?: (line: string) => void;
|
|
226
|
+
runsDir?: () => string;
|
|
209
227
|
}): Promise<number>;
|
|
210
228
|
export function addCheck(name: string, ok: boolean, detail: string, remedy?: string | null | {
|
|
211
229
|
fix: string | null;
|
package/dist/doctor.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { availableHostMemoryMb, workersHostCanRun } from "./host-capacity.mjs";
|
|
|
10
10
|
import { fleetConsistency, describeMismatches } from "./fleet-consistency.mjs";
|
|
11
11
|
import { fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
|
|
12
12
|
import { namedInventoryWorkers, configuredWorkers } from "./fleet-env.mjs";
|
|
13
|
-
import { refuseUnknownFlags } from "./cli-flags.mjs";
|
|
13
|
+
import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
|
|
14
14
|
import { requestJson } from "./worker-http.mjs";
|
|
15
15
|
import { sandboxGitEnv } from "./src_git-safe-env_mjs.mjs";
|
|
16
16
|
import { assessWorker } from "./worker-health.mjs";
|
|
@@ -72,18 +72,32 @@ function shimInvocation(shim, script, args) {
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
refuseUnknownFlags([
|
|
75
|
-
"--json"
|
|
75
|
+
"--json",
|
|
76
|
+
"--runs-dir="
|
|
76
77
|
], {
|
|
77
78
|
entry: import.meta.url,
|
|
78
79
|
command: "pnpm run doctor"
|
|
79
80
|
});
|
|
80
81
|
const doctor_run = promisify(execFile);
|
|
81
82
|
const JSON_OUT = process.argv.includes("--json");
|
|
83
|
+
const REFUSED = 2;
|
|
82
84
|
const WORKERS_ENV = configuredWorkers();
|
|
83
85
|
const PAGES_PORT = Number(process.env.DATASET_PAGES_PORT || 5050);
|
|
84
86
|
const CTL = fleetScriptPaths().workerCtl;
|
|
85
87
|
const SCORER_MODEL_DIR = fileURLToPath(new URL("../../scorer/models/screenreader-scorer/", import.meta.url));
|
|
86
|
-
const
|
|
88
|
+
const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
89
|
+
const isMonorepoRoot = (root)=>external_node_fs_existsSync(resolve(root, "packages")) && external_node_fs_existsSync(resolve(root, "package.json"));
|
|
90
|
+
function runsDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
|
|
91
|
+
const supplied = flagValue(argv, "runs-dir");
|
|
92
|
+
if (void 0 !== supplied) {
|
|
93
|
+
if (external_node_fs_existsSync(supplied)) return resolve(supplied);
|
|
94
|
+
throw new Error(`doctor: --runs-dir=${supplied} does not exist. Pass --runs-dir=<the runs directory>.`);
|
|
95
|
+
}
|
|
96
|
+
const root = resolve(baseDir, "..", "..", "..");
|
|
97
|
+
if (isMonorepoRoot(root)) return resolve(root, "runs");
|
|
98
|
+
throw new Error(`doctor: no runs directory: ${root} is not a checkout (this is an installed package), so there is no runs/ to default to. Pass --runs-dir=<the runs directory>.`);
|
|
99
|
+
}
|
|
100
|
+
const datasetDir = ()=>resolve(runsDirFor(), "screenreader-dataset");
|
|
87
101
|
const PROBE_TIMEOUT_MS = 8000;
|
|
88
102
|
const checks = [];
|
|
89
103
|
const GATES = Object.freeze({
|
|
@@ -419,7 +433,7 @@ function checkHostCapacity(pool) {
|
|
|
419
433
|
add("host memory", true, limit >= poolSize ? detail : `${detail}; the rest stay stopped so the run does not swap (override: A11Y_MAX_WORKERS)`);
|
|
420
434
|
}
|
|
421
435
|
async function checkDatasetPages() {
|
|
422
|
-
const manifestPath = resolve(
|
|
436
|
+
const manifestPath = resolve(datasetDir(), "manifest.json");
|
|
423
437
|
if (!external_node_fs_existsSync(manifestPath)) return add("dataset", false, "no manifest — the dataset has not been generated", "pnpm run training:generate");
|
|
424
438
|
const sample = JSON.parse(readFileSync(manifestPath, "utf8")).cases?.[0]?.id;
|
|
425
439
|
const probe = sample ? `${sample}/good.html` : "";
|
|
@@ -434,7 +448,7 @@ async function checkDatasetPages() {
|
|
|
434
448
|
}
|
|
435
449
|
}
|
|
436
450
|
function checkRunState() {
|
|
437
|
-
const progress = resolve(
|
|
451
|
+
const progress = resolve(datasetDir(), "capture-progress.json");
|
|
438
452
|
if (!external_node_fs_existsSync(progress)) return add("run", true, "no capture run recorded");
|
|
439
453
|
const p = JSON.parse(readFileSync(progress, "utf8"));
|
|
440
454
|
if (!p.startedAt) return add("run", true, "no capture run recorded");
|
|
@@ -475,7 +489,14 @@ function errorDocument(error) {
|
|
|
475
489
|
};
|
|
476
490
|
}
|
|
477
491
|
async function doctorRun(deps = {}) {
|
|
478
|
-
const { steps = DEFAULT_STEPS, json = JSON_OUT, out = console.log, err = console.error } = deps;
|
|
492
|
+
const { steps = DEFAULT_STEPS, json = JSON_OUT, out = console.log, err = console.error, runsDir = runsDirFor } = deps;
|
|
493
|
+
try {
|
|
494
|
+
runsDir();
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if (json) out(JSON.stringify(errorDocument(error), null, 2));
|
|
497
|
+
else err(error.message);
|
|
498
|
+
return REFUSED;
|
|
499
|
+
}
|
|
479
500
|
try {
|
|
480
501
|
for (const step of steps)await step();
|
|
481
502
|
} catch (error) {
|
|
@@ -511,4 +532,4 @@ async function main() {
|
|
|
511
532
|
process.exit(await doctorRun());
|
|
512
533
|
}
|
|
513
534
|
if (import.meta.url === pathToFileURL(process.argv[1] ? external_node_fs_realpathSync(process.argv[1]) : "").href) await main();
|
|
514
|
-
export { addCheck, allChecks, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, resolvesToThisCheckout, tscProjectUpToDate, workerControlFix };
|
|
535
|
+
export { addCheck, allChecks, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, resolvesToThisCheckout, runsDirFor, tscProjectUpToDate, workerControlFix };
|
package/dist/fleet-env.d.mts
CHANGED
|
@@ -20,6 +20,27 @@ export function configuredWorkers(): Array<{
|
|
|
20
20
|
name: string;
|
|
21
21
|
url: string;
|
|
22
22
|
}>;
|
|
23
|
+
/**
|
|
24
|
+
* The two files the COMMAND reads, from the caller or else from the monorepo layout, and a refusal naming the path
|
|
25
|
+
* and the flag when the one it would read is not there (a11ign/a11ign#3767).
|
|
26
|
+
*
|
|
27
|
+
* `INVENTORY` above points into `packages/control`, which an installed `@a11ign/screenreader-fleet` does not have, so
|
|
28
|
+
* `fleet-env --list` there died on an ENOENT for a path nobody could have guessed. The library functions keep their
|
|
29
|
+
* default because their answer for "no inventory here" is `[]`, which is supported; a command that prints
|
|
30
|
+
* `export A11Y_WORKERS=''` for a missing file would be the same silent wrong answer, so it refuses.
|
|
31
|
+
*
|
|
32
|
+
* `baseDir` is INJECTED so a test can stand in for either layout without moving a file.
|
|
33
|
+
*
|
|
34
|
+
* @param {{ argv?: readonly string[], baseDir?: string }} [options]
|
|
35
|
+
* @returns {{ inventoryPath: string, groupVarsPath: string }}
|
|
36
|
+
*/
|
|
37
|
+
export function inventoryPathsFor({ argv, baseDir }?: {
|
|
38
|
+
argv?: readonly string[];
|
|
39
|
+
baseDir?: string;
|
|
40
|
+
}): {
|
|
41
|
+
inventoryPath: string;
|
|
42
|
+
groupVarsPath: string;
|
|
43
|
+
};
|
|
23
44
|
/**
|
|
24
45
|
* Which group each line of the inventory sits in, index-aligned with `text.split(/\r?\n/)`.
|
|
25
46
|
*
|
|
@@ -90,7 +111,7 @@ export function portFromGroupVars(text: string): number;
|
|
|
90
111
|
*
|
|
91
112
|
* Exists so a caller can tell a physical box from a local UTM VM, which decides how it is deployed to and
|
|
92
113
|
* therefore what remedy to print. `worker:code` used to tell every stale worker to run `utmctl` and
|
|
93
|
-
* `npm run worker:deploy
|
|
114
|
+
* `npm run worker:deploy` (since removed, #3765), which CANNOT reach a bare-metal box — it was a `utmctl file push` keyed on a VM
|
|
94
115
|
* UUID and fails immediately off macOS. Following that advice on this fleet wastes the time it takes to
|
|
95
116
|
* discover the tool was describing a different kind of machine.
|
|
96
117
|
*
|
package/dist/fleet-env.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
4
|
import { assertWorkerUrl } from "./worker-http.mjs";
|
|
4
|
-
import { refuseUnknownFlags } from "./cli-flags.mjs";
|
|
5
|
+
import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
|
|
5
6
|
refuseUnknownFlags([
|
|
6
|
-
"--list"
|
|
7
|
+
"--list",
|
|
8
|
+
"--inventory=",
|
|
9
|
+
"--group-vars="
|
|
7
10
|
], {
|
|
8
11
|
entry: import.meta.url,
|
|
9
12
|
command: "npm run fleet:env"
|
|
@@ -19,8 +22,32 @@ function configuredWorkers() {
|
|
|
19
22
|
url
|
|
20
23
|
}));
|
|
21
24
|
}
|
|
22
|
-
const
|
|
23
|
-
const
|
|
25
|
+
const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
26
|
+
const monorepoAnsibleFile = (relative, baseDir = MODULE_DIR)=>resolve(baseDir, "..", "..", "control", "ansible", relative);
|
|
27
|
+
const INVENTORY = monorepoAnsibleFile("inventory.yml");
|
|
28
|
+
const GROUP_VARS = monorepoAnsibleFile("group_vars/a11y_workers.yml");
|
|
29
|
+
function inventoryPathsFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
|
|
30
|
+
const inventoryPath = existingPath({
|
|
31
|
+
flag: "inventory",
|
|
32
|
+
supplied: flagValue(argv, "inventory"),
|
|
33
|
+
fallback: monorepoAnsibleFile("inventory.yml", baseDir)
|
|
34
|
+
});
|
|
35
|
+
const groupVarsPath = existingPath({
|
|
36
|
+
flag: "group-vars",
|
|
37
|
+
supplied: flagValue(argv, "group-vars"),
|
|
38
|
+
fallback: monorepoAnsibleFile("group_vars/a11y_workers.yml", baseDir)
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
inventoryPath,
|
|
42
|
+
groupVarsPath
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function existingPath({ flag, supplied, fallback }) {
|
|
46
|
+
const path = supplied ?? fallback;
|
|
47
|
+
if (existsSync(path)) return path;
|
|
48
|
+
const origin = void 0 === supplied ? "the monorepo layout this command defaults to has no such file" : "no such file";
|
|
49
|
+
throw new Error(`fleet:env: ${path} does not exist (${origin}). Pass --${flag}=<file>.`);
|
|
50
|
+
}
|
|
24
51
|
const HOST_LINE = /^\s*ansible_host\s*:\s*(\S+)\s*$/;
|
|
25
52
|
const SUSPECT = /ansible_host\s*:/;
|
|
26
53
|
const KEY_LINE = /^(\s*)([A-Za-z_][\w.-]*)\s*:/;
|
|
@@ -207,8 +234,16 @@ function fleetEnvOutput(text, { port = DEFAULT_WORKER_PORT, mode = "env" } = {})
|
|
|
207
234
|
};
|
|
208
235
|
}
|
|
209
236
|
function main() {
|
|
210
|
-
|
|
211
|
-
|
|
237
|
+
let paths;
|
|
238
|
+
try {
|
|
239
|
+
paths = inventoryPathsFor();
|
|
240
|
+
} catch (error) {
|
|
241
|
+
process.stderr.write(`${error.message}\n`);
|
|
242
|
+
process.exitCode = 2;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const port = portFromGroupVars(readFileSync(paths.groupVarsPath, "utf8"));
|
|
246
|
+
const { stdout, stderr } = fleetEnvOutput(readFileSync(paths.inventoryPath, "utf8"), {
|
|
212
247
|
port,
|
|
213
248
|
mode: process.argv.includes("--list") ? "list" : "env"
|
|
214
249
|
});
|
|
@@ -216,4 +251,4 @@ function main() {
|
|
|
216
251
|
process.stdout.write(stdout);
|
|
217
252
|
}
|
|
218
253
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
|
|
219
|
-
export { DEFAULT_WORKER_PORT, WORKER_GROUP, configuredWorkers, fleetEnvOutput, groupPerLine, hostsOutOfCaptureSet, inventoryWorkerUrls, namedInventoryWorkers, portFromGroupVars, resolveWorkerPool, workerNamesFromInventory, workersFromInventory };
|
|
254
|
+
export { DEFAULT_WORKER_PORT, WORKER_GROUP, configuredWorkers, fleetEnvOutput, groupPerLine, hostsOutOfCaptureSet, inventoryPathsFor, inventoryWorkerUrls, namedInventoryWorkers, portFromGroupVars, resolveWorkerPool, workerNamesFromInventory, workersFromInventory };
|
package/dist/index.mjs
CHANGED
|
@@ -4,7 +4,9 @@ import { networkInterfaces } from "node:os";
|
|
|
4
4
|
import { capacityReason, availableHostMemoryMb, workersHostCanRun } from "./host-capacity.mjs";
|
|
5
5
|
import { fleetScriptPaths as fleet_scripts_fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
|
|
6
6
|
import { inventoryWorkerUrls } from "./fleet-env.mjs";
|
|
7
|
-
|
|
7
|
+
function warnUtmDeprecated(what) {
|
|
8
|
+
process.stderr.write(`DEPRECATED: ${what} manages a local UTM worker VM. UTM was a testing path and is not the fleet.\nCapture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy. See CLAUDE.md's\n"Working on a Mac" section.\n`);
|
|
9
|
+
}
|
|
8
10
|
const execFileAsync = promisify(execFile);
|
|
9
11
|
const DEFAULT_WORKER = "http://localhost:8765";
|
|
10
12
|
const CTL = fleet_scripts_fleetScriptPaths().workerCtl;
|
|
@@ -33,8 +33,8 @@ function remedyLines(staleUrls, bareMetalUrls) {
|
|
|
33
33
|
const lines = [
|
|
34
34
|
`\n${staleUrls.length} stale worker(s).`
|
|
35
35
|
];
|
|
36
|
-
if (physical.length) lines.push(`\n ${physical.length} in inventory.yml — bare metal, so they deploy by PULLING:`, " npm run fleet:deploy", "
|
|
37
|
-
if (vms.length) lines.push(`\n ${vms.length} not in inventory.yml — local VM(s).
|
|
36
|
+
if (physical.length) lines.push(`\n ${physical.length} in inventory.yml — bare metal, so they deploy by PULLING:`, " npm run fleet:deploy", " The UTM push (`a11ign-worker-deploy`) was removed and never reached these.");
|
|
37
|
+
if (vms.length) lines.push(`\n ${vms.length} not in inventory.yml — local VM(s). No command deploys to them any more`, " (`a11ign-worker-deploy` was removed, #3765): re-provision the VM, or declare it in inventory.yml", " so `npm run fleet:deploy` reaches it.");
|
|
38
38
|
return lines;
|
|
39
39
|
}
|
|
40
40
|
function describeCodeDrift(drift, { when = "before the run", bareMetalUrls = [], sourceDirty = "" } = {}) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a11ign/screenreader-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Host-side lifecycle, health and capacity for a fleet of Windows NVDA capture workers: lease one, judge whether it is degrading, and know how many the host can afford.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -58,8 +58,7 @@
|
|
|
58
58
|
"a11ign-doctor": "./dist/doctor.mjs",
|
|
59
59
|
"a11ign-worker-code": "./dist/check-worker-code.mjs",
|
|
60
60
|
"a11ign-worker-compare": "./dist/compare-workers.mjs",
|
|
61
|
-
"a11ign-worker-ctl": "./src/local-worker/worker-ctl.sh"
|
|
62
|
-
"a11ign-worker-deploy": "./dist/deploy-worker.mjs"
|
|
61
|
+
"a11ign-worker-ctl": "./src/local-worker/worker-ctl.sh"
|
|
63
62
|
},
|
|
64
63
|
"files": [
|
|
65
64
|
"dist",
|
|
@@ -66,7 +66,7 @@ PORT="${A11Y_PORT:-8765}"
|
|
|
66
66
|
|
|
67
67
|
# Accept `--vm=<name>` as well as A11Y_VM_NAME, and accept it in ANY position.
|
|
68
68
|
#
|
|
69
|
-
# `worker:deploy`
|
|
69
|
+
# `worker:deploy` (since removed, #3765) always took `--vm=`, so anyone who used that reaches for it here too — and this
|
|
70
70
|
# script silently ignored it, then reported a DIFFERENT VM's state under the name you asked for. Silently,
|
|
71
71
|
# because a stray argument was simply never read. Two tools in one fleet disagreeing about how to name a
|
|
72
72
|
# machine is the kind of paper cut that gets diagnosed as "the guest is broken".
|
|
@@ -589,7 +589,7 @@ if (-not $handedOff) {
|
|
|
589
589
|
# - `workerCode` is recorded on every capture so you know what produced it. A worker that
|
|
590
590
|
# silently updates itself on reboot can span two code versions inside one corpus run, and the
|
|
591
591
|
# provenance stops meaning anything.
|
|
592
|
-
# - `
|
|
592
|
+
# - `fleet:deploy` VERIFIES over /health.code, which shares no failure mode with the
|
|
593
593
|
# push. An unattended self-update has no such check.
|
|
594
594
|
# - one bad push would then brick every box in the fleet at its next restart, simultaneously.
|
|
595
595
|
if (Get-ScheduledTask -TaskName 'a11ybootstrap' -ErrorAction SilentlyContinue) {
|
package/dist/deploy-worker.d.mts
DELETED
package/dist/deploy-worker.mjs
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { execFile, execFileSync } from "node:child_process";
|
|
4
|
-
import { createReadStream, realpathSync } from "node:fs";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
|
-
import { resolve as external_node_path_resolve } from "node:path";
|
|
7
|
-
import { WORKER_FILES } from "@a11ign/screenreader-worker/worker-files";
|
|
8
|
-
import { codeVersion, workerSourceDir } from "@a11ign/screenreader-worker/code-version";
|
|
9
|
-
import { CAPTURE_PROTOCOL_VERSION } from "@a11ign/screenreader-worker/protocol-version";
|
|
10
|
-
import { requestJson as worker_http_requestJson } from "./worker-http.mjs";
|
|
11
|
-
import { fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
|
|
12
|
-
import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
|
|
13
|
-
import { sandboxGitEnv } from "./src_git-safe-env_mjs.mjs";
|
|
14
|
-
import { warnUtmDeprecated } from "./src_utm-deprecated_mjs.mjs";
|
|
15
|
-
const MANIFEST_CASES = 1715;
|
|
16
|
-
const CAPTURES_PER_CASE = 2;
|
|
17
|
-
const RECAPTURE_COST = `${(MANIFEST_CASES * CAPTURES_PER_CASE).toLocaleString("en-US")} captures (${MANIFEST_CASES.toLocaleString("en-US")} cases in manifest.json x ${CAPTURES_PER_CASE}, read 2026-09-23T14:26Z; how long that takes depends on the fleet, so time a run rather than trust a figure)`;
|
|
18
|
-
refuseUnknownFlags([
|
|
19
|
-
"--vm=",
|
|
20
|
-
"--allow-protocol-change"
|
|
21
|
-
], {
|
|
22
|
-
entry: import.meta.url,
|
|
23
|
-
command: "npm run worker:deploy"
|
|
24
|
-
});
|
|
25
|
-
const run = promisify(execFile);
|
|
26
|
-
const NVDA_DIR = workerSourceDir();
|
|
27
|
-
const GUEST_DIR = "C:\\Users\\witness\\a11y-witness\\src\\capture\\nvda";
|
|
28
|
-
const CTL = fleetScriptPaths().workerCtl;
|
|
29
|
-
const LIFECYCLE_TIMEOUT_MS = 420000;
|
|
30
|
-
const POOL_TIMEOUT_MS = 240000;
|
|
31
|
-
const deploy_worker_HEALTH_TIMEOUT_MS = 20000;
|
|
32
|
-
const only = flagValue(process.argv, "vm");
|
|
33
|
-
function hashedFiles() {
|
|
34
|
-
return WORKER_FILES;
|
|
35
|
-
}
|
|
36
|
-
function localVersion() {
|
|
37
|
-
return codeVersion(NVDA_DIR);
|
|
38
|
-
}
|
|
39
|
-
async function pool() {
|
|
40
|
-
const { stdout } = await run(CTL, [
|
|
41
|
-
"pool"
|
|
42
|
-
], {
|
|
43
|
-
timeout: POOL_TIMEOUT_MS,
|
|
44
|
-
encoding: "utf8"
|
|
45
|
-
});
|
|
46
|
-
const all = JSON.parse(stdout);
|
|
47
|
-
return only ? all.filter((vm)=>vm.name === only) : all;
|
|
48
|
-
}
|
|
49
|
-
function ctl(action, vmName) {
|
|
50
|
-
return run(CTL, [
|
|
51
|
-
action
|
|
52
|
-
], {
|
|
53
|
-
timeout: LIFECYCLE_TIMEOUT_MS,
|
|
54
|
-
encoding: "utf8",
|
|
55
|
-
env: {
|
|
56
|
-
...process.env,
|
|
57
|
-
A11Y_VM_NAME: vmName
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
function push(uuid, file) {
|
|
62
|
-
return new Promise((done, fail)=>{
|
|
63
|
-
const child = execFile("utmctl", [
|
|
64
|
-
"file",
|
|
65
|
-
"push",
|
|
66
|
-
uuid,
|
|
67
|
-
`${GUEST_DIR}\\${file}`
|
|
68
|
-
], (error)=>error ? fail(new Error(`push ${file}: ${error.message}`)) : done());
|
|
69
|
-
if (child.stdin) createReadStream(external_node_path_resolve(NVDA_DIR, file)).pipe(child.stdin);
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
async function healthCode(ip, port) {
|
|
73
|
-
const response = await worker_http_requestJson(`http://${ip}:${port}/health`, {
|
|
74
|
-
timeoutMs: deploy_worker_HEALTH_TIMEOUT_MS
|
|
75
|
-
});
|
|
76
|
-
if (!response.ok) throw new Error(`HTTP ${response.status} from /health`);
|
|
77
|
-
if (void 0 === response.json) throw new Error(`invalid JSON from http://${ip}:${port}/health`);
|
|
78
|
-
return response.json.code;
|
|
79
|
-
}
|
|
80
|
-
const VERIFY_BUDGET_MS = 240000;
|
|
81
|
-
const VERIFY_POLL_MS = 10000;
|
|
82
|
-
async function healthCodeWhenAwake(ip, port) {
|
|
83
|
-
const deadline = Date.now() + VERIFY_BUDGET_MS;
|
|
84
|
-
let last = "no answer";
|
|
85
|
-
let waited = false;
|
|
86
|
-
while(Date.now() < deadline)try {
|
|
87
|
-
const actual = await healthCode(ip, port);
|
|
88
|
-
if (waited) process.stdout.write("\n");
|
|
89
|
-
return actual;
|
|
90
|
-
} catch (error) {
|
|
91
|
-
last = error instanceof Error ? error.message : String(error);
|
|
92
|
-
if (!waited) process.stdout.write(" waiting for the guest to answer /health ");
|
|
93
|
-
waited = true;
|
|
94
|
-
process.stdout.write(".");
|
|
95
|
-
await new Promise((resolve)=>setTimeout(resolve, VERIFY_POLL_MS));
|
|
96
|
-
}
|
|
97
|
-
if (waited) process.stdout.write("\n");
|
|
98
|
-
throw new Error(`${ip}:${port} never answered /health within ${VERIFY_BUDGET_MS / 1000}s (last: ${last})`);
|
|
99
|
-
}
|
|
100
|
-
async function waitUntilSettled(name, limitMs = 120000) {
|
|
101
|
-
const deadline = Date.now() + limitMs;
|
|
102
|
-
while(Date.now() < deadline){
|
|
103
|
-
const vm = (await pool()).find((v)=>v.name === name);
|
|
104
|
-
if (!vm || "stopping" !== vm.state) return;
|
|
105
|
-
await new Promise((resolve)=>setTimeout(resolve, 5000));
|
|
106
|
-
}
|
|
107
|
-
process.stdout.write(` note: ${name} is still stopping; continuing anyway\n`);
|
|
108
|
-
}
|
|
109
|
-
async function deployTo(vm, files, expected) {
|
|
110
|
-
process.stdout.write(`\n=== ${vm.name} ===\n`);
|
|
111
|
-
await ctl("up", vm.name);
|
|
112
|
-
for (const file of files){
|
|
113
|
-
await push(vm.uuid, file);
|
|
114
|
-
process.stdout.write(` pushed ${file}\n`);
|
|
115
|
-
}
|
|
116
|
-
process.stdout.write(" rebooting (utmctl exec cannot be trusted to restart the worker) ...\n");
|
|
117
|
-
await ctl("stop", vm.name);
|
|
118
|
-
await ctl("up", vm.name);
|
|
119
|
-
try {
|
|
120
|
-
const fresh = await pool();
|
|
121
|
-
const back = fresh.find((v)=>v.name === vm.name);
|
|
122
|
-
if (!back?.ip) throw new Error(`${vm.name} did not come back with an address`);
|
|
123
|
-
const actual = await healthCodeWhenAwake(back.ip, back.port);
|
|
124
|
-
const ok = actual === expected;
|
|
125
|
-
process.stdout.write(` /health.code ${actual} ${ok ? "== expected" : `!= expected ${expected}`}\n`);
|
|
126
|
-
return ok;
|
|
127
|
-
} finally{
|
|
128
|
-
if ("started" !== vm.state) await ctl("stop", vm.name).catch(()=>void 0);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
function guardProtocolChange() {
|
|
132
|
-
const inTree = String(CAPTURE_PROTOCOL_VERSION);
|
|
133
|
-
let committed;
|
|
134
|
-
try {
|
|
135
|
-
committed = /CAPTURE_PROTOCOL_VERSION = (\d+)/.exec(execFileSync("git", [
|
|
136
|
-
"-C",
|
|
137
|
-
NVDA_DIR,
|
|
138
|
-
"show",
|
|
139
|
-
"HEAD:./protocol-version.mjs"
|
|
140
|
-
], {
|
|
141
|
-
encoding: "utf8",
|
|
142
|
-
env: sandboxGitEnv()
|
|
143
|
-
}))?.[1];
|
|
144
|
-
} catch {
|
|
145
|
-
try {
|
|
146
|
-
execFileSync("git", [
|
|
147
|
-
"rev-parse",
|
|
148
|
-
"--verify",
|
|
149
|
-
"HEAD"
|
|
150
|
-
], {
|
|
151
|
-
stdio: "ignore",
|
|
152
|
-
env: sandboxGitEnv()
|
|
153
|
-
});
|
|
154
|
-
process.stdout.write(` note: cannot compare CAPTURE_PROTOCOL_VERSION against HEAD — ${external_node_path_resolve(NVDA_DIR, "protocol-version.mjs")} is not in HEAD.\n Expected for a brand-new or just-moved file; if the path moved, fix it here or this guard is off.\n`);
|
|
155
|
-
} catch {}
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
if (!inTree || !committed || inTree === committed) return;
|
|
159
|
-
if (process.argv.includes("--allow-protocol-change")) return void process.stdout.write(`\nDeploying CAPTURE_PROTOCOL_VERSION ${committed} -> ${inTree} as requested. Every cached capture is now invalid and the next run will recapture all of them.\n`);
|
|
160
|
-
process.stderr.write(`\nREFUSING TO DEPLOY: the working tree has CAPTURE_PROTOCOL_VERSION = ${inTree}, but HEAD has ${committed}.\n\nThat value is a capture-cache key, so deploying it invalidates all cached captures and forces a\nfull recapture: ${RECAPTURE_COST}. If a \`worker:code\` STALE report sent you here, the stale\nhash is probably caused by this uncommitted bump rather than by the guests being out of date.\n\n git stash # deploy without the bump, or\n npm run worker:deploy -- --allow-protocol-change # deploy it deliberately\n`);
|
|
161
|
-
process.exit(3);
|
|
162
|
-
}
|
|
163
|
-
async function main() {
|
|
164
|
-
warnUtmDeprecated("npm run worker:deploy");
|
|
165
|
-
guardProtocolChange();
|
|
166
|
-
const files = hashedFiles();
|
|
167
|
-
const expected = localVersion();
|
|
168
|
-
const vms = await pool();
|
|
169
|
-
if (!vms.length) {
|
|
170
|
-
process.stderr.write(only ? `no local worker VM named ${only}\n` : "no local worker VMs registered\n");
|
|
171
|
-
process.exit(2);
|
|
172
|
-
}
|
|
173
|
-
process.stdout.write(`Deploying ${files.length} file(s) to ${vms.length} worker(s)\n`);
|
|
174
|
-
process.stdout.write(`Files: ${files.join(", ")}\nExpected code: ${expected}\n`);
|
|
175
|
-
const failed = [];
|
|
176
|
-
for (const vm of vms){
|
|
177
|
-
try {
|
|
178
|
-
if (!await deployTo(vm, files, expected)) failed.push(vm.name);
|
|
179
|
-
} catch (error) {
|
|
180
|
-
process.stdout.write(` FAILED: ${error.message}\n`);
|
|
181
|
-
failed.push(vm.name);
|
|
182
|
-
}
|
|
183
|
-
await waitUntilSettled(vm.name);
|
|
184
|
-
}
|
|
185
|
-
process.stdout.write(`\n${vms.length - failed.length}/${vms.length} worker(s) on ${expected}\n`);
|
|
186
|
-
if (failed.length) {
|
|
187
|
-
process.stdout.write(`stale or failed: ${failed.join(", ")}\n`);
|
|
188
|
-
process.stdout.write("Re-run this command; if it persists, the guest is not rebooting — see docs/nvda-worker-runbook.md\n");
|
|
189
|
-
}
|
|
190
|
-
process.exit(failed.length ? 1 : 0);
|
|
191
|
-
}
|
|
192
|
-
if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href) await main();
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
function warnUtmDeprecated(what) {
|
|
2
|
-
process.stderr.write(`DEPRECATED: ${what} manages a local UTM worker VM. UTM was a testing path and is not the fleet.\nCapture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy. See CLAUDE.md's\n"Working on a Mac" section.\n`);
|
|
3
|
-
}
|
|
4
|
-
export { warnUtmDeprecated };
|