forgedock 1.40.0 → 1.42.0
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/bin/engine/outcome-codes.mjs +6 -3
- package/bin/engine-cli.mjs +40 -4
- package/bin/forgedock.mjs +22 -1
- package/bin/lean/batch.test.mjs +33 -1
- package/bin/lean/capwait.mjs +36 -0
- package/bin/lean/capwait.test.mjs +48 -0
- package/bin/lean/conformance.mjs +150 -2
- package/bin/lean/conformance.test.mjs +126 -0
- package/bin/lean/depcache.mjs +10 -2
- package/bin/lean/depcache.test.mjs +22 -17
- package/bin/lean/execution.mjs +21 -5
- package/bin/lean/execution.test.mjs +7 -1
- package/bin/lean/heavy.mjs +22 -1
- package/bin/lean/lanecheck.mjs +129 -0
- package/bin/lean/lanecheck.test.mjs +118 -0
- package/bin/lean/lanelock.mjs +31 -0
- package/bin/lean/lean.mjs +102 -30
- package/bin/lean/lean.test.mjs +173 -3
- package/bin/lean/orchestrate.mjs +42 -7
- package/bin/lean/phaseledger.mjs +116 -0
- package/bin/lean/phaseledger.test.mjs +72 -0
- package/bin/lean/prodwatch.mjs +12 -1
- package/bin/lean/prodwatch.test.mjs +32 -3
- package/bin/lean/prompts.mjs +19 -0
- package/bin/lean/render.mjs +10 -2
- package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
- package/bin/lean/sandbox-remote.mjs +8 -1
- package/bin/lean/schedd.mjs +5 -1
- package/bin/lean/selftest-gate.mjs +46 -0
- package/bin/lean/selftest-gate.test.mjs +40 -0
- package/bin/lean/selftest-live.mjs +248 -0
- package/bin/lean/selftest-live.test.mjs +154 -0
- package/bin/lean/selftest-nightly.mjs +81 -0
- package/bin/lean/selftest-nightly.test.mjs +84 -0
- package/bin/lean/selftest-static.mjs +81 -0
- package/bin/lean/selftest-static.test.mjs +32 -0
- package/bin/pool/agent.mjs +16 -5
- package/bin/pool/router.mjs +17 -1
- package/bin/pool/warm.mjs +80 -0
- package/bin/review-staging.mjs +28 -6
- package/bin/stats/aggregate.mjs +2 -1
- package/bin/stats/efficiency.mjs +3 -1
- package/bin/stats/efficiency.test.mjs +4 -1
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/local-source.mjs +1 -0
- package/bin/stats/render.mjs +2 -0
- package/commands/deploy-pr.md +3 -3
- package/commands/review-pr-staging.md +2 -2
- package/commands/work-on/build.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +1 -1
- package/docs/PROD-WATCH.md +1 -1
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/SELFTEST.md +34 -0
- package/docs/eval/conformance-labels-v1.json +13 -0
- package/package.json +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/entrypoint.sh +44 -11
- package/pool/image/warm-refresh.sh +14 -0
- package/scripts/conformance-calibrate.mjs +132 -0
- package/scripts/file-deferred-followups.sh +21 -8
- package/scripts/file-deferred-followups.test.sh +27 -6
|
@@ -81,6 +81,27 @@ describe("#4655 multi-requirement amendment references", () => {
|
|
|
81
81
|
assert.match(applyAmendments(C, [{ kind: "defer", requirement: "R1/R9", why: "w" }]).problems[0], /unknown requirement R9/);
|
|
82
82
|
assert.match(applyAmendments(C, [{ kind: "scope-note", requirement: "R1", why: "w" }]).problems[0], /unknown kind 'scope-note'; valid kinds are/);
|
|
83
83
|
});
|
|
84
|
+
it("supersede-guard guards[] expands to per-guard supersedes", () => {
|
|
85
|
+
const a = applyAmendments(C, [{ kind: "supersede-guard", guards: ["#4879", "#4838"], why: "x" }]);
|
|
86
|
+
assert.deepEqual(a.problems, []);
|
|
87
|
+
assert.deepEqual(a.contract.supersedes, [{ guard: "#4879", why: "x" }, { guard: "#4838", why: "x" }]);
|
|
88
|
+
assert.equal(a.applied.length, 2);
|
|
89
|
+
});
|
|
90
|
+
it("synonym kinds and a supersedes amendment with guards[] expand alike; an invalid guard id is reported", () => {
|
|
91
|
+
for (const kind of ["supersede", "supersedes-guard", "supersedes"]) {
|
|
92
|
+
const a = applyAmendments(C, [{ kind, guards: ["4879", "#4838"], why: "x" }]);
|
|
93
|
+
assert.deepEqual(a.problems, [], kind);
|
|
94
|
+
assert.deepEqual(a.contract.supersedes.map((s) => s.guard), ["#4879", "#4838"], kind);
|
|
95
|
+
}
|
|
96
|
+
const bad = applyAmendments(C, [{ kind: "supersedes", guards: ["#4879", "abc"], why: "x" }]);
|
|
97
|
+
assert.equal(bad.problems.length, 1);
|
|
98
|
+
assert.match(bad.problems[0], /needs guard/);
|
|
99
|
+
assert.deepEqual(bad.contract.supersedes.map((s) => s.guard), ["#4879"]);
|
|
100
|
+
assert.match(applyAmendments(C, [{ kind: "supersede-guard", guards: [], why: "x" }]).problems[0], /needs guard/);
|
|
101
|
+
});
|
|
102
|
+
it("an unrelated unknown kind is still rejected with the valid-kinds message", () => {
|
|
103
|
+
assert.match(applyAmendments(C, [{ kind: "supersede-everything", guards: ["#1"], why: "w" }]).problems[0], /unknown kind 'supersede-everything'; valid kinds are/);
|
|
104
|
+
});
|
|
84
105
|
});
|
|
85
106
|
|
|
86
107
|
describe("#4574 amendment slips", () => {
|
|
@@ -141,6 +162,9 @@ describe("precheck (deterministic, no reviewer)", () => {
|
|
|
141
162
|
assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, ciVerified: true, validateRuns: { V1: { rc: 127, output: "bash: pytest: command not found" } } })), [], "CI-verified head: like a validate left to CI");
|
|
142
163
|
assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 0, output: "" } } })), [], "a passing rerun counts as passed");
|
|
143
164
|
assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 1, output: "AssertionError: 1 !== 2" } } })), ["conformance.unimplemented"]);
|
|
165
|
+
// #4906: a rerun that timed out (rc 124) is infrastructure, never conformance.unimplemented
|
|
166
|
+
assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 124, output: "(timed out)" } } })), ["env.validate_timeout"]);
|
|
167
|
+
assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, ciVerified: true, validateRuns: { V1: { rc: 124, output: "" } } })), [], "CI-verified head covers a timed-out rerun");
|
|
144
168
|
const local = precheck({ contract: C, result: failed, changedFiles: files, repoHas: repoTracks(["src/leak/__init__.py"]), validateRuns: { V1: { rc: 1, output: "ModuleNotFoundError: No module named 'leak.core'" } } });
|
|
145
169
|
assert.deepEqual(codes(local), ["conformance.unimplemented"], "a repo module the change did not create is a code defect");
|
|
146
170
|
});
|
|
@@ -866,3 +890,105 @@ describe("implied docs-freshness deliverable", () => {
|
|
|
866
890
|
assert.deepEqual(impliedDocDeliverables({ before: [...base, "docs/guide.md"], after, contract: C, wasFailing: true, nowOk: true }), []);
|
|
867
891
|
});
|
|
868
892
|
});
|
|
893
|
+
|
|
894
|
+
import { normalizeKindModes, blockingFailures, unverifiedRequirements, KIND_MODES } from "./conformance.mjs";
|
|
895
|
+
|
|
896
|
+
describe("per-kind modes and requirement evidence", () => {
|
|
897
|
+
const f = (code) => ({ code });
|
|
898
|
+
const ALLOW = ["conformance.unimplemented"];
|
|
899
|
+
it("normalizeKindModes keeps valid entries from a map or the flat forge.yaml string", () => {
|
|
900
|
+
assert.deepEqual(KIND_MODES, ["enforce", "shadow"]);
|
|
901
|
+
assert.deepEqual(normalizeKindModes({ "a.b": "shadow", c: "bogus", d: "enforce" }), { "a.b": "shadow", d: "enforce" });
|
|
902
|
+
assert.deepEqual(normalizeKindModes("a.b=shadow, c.d = enforce, x=nope"), { "a.b": "shadow", "c.d": "enforce" });
|
|
903
|
+
assert.deepEqual(normalizeKindModes(null), {});
|
|
904
|
+
});
|
|
905
|
+
it("a shadow kind is recorded and an enforce kind blocks, in either lane mode", () => {
|
|
906
|
+
const kinds = { "k.shadow": "shadow", "k.enforce": "enforce" };
|
|
907
|
+
for (const shadow of [true, false]) {
|
|
908
|
+
const r = blockingFailures([f("k.shadow"), f("k.enforce")], { shadow, kinds });
|
|
909
|
+
assert.deepEqual([r.block.map((x) => x.code), r.record.map((x) => x.code)], [["k.enforce"], ["k.shadow"]]);
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
it("kinds without their own mode follow the lane: enforce blocks all, shadow only the allowlist", () => {
|
|
913
|
+
assert.equal(blockingFailures([f("x"), f("conformance.unimplemented")], { shadow: false }).block.length, 2);
|
|
914
|
+
const r = blockingFailures([f("x"), f("conformance.unimplemented")], { shadow: true, allow: ALLOW });
|
|
915
|
+
assert.deepEqual([r.block.map((x) => x.code), r.record.map((x) => x.code)], [["conformance.unimplemented"], ["x"]]);
|
|
916
|
+
assert.equal(blockingFailures([f("conformance.unimplemented")], { shadow: true, allow: ALLOW, kinds: { "conformance.unimplemented": "shadow" } }).block.length, 0);
|
|
917
|
+
});
|
|
918
|
+
it("a deliver requirement with no executed evidence test is unverified, with its id", () => {
|
|
919
|
+
const c = {
|
|
920
|
+
requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "deliver" }, { id: "R3", disposition: "deliver" }, { id: "R4", disposition: "defer" }, { id: "R5", disposition: "deliver", verify: "out_of_band" }],
|
|
921
|
+
deliverables: [{ id: "D1", path: "src/a.js", requirements: ["R1"] }, { id: "D2", path: "test/a.test.js", requirements: ["R1"] }, { id: "D3", path: "test/b.test.js", requirements: ["R2"] }],
|
|
922
|
+
validate: [{ id: "V1", command: "node --test test/a.test.js" }, { id: "V2", command: "node --test test/b.test.js" }],
|
|
923
|
+
};
|
|
924
|
+
const u = (o) => unverifiedRequirements(c, o).map((x) => x.id);
|
|
925
|
+
assert.deepEqual(u({ runs: [{ id: "V1", exit: 0 }, { id: "V2", exit: 1 }] }), ["R2", "R3"]);
|
|
926
|
+
assert.deepEqual(u({ runs: [{ id: "V1", exit: 1 }], reruns: { V1: { rc: 0 }, V2: { rc: 0 } } }), ["R3"]);
|
|
927
|
+
assert.deepEqual(u({ runs: [], ciCovered: ["V1", "V2"] }), ["R3"]);
|
|
928
|
+
assert.deepEqual(u({ runs: [{ id: "V1", exit: 0 }, { id: "V2", exit: 0 }], reruns: { V2: { rc: 1 } } }), ["R2", "R3"]);
|
|
929
|
+
});
|
|
930
|
+
it("formatShadowSummary adds per-kind shares only when rows carry kinds", () => {
|
|
931
|
+
const rows = [{ conformance: { mode: "shadow", would: "nonconforming", kinds: ["a.b"] } }, { conformance: { mode: "shadow", would: "conforms", kinds: [] } }];
|
|
932
|
+
assert.match(formatShadowSummary(rows), /; per kind: a\.b 1\/2 \(50%\)$/);
|
|
933
|
+
assert.doesNotMatch(formatShadowSummary([{ conformance: { mode: "shadow", would: "conforms" } }]), /per kind/);
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
import { mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED } from "./conformance.mjs";
|
|
938
|
+
|
|
939
|
+
describe("mutation check (#4913)", () => {
|
|
940
|
+
const c = {
|
|
941
|
+
requirements: [{ id: "R1", quote: "q1", disposition: "deliver" }, { id: "R2", quote: "q2", disposition: "deliver" }, { id: "R3", quote: "q3", disposition: "deliver", verify: "out_of_band", evidence: "job" }],
|
|
942
|
+
deliverables: [{ id: "D1", path: "test/a.test.js", requirements: ["R1", "R3"] }, { id: "D2", path: "src/b.js", requirements: ["R2"] }],
|
|
943
|
+
validate: [{ id: "V1", command: "node --test test/a.test.js" }],
|
|
944
|
+
};
|
|
945
|
+
const green = { runs: [{ id: "V1", exit: 0 }] };
|
|
946
|
+
it("targets only deliver requirements whose evidence test ran green", () => {
|
|
947
|
+
assert.deepEqual(mutationTargets(c, green), [{ id: "R1", commands: [{ id: "V1", command: "node --test test/a.test.js" }] }]);
|
|
948
|
+
assert.deepEqual(mutationTargets(c, { runs: [{ id: "V1", exit: 1 }] }), []);
|
|
949
|
+
});
|
|
950
|
+
it("only a survivor fails; killed and skipped never do", () => {
|
|
951
|
+
const f = mutationFailures([{ id: "R1", command: "x", status: "survived" }, { id: "R2", command: "y", status: "killed" }, { id: "R4", command: "z", status: "skipped" }]);
|
|
952
|
+
assert.equal(f.length, 1); assert.equal(f[0].code, MUTATION_SURVIVED); assert.match(f[0].detail, /^R1 /);
|
|
953
|
+
});
|
|
954
|
+
const harness = (o = {}) => {
|
|
955
|
+
const calls = [], fs = { writeFileSync() {}, rmSync() {} };
|
|
956
|
+
let restored = false;
|
|
957
|
+
const git = async (a) => { calls.push(a.join(" ")); if (a[0] === "reset") restored = true; if (a[0] === "status") return restored ? (o.stillDirty ?? "") : (o.dirty ?? ""); if (a[0] === "apply" && o.applyFails) throw new Error("no"); return "patch"; };
|
|
958
|
+
const runCommand = async () => { calls.push("run"); if (o.throws) throw new Error("boom"); return { rc: o.rc ?? 0 }; };
|
|
959
|
+
const targets = mutationTargets(c, green);
|
|
960
|
+
return { calls, go: () => runMutationCheck({ targets, files: ["src/b.js", "test/a.test.js"], base: "origin/staging", git, runCommand, cwd: "/w", fs, tmp: "/t" }) };
|
|
961
|
+
};
|
|
962
|
+
it("reverts only non-test files, reruns, then restores from HEAD (reset + clean of those paths)", async () => {
|
|
963
|
+
const h = harness(); const r = await h.go();
|
|
964
|
+
assert.deepEqual(r.results.map((x) => x.status), ["survived"]);
|
|
965
|
+
assert.ok(h.calls.some((x) => x.startsWith("diff --binary origin/staging...HEAD -- src/b.js") && !x.includes("test/a")));
|
|
966
|
+
assert.deepEqual(h.calls.filter((x) => /^(apply|run|reset|clean)/.test(x)), ["apply -R --whitespace=nowarn /t/mutation-" + process.pid + ".patch", "run", "reset -q --hard HEAD", "clean -fdq -- src/b.js"]);
|
|
967
|
+
});
|
|
968
|
+
it("a failing rerun is killed; the tree is restored even when the command throws", async () => {
|
|
969
|
+
assert.equal((await harness({ rc: 1 }).go()).results[0].status, "killed");
|
|
970
|
+
const h = harness({ throws: true }); assert.equal((await h.go()).results[0].status, "skipped");
|
|
971
|
+
assert.equal(h.calls.at(-1), "status --porcelain -- src/b.js");
|
|
972
|
+
});
|
|
973
|
+
it("a dirty tree or a failed revert skips without running anything or restoring", async () => {
|
|
974
|
+
for (const o of [{ dirty: " M src/b.js" }, { applyFails: true }]) { const h = harness(o); const r = await h.go(); assert.deepEqual(r.results, []); assert.ok(r.skipped); assert.ok(!h.calls.includes("run") && !h.calls.some((x) => x.startsWith("reset"))); }
|
|
975
|
+
});
|
|
976
|
+
it("#4931: a tree still dirty after the restore throws instead of returning (never push the reverted change)", async () => {
|
|
977
|
+
await assert.rejects(harness({ stillDirty: "?? src/b.js" }).go(), /could not restore the worktree: \?\? src\/b\.js/);
|
|
978
|
+
});
|
|
979
|
+
it("#4931: a real repo whose change deleted a non-test file is fully restored", async () => {
|
|
980
|
+
const { execFileSync } = await import("node:child_process");
|
|
981
|
+
const { writeFileSync, existsSync, readFileSync } = await import("node:fs");
|
|
982
|
+
const { join } = await import("node:path");
|
|
983
|
+
const { tmpDir } = await import("../tests/_tmp.mjs");
|
|
984
|
+
const d = tmpDir("mut-");
|
|
985
|
+
const g = (...a) => execFileSync("git", ["-C", d, "-c", "user.email=t@t", "-c", "user.name=t", ...a], { encoding: "utf8" });
|
|
986
|
+
g("init", "-q", "-b", "main"); writeFileSync(join(d, "a.js"), "a1\n"); writeFileSync(join(d, "b.js"), "b\n"); g("add", "."); g("commit", "-qm", "base");
|
|
987
|
+
g("checkout", "-qb", "lane"); writeFileSync(join(d, "a.js"), "a2\n"); g("rm", "-q", "b.js"); g("add", "a.js"); g("commit", "-qm", "change");
|
|
988
|
+
const r = await runMutationCheck({ targets: [{ id: "R1", commands: [{ id: "V1", command: "t" }] }], files: ["a.js", "b.js"], base: "main",
|
|
989
|
+
git: async (a) => g(...a), runCommand: async () => ({ rc: 1 }), cwd: d, tmp: d });
|
|
990
|
+
assert.equal(r.skipped, "", r.skipped); assert.equal(r.results[0].status, "killed");
|
|
991
|
+
assert.equal(readFileSync(join(d, "a.js"), "utf8"), "a2\n"); assert.equal(existsSync(join(d, "b.js")), false);
|
|
992
|
+
assert.equal(g("status", "--porcelain").trim(), "");
|
|
993
|
+
});
|
|
994
|
+
});
|
package/bin/lean/depcache.mjs
CHANGED
|
@@ -146,7 +146,15 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
146
146
|
|
|
147
147
|
// ---- trees ----
|
|
148
148
|
const boundary = (p) => new RegExp(p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?=$|[/\\\\'\"\\s:;])", "g");
|
|
149
|
-
/** Hardlink
|
|
149
|
+
/** Hardlink a file; across filesystems (EXDEV, #4909) copy it with its mode so the cache is reused instead of reinstalled. */
|
|
150
|
+
function linkOrCopy(sp, dp) {
|
|
151
|
+
try { fs.linkSync(sp, dp); } catch (e) {
|
|
152
|
+
if (e?.code !== "EXDEV") throw e;
|
|
153
|
+
fs.copyFileSync(sp, dp);
|
|
154
|
+
fs.chmodSync(dp, fs.statSync(sp).mode & 0o7777);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Hardlink `src` into `dst` (copy on EXDEV); symlinks recreated; files in bin dirs that name `from` are copied with `to` instead. */
|
|
150
158
|
function linkTree(src, dst, { from = null, to = null, binDir = () => false } = {}) {
|
|
151
159
|
const re = from && to && from !== to ? boundary(from) : null;
|
|
152
160
|
const walk = (s, d, rel) => {
|
|
@@ -165,7 +173,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
165
173
|
continue;
|
|
166
174
|
}
|
|
167
175
|
}
|
|
168
|
-
|
|
176
|
+
linkOrCopy(sp, dp);
|
|
169
177
|
}
|
|
170
178
|
}
|
|
171
179
|
};
|
|
@@ -272,23 +272,12 @@ describe("depcache (#4220): shared dependency installs keyed by lock hash", () =
|
|
|
272
272
|
assert.ok(!existsSync(join(wt, "node_modules")));
|
|
273
273
|
assert.equal(snapDirs(s.root, "pnpm").length, 0);
|
|
274
274
|
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
assert.equal(
|
|
281
|
-
assert.equal(r2.published, false);
|
|
282
|
-
assert.match(r2.reason, /EXDEV/);
|
|
283
|
-
assert.ok(existsSync(join(wt2, "node_modules/left-pad/index.js")), "the lane keeps its own install");
|
|
284
|
-
assert.equal(snapDirs(s2.root, "npm").length, 0);
|
|
285
|
-
assert.deepEqual(readdirSync(join(s2.root, "r")), [], "no tmp or lock left");
|
|
286
|
-
// A published snapshot that cannot be linked (EXDEV) is skipped and leaves nothing half-linked.
|
|
287
|
-
await s2.cache.prepare({ wt: s2.lane("z"), repoKey: "r" });
|
|
288
|
-
const wt3 = s2.lane("w");
|
|
289
|
-
const [r3] = await c2.prepare({ wt: wt3, repoKey: "r" });
|
|
290
|
-
assert.equal(r3.status, "skipped");
|
|
291
|
-
assert.ok(!existsSync(join(wt3, "node_modules")));
|
|
275
|
+
const s4x = setup("npm");
|
|
276
|
+
const noLink = makeDepCache({ root: s4x.root, exec: s4x.f.exec, env: {}, fs: { ...fs, linkSync: () => { throw Object.assign(new Error("EACCES"), { code: "EACCES" }); } } });
|
|
277
|
+
const [rx] = await noLink.prepare({ wt: s4x.lane("y"), repoKey: "r" });
|
|
278
|
+
assert.equal(rx.status, "miss-installed");
|
|
279
|
+
assert.equal(rx.published, false);
|
|
280
|
+
assert.equal(snapDirs(s4x.root, "npm").length, 0);
|
|
292
281
|
|
|
293
282
|
const s4 = setup("npm");
|
|
294
283
|
const c4 = makeDepCache({ root: s4.root, exec: async (cmd, args) => { if (cmd === "git" || args[0] === "--version") return s4.f.exec(cmd, args, { cwd: s4.base }); throw Object.assign(new Error("spawn npm ENOENT"), { code: "ENOENT" }); }, env: {} });
|
|
@@ -297,6 +286,22 @@ describe("depcache (#4220): shared dependency installs keyed by lock hash", () =
|
|
|
297
286
|
assert.match(r4.reason, /tool not found/);
|
|
298
287
|
});
|
|
299
288
|
|
|
289
|
+
it("with the cache on another filesystem (EXDEV) a second lane with the same lock hash copies the snapshot: deps hit, no install (#4909)", async () => {
|
|
290
|
+
const s = setup("npm");
|
|
291
|
+
const exdev = { ...fs, linkSync: () => { throw Object.assign(new Error("EXDEV: cross-device link"), { code: "EXDEV" }); } };
|
|
292
|
+
const c = makeDepCache({ root: s.root, exec: s.f.exec, env: {}, fs: exdev });
|
|
293
|
+
const [r1] = await c.prepare({ wt: s.lane("a"), repoKey: "r" });
|
|
294
|
+
assert.equal(r1.status, "miss-installed");
|
|
295
|
+
assert.equal(r1.published, true, "published by copy");
|
|
296
|
+
const installs = () => s.f.calls.filter((x) => x.cmd !== "git" && x.args[0] !== "--version").length;
|
|
297
|
+
const before = installs();
|
|
298
|
+
const wt2 = s.lane("b");
|
|
299
|
+
const [r2] = await c.prepare({ wt: wt2, repoKey: "r" });
|
|
300
|
+
assert.equal(r2.status, "hit");
|
|
301
|
+
assert.equal(installs(), before, "no install on the second lane");
|
|
302
|
+
assert.ok(existsSync(join(wt2, "node_modules/left-pad/index.js")));
|
|
303
|
+
});
|
|
304
|
+
|
|
300
305
|
it("no lockfile, yarn Plug'n'Play, an existing node_modules and FORGE_DEPCACHE=0 are skipped untouched", async () => {
|
|
301
306
|
const s = setup("npm");
|
|
302
307
|
const empty = join(s.base, "empty"); mkdirSync(empty);
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -79,6 +79,15 @@ export function isTransportError(e) {
|
|
|
79
79
|
if (e.name === "AbortError" || e.name === "TimeoutError") return true;
|
|
80
80
|
return /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket hang up|network error|timed? ?out/i.test(String(e.message ?? ""));
|
|
81
81
|
}
|
|
82
|
+
const CLONE_RE = /\bclone\b[^\n]*\b(time[ds]?\s?out|timeout|fail\w*|refused|reset|unreachable|early eof|could not)|\b(time[ds]?\s?out|timeout|fail\w*)\b[^\n]*\bclone\b/i;
|
|
83
|
+
/** #4866: text of a sandbox clone failure (timeout, transport) as seen in an error or a lane result's detail. */
|
|
84
|
+
export const isCloneFailure = (text) => CLONE_RE.test(String(text ?? ""));
|
|
85
|
+
const CLONE_RETRY_S = 120;
|
|
86
|
+
/** Outcome code of a retryable sandbox clone failure; local (not in OUTCOME_CODES), like the preflight codes in lanecheck.mjs. */
|
|
87
|
+
export const SANDBOX_CLONE_CODE = "sandbox.clone";
|
|
88
|
+
/** #4866: a sandbox clone failure is a retryable `deferred` `sandbox.clone`, never an engine-error (so the breaker never sees it). */
|
|
89
|
+
const cloneDeferred = (id, detail) => ({ terminalReason: "deferred", code: SANDBOX_CLONE_CODE, retryInSeconds: CLONE_RETRY_S, detail: `sandbox ${id} could not clone the repo: ${detail}; retry in ${CLONE_RETRY_S}s` });
|
|
90
|
+
const errLine = (t) => String(t ?? "").split("\n")[0].slice(0, 300);
|
|
82
91
|
const errText = (e) => String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 300);
|
|
83
92
|
|
|
84
93
|
/**
|
|
@@ -115,10 +124,12 @@ export function parseExecution(sec = {}) {
|
|
|
115
124
|
const patRaw = Array.isArray(s.host_capability_patterns) ? s.host_capability_patterns : str(s.host_capability_patterns) ? str(s.host_capability_patterns).split(",") : [];
|
|
116
125
|
const hostCapabilityPatterns = patRaw.map((x) => { const t = str(x), i = t.indexOf(":"); return { name: t.slice(0, i).trim().toLowerCase(), pattern: t.slice(i + 1).trim() }; })
|
|
117
126
|
.filter((x) => CAPABILITY_RE.test(x.name) && x.pattern && safeRegExp(x.pattern));
|
|
127
|
+
const wsN = Number(str(s.warm_sandboxes));
|
|
128
|
+
const warmSandboxes = str(s.warm_sandboxes) !== "" && Number.isInteger(wsN) && wsN > 0 ? wsN : 0; // #4912: warm sandboxes the pool agent keeps ready; 0 = off
|
|
118
129
|
const relay = Object.fromEntries(Object.entries(parseRelayCommands(s.relay_commands)).filter(([c]) => CAPABILITY_RE.test(c))); // #4778
|
|
119
130
|
const uw = Number(str(s.unreachable_window_min));
|
|
120
131
|
const unreachableWindowMin = str(s.unreachable_window_min) !== "" && Number.isFinite(uw) && uw >= 0 ? uw : null; // unset: providerLanes defaults to 5
|
|
121
|
-
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}) };
|
|
132
|
+
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}) };
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
const round1 = (x) => Math.round(x * 10) / 10;
|
|
@@ -525,7 +536,7 @@ export function providerByName(name, opts) {
|
|
|
525
536
|
* returns that sandbox's ledger (ledger.mjs `makeLedger`).
|
|
526
537
|
*/
|
|
527
538
|
export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env, secrets = [], pollMs = 5000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
528
|
-
costPerMinute = 0, now = () => Date.now(), unreachableWindowMs = 300000 }) {
|
|
539
|
+
costPerMinute = 0, now = () => Date.now(), unreachableWindowMs = 300000, logTail = null }) {
|
|
529
540
|
const cpm = nonNeg(Number(costPerMinute)) ?? 0;
|
|
530
541
|
// #4255: the sandbox's usage: the provider's `usage()`, else the billable minutes it put on the handle (#4254), else wall
|
|
531
542
|
// clock since the handle's startedAt; cost is the provider's, else minutes x cost_per_minute.
|
|
@@ -560,6 +571,8 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
560
571
|
};
|
|
561
572
|
const unreachable = (h, e) => ({ terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_UNREACHABLE, retryInSeconds: 60,
|
|
562
573
|
detail: `sandbox ${h.id} ${errText(e)}; kept for adopt on resume` });
|
|
574
|
+
const tailOf = async (h) => { try { return typeof logTail === "function" ? String(await logTail(h) ?? "") : ""; } catch { return ""; } };
|
|
575
|
+
const settleClone = (r, id) => (r?.terminalReason === "engine-error" && r.code === undefined && isCloneFailure(r.detail) ? cloneDeferred(id, errLine(r.detail)) : r);
|
|
563
576
|
const wait = async (h, g = guard({ waited: 0, tries: 0 })) => {
|
|
564
577
|
for (;;) {
|
|
565
578
|
const r = await g(() => provider.fetchResult(h));
|
|
@@ -567,7 +580,9 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
567
580
|
const st = await g(() => provider.status(h));
|
|
568
581
|
if (st !== "running") {
|
|
569
582
|
const last = await g(() => provider.fetchResult(h)); // it may have written the result just before exiting
|
|
570
|
-
|
|
583
|
+
if (last) return last;
|
|
584
|
+
const why = `sandbox ${h.id} ${st} without a result`;
|
|
585
|
+
return isCloneFailure(await tailOf(h)) ? cloneDeferred(h.id, why) : { terminalReason: "engine-error", detail: why };
|
|
571
586
|
}
|
|
572
587
|
await sleep(pollMs);
|
|
573
588
|
}
|
|
@@ -578,6 +593,7 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
578
593
|
? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)` }
|
|
579
594
|
: e?.retryable === true
|
|
580
595
|
? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
|
|
596
|
+
: isCloneFailure(errText(e)) ? cloneDeferred(id, errText(e))
|
|
581
597
|
: { terminalReason: "needs-human", code: OUTCOME_CODES.SANDBOX_START_FAILED, detail: `sandbox ${id} failed to start: ${errText(e)}` });
|
|
582
598
|
return {
|
|
583
599
|
// #4612: a graceful stop of a live sandbox (the provider's checkpoint stop); false when it has none.
|
|
@@ -600,10 +616,10 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
600
616
|
if (!Number.isFinite(h.startedAt)) h.startedAt = base.startedAt;
|
|
601
617
|
started = true;
|
|
602
618
|
ctx.onStart?.({ ...(Number.isInteger(h.pid) ? { pid: h.pid } : {}), handle: h, remote: true });
|
|
603
|
-
r = await wait(h);
|
|
619
|
+
r = settleClone(await wait(h), base.id);
|
|
604
620
|
} catch (e) {
|
|
605
621
|
if (started && e?.unreachable) return withUsage(unreachable(h, e), h); // #4672: no tear, the ledger keeps it
|
|
606
|
-
r = started ? { terminalReason: "engine-error", detail: `sandbox ${base.id}: ${errText(e)}` } : startFailed(base.id, e);
|
|
622
|
+
r = started ? settleClone({ terminalReason: "engine-error", detail: `sandbox ${base.id}: ${errText(e)}` }, base.id) : startFailed(base.id, e);
|
|
607
623
|
}
|
|
608
624
|
await tear(led, h); // every path, after the result is in (tear never throws)
|
|
609
625
|
return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
|
|
@@ -7,7 +7,7 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { renderContract } from "./render.mjs";
|
|
10
|
-
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, isTransportError, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
10
|
+
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
11
11
|
import { makeLedger, countLiveLanes } from "./ledger.mjs";
|
|
12
12
|
import { openBatch } from "./batch.mjs";
|
|
13
13
|
import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
|
|
@@ -43,6 +43,10 @@ const NO_BUDGET = { batchMinutes: null, batchUsd: null, dayMinutes: null, dayUsd
|
|
|
43
43
|
const ledgerIn = (root, { pid = process.pid, host = HOST } = {}) => (n) => makeLedger({ root, batch: "bt", lane: `sandbox-${n}`, owner: "orch_test", issue: n, host, pid });
|
|
44
44
|
|
|
45
45
|
describe("execution config (#4253)", () => {
|
|
46
|
+
it("warm_sandboxes: positive integer kept, default 0 omitted (#4912)", () => {
|
|
47
|
+
assert.equal(parseExecution({ mode: "remote", warm_sandboxes: "2" }).warmSandboxes, 2);
|
|
48
|
+
for (const v of [undefined, "0", "-1", "x", "1.5"]) assert.ok(!("warmSandboxes" in parseExecution({ mode: "remote", ...(v === undefined ? {} : { warm_sandboxes: v }) })));
|
|
49
|
+
});
|
|
46
50
|
it("pool is a known sandbox provider (#4546)", () => {
|
|
47
51
|
assert.ok(SANDBOX_PROVIDERS.includes("pool"));
|
|
48
52
|
assert.equal(parseExecution({ mode: "hybrid", max_remote: "1", provider: "pool" }).provider, "pool");
|
|
@@ -127,6 +131,8 @@ describe("providerLanes: an unreachable provider is retried, then deferred witho
|
|
|
127
131
|
it("unreachable: classifies transport errors and parses the window key", () => {
|
|
128
132
|
for (const e of [new TypeError("fetch failed"), Object.assign(new Error("x"), { code: "ECONNRESET" }), Object.assign(new Error("x"), { status: 502 }), Object.assign(new Error("x"), { name: "AbortError" })]) assert.equal(isTransportError(e), true);
|
|
129
133
|
for (const e of [new Error("boom"), Object.assign(new Error("HTTP 404"), { status: 404 })]) assert.equal(isTransportError(e), false);
|
|
134
|
+
for (const t of ["fatal: git clone timed out after 300s", "git clone failed: early EOF"]) assert.equal(isCloneFailure(t), true); // #4866
|
|
135
|
+
for (const t of ["clone of repo ok", "", null]) assert.equal(isCloneFailure(t), false);
|
|
130
136
|
assert.equal(parseExecution({}).unreachableWindowMin, undefined); // providerLanes defaults it to 5 min
|
|
131
137
|
assert.equal(parseExecution({ unreachable_window_min: "2" }).unreachableWindowMin, 2);
|
|
132
138
|
});
|
package/bin/lean/heavy.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
21
|
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, appendFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
22
22
|
import { cpus, freemem } from "node:os";
|
|
23
|
-
import { dirname, join } from "node:path";
|
|
23
|
+
import { delimiter, dirname, join } from "node:path";
|
|
24
24
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
25
|
import { writeAtomic } from "./batch.mjs";
|
|
26
26
|
import { schedDir, pidAlive, DEFAULT_PRIORITY } from "./sched.mjs";
|
|
@@ -310,6 +310,27 @@ export function sumWaits(text, waits = {}) {
|
|
|
310
310
|
return { waits, queueMax };
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Admission-time form of the guardChain loop (#4866): every PATH dir holding a pass-through wrapper for a heavy-shimmed tool
|
|
315
|
+
* with no real binary behind it (so the wrapper's own `command -v` lands back on the shim). Returns [{name, dir}].
|
|
316
|
+
*/
|
|
317
|
+
export function findShimLoops(path, { names = HEAVY_BINS, find = which, read = head, skip = [] } = {}) {
|
|
318
|
+
const out = [];
|
|
319
|
+
const dirs = String(path ?? "").split(delimiter).filter(Boolean);
|
|
320
|
+
for (const name of names) {
|
|
321
|
+
const wrappers = [];
|
|
322
|
+
for (const d of dirs) {
|
|
323
|
+
if (skip.includes(d)) continue;
|
|
324
|
+
const f = find(name, d, []);
|
|
325
|
+
if (f && isPassThroughWrapper(read(f), name)) wrappers.push(d);
|
|
326
|
+
}
|
|
327
|
+
if (!wrappers.length) continue;
|
|
328
|
+
const real = find(name, path, [...skip, ...wrappers]);
|
|
329
|
+
if (!real) for (const dir of wrappers) out.push({ name, dir });
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
313
334
|
export const SHIM_LOOP_MAX = 4;
|
|
314
335
|
/**
|
|
315
336
|
* Re-entry guard (#4680): FORGE_HEAVY_CHAIN lists `name:cmd` per shim run. A repeat of the same name+cmd where cmd is a
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4866: lane preflight. Host and environment faults (no pool token, a stale pool agent, a PATH wrapper that loops back
|
|
5
|
+
* to the heavy shim, a gh without a --json field the engine reads) used to surface inside lanes as 401s, fork loops or
|
|
6
|
+
* untyped engine-errors. Each check here returns `{code, ok, detail, scope}`; `scope` is "remote" (only remote lanes are held)
|
|
7
|
+
* or "all". The orchestrator holds admission while any check fails and re-checks each pass (never the breaker); `forgedock
|
|
8
|
+
* doctor --lanes` prints every check. Pure: all I/O is injected (`io`), so a fixed cause clears on the next call.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { findShimLoops } from "./heavy.mjs";
|
|
12
|
+
|
|
13
|
+
/** The running CLI's package version. */
|
|
14
|
+
export const cliVersion = () => JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
15
|
+
export const UPGRADE_COMMAND = "npx forgedock pool upgrade";
|
|
16
|
+
/** The `gh <cmd> --json` fields the engine reads (keep next to the call sites' lists in engine-cli.mjs and lean/*.mjs). */
|
|
17
|
+
export const GH_FIELDS = Object.freeze({
|
|
18
|
+
"issue view": ["number", "title", "body", "state", "stateReason", "labels", "comments", "milestone", "author", "createdAt", "updatedAt"],
|
|
19
|
+
"pr view": ["number", "state", "mergeable", "mergeStateStatus"],
|
|
20
|
+
"repo view": ["nameWithOwner", "defaultBranchRef"],
|
|
21
|
+
});
|
|
22
|
+
const pass = (code, detail, scope) => ({ code, ok: true, detail, scope });
|
|
23
|
+
const fail = (code, detail, scope) => ({ code, ok: false, detail, scope });
|
|
24
|
+
/** Preflight codes. Local to this module, not in OUTCOME_CODES: they are admission holds that never reach a run's terminal outcome. */
|
|
25
|
+
export const PREFLIGHT_CODES = Object.freeze({
|
|
26
|
+
PREFLIGHT_POOL_AUTH: "preflight.pool_auth", PREFLIGHT_POOL_VERSION: "preflight.pool_version",
|
|
27
|
+
PREFLIGHT_PATH_LOOP: "preflight.path_loop", PREFLIGHT_GH_FIELDS: "preflight.gh_fields",
|
|
28
|
+
});
|
|
29
|
+
const C = PREFLIGHT_CODES;
|
|
30
|
+
|
|
31
|
+
/** A remote or hybrid execution is configured (the only case the pool checks apply to). */
|
|
32
|
+
export const usesPool = (exec) => Boolean(exec) && (exec.mode === "remote" || exec.mode === "hybrid") && ["pool", "remote"].includes(exec.provider ?? "pool");
|
|
33
|
+
|
|
34
|
+
/** The pool token resolves (env/keychain `token_env`, else the router's `forgedock_token_file`) and the router does not answer 401/403 to an authenticated ping. */
|
|
35
|
+
export async function checkPoolAuth({ exec, router = null, credential = () => null, ping = null }) {
|
|
36
|
+
const code = C.PREFLIGHT_POOL_AUTH;
|
|
37
|
+
if (!usesPool(exec)) return pass(code, "skipped: no remote or hybrid execution", "remote");
|
|
38
|
+
const name = exec.tokenEnv ?? "COMPUTESHARE_TOKEN";
|
|
39
|
+
const fromEnv = credential(name);
|
|
40
|
+
const token = fromEnv || router?.token || null;
|
|
41
|
+
if (!token) return fail(code, `pool token does not resolve: tried env/keychain ${name}, ${router ? "the router's forgedock_token_file" : "no router config (COMPUTESHARE_CONFIG / ~/.config/computeshare/router.json)"}`, "remote");
|
|
42
|
+
const endpoint = exec.endpoint ?? router?.endpoint ?? null;
|
|
43
|
+
if (!endpoint || typeof ping !== "function") return pass(code, `token resolved from ${fromEnv ? name : "forgedock_token_file"}`, "remote");
|
|
44
|
+
let r = null;
|
|
45
|
+
try { r = await ping(endpoint, token); } catch { r = null; }
|
|
46
|
+
if ([401, 403].includes(r?.status)) return fail(code, `router ${endpoint} answered HTTP ${r.status} to the token from ${fromEnv ? name : "forgedock_token_file"}`, "remote");
|
|
47
|
+
return pass(code, `token resolved from ${fromEnv ? name : "forgedock_token_file"}${r ? "" : "; router unreachable (not an auth failure)"}`, "remote");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every online pool agent runs the CLI's version. `agents` is the router's `GET /agents` list (each spreads versionOf: version, image). */
|
|
51
|
+
export function checkPoolVersion({ exec, agents = null, cliVersion }) {
|
|
52
|
+
const code = C.PREFLIGHT_POOL_VERSION;
|
|
53
|
+
if (!usesPool(exec)) return pass(code, "skipped: no remote or hybrid execution", "remote");
|
|
54
|
+
if (!Array.isArray(agents)) return pass(code, "skipped: pool agents not readable", "remote");
|
|
55
|
+
const bad = agents.filter((a) => a?.ok !== false && typeof a?.version === "string" && a.version !== cliVersion);
|
|
56
|
+
if (!bad.length) return pass(code, `${agents.length} agent(s) at ${cliVersion}`, "remote");
|
|
57
|
+
return fail(code, bad.map((a) => `agent ${a.name ?? "?"} runs image version ${a.version}, CLI is ${cliVersion}`).join("; ") + `; upgrade: ${UPGRADE_COMMAND}`, "remote");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** No PATH wrapper dir resolves a heavy-shimmed tool back to the shim. */
|
|
61
|
+
export function checkPathLoop({ env = {}, loops = findShimLoops }) {
|
|
62
|
+
const code = C.PREFLIGHT_PATH_LOOP;
|
|
63
|
+
const skip = env.FORGE_HEAVY_SHIM_DIR ? [env.FORGE_HEAVY_SHIM_DIR] : [];
|
|
64
|
+
const found = loops(env.PATH ?? "", { skip });
|
|
65
|
+
if (!found.length) return pass(code, "no PATH wrapper loops back to the heavy shim", "all");
|
|
66
|
+
return fail(code, found.map((l) => `${l.dir} wraps ${l.name} and resolves back to the heavy shim`).join("; "), "all");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse the field names `gh <cmd> --json bogus` lists as available (null when the output has no list). */
|
|
70
|
+
export function availableFields(text) {
|
|
71
|
+
const m = /Available fields:\s*([\s\S]*)$/i.exec(String(text ?? ""));
|
|
72
|
+
if (!m) return null;
|
|
73
|
+
const f = m[1].split(/\s+/).map((x) => x.trim()).filter((x) => /^[A-Za-z]\w*$/.test(x));
|
|
74
|
+
return f.length ? f : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The installed gh offers every `--json` field the engine uses; `exec(args) -> {stdout, stderr}`. An unreadable gh passes (not this check's fault). */
|
|
78
|
+
export async function checkGhFields({ exec, fields = GH_FIELDS }) {
|
|
79
|
+
const code = C.PREFLIGHT_GH_FIELDS;
|
|
80
|
+
let version = "unknown";
|
|
81
|
+
try { version = /gh version (\S+)/.exec(String((await exec(["--version"]))?.stdout ?? ""))?.[1] ?? "unknown"; } catch { return pass(code, "skipped: gh not runnable", "all"); }
|
|
82
|
+
const probes = await Promise.all(Object.entries(fields).map(async ([cmd, want]) => {
|
|
83
|
+
try { return [want, cmd, await exec([...cmd.split(" "), "--json", "forgedock_probe"])]; } catch { return null; }
|
|
84
|
+
}));
|
|
85
|
+
const missing = [];
|
|
86
|
+
for (const pr of probes) {
|
|
87
|
+
if (!pr) continue;
|
|
88
|
+
const [want, cmd, r] = pr;
|
|
89
|
+
const have = availableFields(`${r?.stderr ?? ""}\n${r?.stdout ?? ""}`);
|
|
90
|
+
if (!have) continue;
|
|
91
|
+
for (const f of want) if (!have.includes(f)) missing.push(`${f} (gh ${cmd} --json)`);
|
|
92
|
+
}
|
|
93
|
+
return missing.length ? fail(code, `gh ${version} lacks --json field ${missing.join(", ")}`, "all") : pass(code, `gh ${version} has every --json field the engine uses`, "all");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Run every check; never throws (a check that throws passes with the error as its detail). */
|
|
97
|
+
export async function runLaneChecks({ exec = null, env = process.env, cliVersion: cliV = cliVersion(), io = {} } = {}) {
|
|
98
|
+
const safe = async (code, scope, fn) => { try { return await fn(); } catch (e) { return pass(code, `check errored: ${String(e?.message ?? e).split("\n")[0]}`, scope); } };
|
|
99
|
+
let router = null;
|
|
100
|
+
try { router = io.router?.() ?? null; } catch { router = null; }
|
|
101
|
+
const ping = io.ping ?? null;
|
|
102
|
+
const agentsOf = async () => { try { return (await io.agents?.(exec?.endpoint ?? router?.endpoint, exec?.tokenEnv ? (io.credential?.(exec.tokenEnv) ?? router?.token) : router?.token)) ?? null; } catch { return null; } };
|
|
103
|
+
return Promise.all([
|
|
104
|
+
safe(C.PREFLIGHT_POOL_AUTH, "remote", () => checkPoolAuth({ exec, router, credential: io.credential, ping })),
|
|
105
|
+
safe(C.PREFLIGHT_POOL_VERSION, "remote", async () => checkPoolVersion({ exec, agents: usesPool(exec) ? await agentsOf() : null, cliVersion: cliV })),
|
|
106
|
+
safe(C.PREFLIGHT_PATH_LOOP, "all", () => checkPathLoop({ env: { ...env, PATH: env.PATH ?? "" }, ...(io.loops ? { loops: io.loops } : {}) })),
|
|
107
|
+
safe(C.PREFLIGHT_GH_FIELDS, "all", () => (io.ghExec ? checkGhFields({ exec: io.ghExec }) : pass(C.PREFLIGHT_GH_FIELDS, "skipped: no gh", "all"))),
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One line per check: `PASS|FAIL code detail`. */
|
|
112
|
+
export const formatLaneChecks = (checks) => checks.map((c) => `${c.ok ? "PASS" : "FAIL"} ${c.code} ${c.detail}`).join("\n");
|
|
113
|
+
|
|
114
|
+
/** Real-host io for the daemon, the batch and `doctor --lanes`. gh output is cached per process (it cannot change under a running engine). */
|
|
115
|
+
export async function hostLaneIo({ env = process.env } = {}) {
|
|
116
|
+
const { poolRouterConfig } = await import("./execution.mjs");
|
|
117
|
+
const { resolveCredential } = await import("./credentials.mjs");
|
|
118
|
+
const { execFile } = await import("node:child_process");
|
|
119
|
+
const call = (url, token, timeoutMs = 5000) => fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
|
|
120
|
+
const run = (args) => new Promise((res, rej) => execFile("gh", args, { timeout: 15000, env }, (e, stdout, stderr) => (e && e.code === "ENOENT" ? rej(e) : res({ stdout, stderr }))));
|
|
121
|
+
const memo = new Map();
|
|
122
|
+
return {
|
|
123
|
+
router: () => poolRouterConfig({ env }),
|
|
124
|
+
credential: (n) => resolveCredential(n, { env }),
|
|
125
|
+
ping: async (endpoint, token) => ({ status: (await call(`${endpoint}/status`, token)).status }),
|
|
126
|
+
agents: async (endpoint, token) => { const r = await call(`${endpoint}/agents`, token); return r.ok ? (await r.json())?.agents ?? null : null; },
|
|
127
|
+
ghExec: (args) => { const k = args.join(" "); if (!memo.has(k)) memo.set(k, run(args)); return memo.get(k); },
|
|
128
|
+
};
|
|
129
|
+
}
|