forgedock 1.49.0 → 1.51.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 +2 -0
- package/bin/engine-cli.mjs +123 -20
- package/bin/forgedock.mjs +31 -3
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +12 -3
- package/bin/lean/batch.test.mjs +304 -8
- package/bin/lean/conformance.mjs +79 -7
- package/bin/lean/conformance.test.mjs +63 -1
- package/bin/lean/context.mjs +8 -2
- package/bin/lean/context.test.mjs +11 -1
- package/bin/lean/credentials.mjs +18 -0
- package/bin/lean/execution.mjs +23 -9
- package/bin/lean/execution.test.mjs +93 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lean.mjs +93 -36
- package/bin/lean/lean.test.mjs +143 -19
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +135 -17
- package/bin/lean/prefilter.mjs +4 -3
- package/bin/lean/prefilter.test.mjs +5 -4
- package/bin/lean/prompts.mjs +4 -2
- package/bin/lean/recovery.test.mjs +5 -5
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +9 -0
- package/bin/pool/agent.mjs +3 -1
- package/bin/pool/router.mjs +72 -7
- package/bin/review-staging.mjs +2 -0
- package/bin/runner.mjs +24 -0
- package/commands/orchestrate.md +2 -1
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +39 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/pool/README.md +2 -0
- package/pool/image/cs-cred +8 -1
- package/scripts/review-staging-post.sh +70 -25
- package/scripts/review-staging-post.test.sh +40 -8
package/bin/lean/context.mjs
CHANGED
|
@@ -212,9 +212,15 @@ const failuresOf = (st) => (Array.isArray(st?.failureClasses) ? [...new Set(st.f
|
|
|
212
212
|
const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
|
|
213
213
|
const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* #5152: a closed record's result for the builder context. Delivered (`workflow:merged`) or moved (`workflow:decomposed`)
|
|
217
|
+
* scope reads `completed` whatever its `stateReason`, so a legacy NOT_PLANNED close of delivered work never reads as dropped.
|
|
218
|
+
*/
|
|
219
|
+
export const closedResult = (rec) => (["workflow:merged", "workflow:decomposed"].some((l) => (rec?.labels ?? []).includes(l)) ? "completed" : rec?.stateReason ?? "closed");
|
|
215
220
|
async function readIssue(gh, repo, n) {
|
|
216
221
|
const j = JSON.parse(String(await gh(["api", `repos/${repo}/issues/${n}`])));
|
|
217
|
-
return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request)
|
|
222
|
+
return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request),
|
|
223
|
+
labels: (Array.isArray(j.labels) ? j.labels : []).map((l) => String(l?.name ?? l)) };
|
|
218
224
|
}
|
|
219
225
|
async function readComments(gh, repo, n) {
|
|
220
226
|
return parseLines(await gh(["api", "--paginate", `repos/${repo}/issues/${n}/comments`, "--jq", ".[] | {id, body, author_association, user: {type: .user.type, login: .user.login}} | @json"]));
|
|
@@ -324,7 +330,7 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
324
330
|
const st = stateOf(rec.body);
|
|
325
331
|
const reason = codeOf(st?.terminalReason), fails = failuresOf(st);
|
|
326
332
|
facts.links.push({ number: n, rel: r, kind: rec.pr ? "pr" : "issue", title: rec.title, state: rec.state, code: outcomeOf(st),
|
|
327
|
-
result: reason ?? (rec.state === "CLOSED" ? rec
|
|
333
|
+
result: reason ?? (rec.state === "CLOSED" ? closedResult(rec) : null), ...(fails.length ? { failures: fails } : {}), ...(r === "parent" || r === "depends-on" ? { body: stripComments(rec.body) } : {}) });
|
|
328
334
|
}
|
|
329
335
|
});
|
|
330
336
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
6
|
-
import { gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments, subjectTerms, subjectHistory, MAX_SUBJECT_ITEMS, MAX_SUBJECT_QUERIES } from "./context.mjs";
|
|
6
|
+
import { closedResult, gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments, subjectTerms, subjectHistory, MAX_SUBJECT_ITEMS, MAX_SUBJECT_QUERIES } from "./context.mjs";
|
|
7
7
|
|
|
8
8
|
const REPO = "o/r";
|
|
9
9
|
const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
|
|
@@ -570,3 +570,13 @@ describe("#5085: subject history and recall status", () => {
|
|
|
570
570
|
assert.equal(compileContextPack({ issue: 1, links: [], attempts: [], ledger: [], facts: [], recall: absent.recall }).recall.present, false, "the empty pack carries it too");
|
|
571
571
|
});
|
|
572
572
|
});
|
|
573
|
+
|
|
574
|
+
describe("#5152 builder context: delivered or moved scope never reads as dropped", () => {
|
|
575
|
+
it("a closed workflow:merged or workflow:decomposed dependency is completed whatever its stateReason (legacy not_planned too)", () => {
|
|
576
|
+
assert.equal(closedResult({ stateReason: "not_planned", labels: ["workflow:merged"] }), "completed");
|
|
577
|
+
assert.equal(closedResult({ stateReason: "not_planned", labels: ["bug", "workflow:decomposed"] }), "completed");
|
|
578
|
+
assert.equal(closedResult({ stateReason: "not_planned", labels: ["workflow:invalid"] }), "not_planned", "dropped scope keeps its reason");
|
|
579
|
+
assert.equal(closedResult({ stateReason: "completed", labels: [] }), "completed");
|
|
580
|
+
assert.equal(closedResult({ stateReason: null }), "closed");
|
|
581
|
+
});
|
|
582
|
+
});
|
package/bin/lean/credentials.mjs
CHANGED
|
@@ -27,3 +27,21 @@ export function resolveCredential(name, { env = process.env, keychain = defaultK
|
|
|
27
27
|
if (v !== undefined && v !== null && String(v) !== "") return String(v);
|
|
28
28
|
try { const k = keychain(String(name), platform); return k === undefined || k === null || String(k) === "" ? null : String(k); } catch { return null; }
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* #5159: a keychain lookup memoised per name for `ttlMs` (a miss or a failure is cached as null too), so a resolver run
|
|
33
|
+
* on every request (#5144) spawns `secret-tool`/`security` at most once per TTL instead of on every HTTP call.
|
|
34
|
+
*/
|
|
35
|
+
export function memoKeychain({ keychain = defaultKeychain, ttlMs = 60000, now = Date.now } = {}) {
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
return (name, platform) => {
|
|
38
|
+
const key = `${platform ?? ""}\u0000${name}`;
|
|
39
|
+
const hit = cache.get(key);
|
|
40
|
+
const t = now();
|
|
41
|
+
if (hit && t - hit.at < ttlMs) return hit.value;
|
|
42
|
+
let value = null;
|
|
43
|
+
try { const k = keychain(name, platform); value = k === undefined || k === null || String(k) === "" ? null : String(k); } catch { value = null; }
|
|
44
|
+
cache.set(key, { at: t, value });
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
47
|
+
}
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -58,7 +58,7 @@ import { isLaneAlive, launchLane, writeAtomic } from "./batch.mjs";
|
|
|
58
58
|
import { schedDir } from "./sched.mjs";
|
|
59
59
|
import { dockerLabels, safeSegment } from "./ledger.mjs";
|
|
60
60
|
import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
61
|
-
import { resolveCredential } from "./credentials.mjs";
|
|
61
|
+
import { resolveCredential, memoKeychain, defaultKeychain } from "./credentials.mjs";
|
|
62
62
|
import { httpTransport, isActionsEndpoint, remoteProvider } from "./sandbox-remote.mjs";
|
|
63
63
|
import { ADMISSION_DEFAULTS } from "./admission.mjs";
|
|
64
64
|
import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
|
|
@@ -515,17 +515,22 @@ export function poolCredential(exec = {}, { env = process.env, router = null, ke
|
|
|
515
515
|
/**
|
|
516
516
|
* The remote transport for forge.yaml `execution` settings: `opts.transport` when injected, else `httpTransport` on
|
|
517
517
|
* `exec.endpoint` with the credential `exec.tokenEnv` from `credentials`, or by default poolCredential (#5131: env,
|
|
518
|
-
* keychain, `token_file`, the matching local router's token
|
|
518
|
+
* keychain, `token_file`, the matching local router's token; #5159: the keychain lookup is memoised per transport, `keychain`
|
|
519
|
+
* is its seam). `fetchFn` is a test seam. Throws `execution.provider.misconfigured`
|
|
519
520
|
* without an endpoint, `execution.provider.actions_refused` for a GitHub Actions endpoint without `allow_actions`.
|
|
520
521
|
*/
|
|
521
|
-
export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn } = {}) {
|
|
522
|
+
export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn, keychain } = {}) {
|
|
522
523
|
if (transport) return transport;
|
|
523
524
|
if (!exec?.endpoint) throw typedError("execution.provider.misconfigured", "execution.provider remote needs execution.endpoint");
|
|
524
525
|
if (isActionsEndpoint(exec.endpoint) && exec.allowActions !== true) {
|
|
525
526
|
throw typedError("execution.provider.actions_refused", "execution.endpoint is a GitHub Actions API; set execution.allow_actions: true to spend Actions minutes");
|
|
526
527
|
}
|
|
527
|
-
|
|
528
|
-
|
|
528
|
+
// #5144: a getter, so the bearer is resolved on every request (a token fixed or rotated mid-batch is picked up), not once here.
|
|
529
|
+
const rt = credentials ? null : (router !== undefined ? router : poolRouterConfig({ env }));
|
|
530
|
+
// #5159: the env and token files are re-read per request; the sync keychain lookup is memoised (misses too) for its TTL.
|
|
531
|
+
const kc = credentials ? null : memoKeychain({ keychain: keychain ?? defaultKeychain });
|
|
532
|
+
const token = credentials ? () => (exec.tokenEnv ? credentials(exec.tokenEnv) : null)
|
|
533
|
+
: () => poolCredential({ provider: "remote", ...exec }, { env, router: rt, keychain: kc }).token;
|
|
529
534
|
return httpTransport({ endpoint: exec.endpoint, token, ...(fetchFn ? { fetchFn } : {}) });
|
|
530
535
|
}
|
|
531
536
|
|
|
@@ -556,13 +561,22 @@ export function providerByName(name, opts) {
|
|
|
556
561
|
let forgeYaml = null;
|
|
557
562
|
if (pool) { try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; } }
|
|
558
563
|
const endpoint = exec.endpoint ?? (pool ? router?.endpoint : undefined);
|
|
559
|
-
|
|
560
|
-
const
|
|
564
|
+
// #5144: the router's token file is re-read per call (a rotated or newly written token), its endpoint stays as at construction.
|
|
565
|
+
const routerEligible = !(opts?.transport || (pool && exec.endpoint));
|
|
566
|
+
const routerNow = () => {
|
|
567
|
+
if (!routerEligible) return null;
|
|
568
|
+
const c = poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
|
|
569
|
+
return c ? { endpoint: router?.endpoint ?? c.endpoint, token: c.token } : router;
|
|
570
|
+
};
|
|
571
|
+
// #5159: one memoised keychain per provider, so a `token_env` absent from the env does not spawn the keychain per request.
|
|
572
|
+
const keychain = memoKeychain({ keychain: opts?.keychain ?? defaultKeychain });
|
|
573
|
+
const resolve = () => poolCredential({ ...exec, provider: name, ...(endpoint ? { endpoint } : {}) }, { env, router: routerNow(), keychain });
|
|
561
574
|
// A token read from a file is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
|
|
575
|
+
// #5144: set even when no token exists yet, so one that appears later is still resolved per call (and redacted).
|
|
562
576
|
const fixed = pool ? "FORGEDOCK_POOL_TOKEN" : "FORGEDOCK_REMOTE_TOKEN";
|
|
563
|
-
const tokenEnv = exec.tokenEnv ??
|
|
577
|
+
const tokenEnv = exec.tokenEnv ?? fixed;
|
|
564
578
|
// #4685/#5131: an empty env/keychain lookup of `token_env` falls back to `token_file`, then the (matching) router's token file.
|
|
565
|
-
const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env })); // per call,
|
|
579
|
+
const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env, keychain })); // per call, keychain memoised (#5159)
|
|
566
580
|
const settings = { ...exec, ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}),
|
|
567
581
|
...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }; // #4778: dispatch-time relay pre-fetch
|
|
568
582
|
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, ...(pool ? { name: "pool" } : {}), env, credentials,
|
|
@@ -399,6 +399,27 @@ describe("runBatch wiring (#4253)", () => {
|
|
|
399
399
|
assert.deepEqual(prov.calls, []);
|
|
400
400
|
}
|
|
401
401
|
});
|
|
402
|
+
|
|
403
|
+
it("#5138: a live --set-placement local makes the next lane of a remote batch a detached host lane", async () => {
|
|
404
|
+
const work = tmpDir("exec-cli-live-");
|
|
405
|
+
const stub = join(work, "stub.mjs");
|
|
406
|
+
writeFileSync(stub, `import { writeFileSync } from "node:fs"; const a = process.argv.slice(2); writeFileSync(a[a.indexOf("--result-file") + 1], JSON.stringify({ terminalReason: "merged", pr: 9 }));\n`);
|
|
407
|
+
const root = join(work, "batches");
|
|
408
|
+
let recorded = null;
|
|
409
|
+
const prov = fakeProvider({ finish: (n) => {
|
|
410
|
+
const b = openBatch(root, "blive");
|
|
411
|
+
recorded = b.readExecution();
|
|
412
|
+
if (n === 1) b.setExecution({ mode: "local" }); // the operator's --set-placement before #2 is admitted
|
|
413
|
+
return { terminalReason: "merged", pr: 100 + n, cost_usd: 0 };
|
|
414
|
+
} });
|
|
415
|
+
const res = await quiet(() => orchestrateFromCli(["1", "2", "--repo", "o/r", "--no-prefilter", "--parallel", "1"], base(work, { batchId: "blive", laneScript: stub,
|
|
416
|
+
execution: parseExecution({ mode: "remote", max_remote: "1" }), sandboxProvider: prov,
|
|
417
|
+
sandboxLedger: (n) => makeLedger({ root: join(work, "ledger"), batch: "blive", lane: `sandbox-${n}`, owner: "t", issue: n }) })));
|
|
418
|
+
assert.deepEqual(recorded, { provider: true });
|
|
419
|
+
assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr, r.placement?.where]), [[1, "merged", 101, "remote"], [2, "merged", 9, "local"]]);
|
|
420
|
+
assert.match(res.rows[1].placement.reason, /live placement/);
|
|
421
|
+
assert.deepEqual(prov.calls.filter((c) => c[0] === "start").map((c) => c[1]), [1]);
|
|
422
|
+
});
|
|
402
423
|
});
|
|
403
424
|
|
|
404
425
|
describe("execution budget and sandbox cost (#4255)", () => {
|
|
@@ -960,6 +981,78 @@ describe("remote/pool credential resolver (#5131)", () => {
|
|
|
960
981
|
assert.equal(hit.seen[0].auth, "Bearer file-tok");
|
|
961
982
|
});
|
|
962
983
|
|
|
984
|
+
it("5144: a token that appears or rotates after the provider is built is the next request's bearer", async () => {
|
|
985
|
+
const dir = tmpDir("router-5144-");
|
|
986
|
+
const tokenFile = join(dir, "forgedock.token");
|
|
987
|
+
writeFileSync(join(dir, "router.json"), JSON.stringify({ listen: { host: "127.0.0.1", port: 7710 } }));
|
|
988
|
+
writeFileSync(tokenFile, "\n"); // router config with no token yet
|
|
989
|
+
const hit = recorder();
|
|
990
|
+
const env = {};
|
|
991
|
+
const mk = (name, exec) => providerByName(name, { batch: { id: "b5144" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: name, ...exec }),
|
|
992
|
+
env, poolConfigPath: join(dir, "router.json"), fetchFn: hit.fetchFn });
|
|
993
|
+
for (const [name, exec] of [["remote", { endpoint: "http://127.0.0.1:7710" }], ["pool", {}]]) {
|
|
994
|
+
writeFileSync(tokenFile, "\n");
|
|
995
|
+
hit.seen.length = 0;
|
|
996
|
+
const p = mk(name, exec);
|
|
997
|
+
await p.capacity();
|
|
998
|
+
assert.equal(hit.seen.at(-1).auth, null, `${name}: no token at construction`);
|
|
999
|
+
writeFileSync(tokenFile, "router-new\n");
|
|
1000
|
+
await p.capacity();
|
|
1001
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer router-new", `${name}: a token written later is sent`);
|
|
1002
|
+
writeFileSync(tokenFile, "router-rotated\n");
|
|
1003
|
+
await p.capacity();
|
|
1004
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer router-rotated", `${name}: a rotated token is sent`);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("5144: an env token_env that is set after construction is sent; remoteTransportFor default path resolves per call", async () => {
|
|
1009
|
+
const dir = routerDir();
|
|
1010
|
+
const hit = recorder();
|
|
1011
|
+
const penv = {};
|
|
1012
|
+
const q = providerByName("remote", { batch: { id: "b5144e" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", endpoint: "https://runner.example.com", token_env: "COMPUTESHARE_TOKEN" }),
|
|
1013
|
+
env: penv, poolConfigPath: join(dir, "router.json"), fetchFn: hit.fetchFn });
|
|
1014
|
+
await q.capacity();
|
|
1015
|
+
assert.equal(hit.seen.at(-1).auth, null);
|
|
1016
|
+
penv.COMPUTESHARE_TOKEN = "env-late";
|
|
1017
|
+
await q.capacity();
|
|
1018
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer env-late");
|
|
1019
|
+
const tenv = {};
|
|
1020
|
+
const t = remoteTransportFor({ endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN" }, { env: tenv, router: null, fetchFn: hit.fetchFn });
|
|
1021
|
+
await t.capacity();
|
|
1022
|
+
assert.equal(hit.seen.at(-1).auth, null);
|
|
1023
|
+
tenv.COMPUTESHARE_TOKEN = "env-late-2";
|
|
1024
|
+
await t.capacity();
|
|
1025
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("5159: a token_env absent from the env runs the keychain once, not on every request; the token_file bearer and a later env token still apply", async () => {
|
|
1029
|
+
const dir = tmpDir("kc-5159-");
|
|
1030
|
+
const file = join(dir, "explicit.token");
|
|
1031
|
+
writeFileSync(file, "file-tok\n");
|
|
1032
|
+
const hit = recorder();
|
|
1033
|
+
let calls = 0;
|
|
1034
|
+
const keychain = () => { calls++; return null; };
|
|
1035
|
+
const env = {};
|
|
1036
|
+
const p = providerByName("remote", { batch: { id: "b5159" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", endpoint: "https://runner.example.com", token_env: "COMPUTESHARE_TOKEN", token_file: file }),
|
|
1037
|
+
env, poolConfigPath: join(dir, "none.json"), fetchFn: hit.fetchFn, keychain });
|
|
1038
|
+
for (let i = 0; i < 4; i++) await p.capacity();
|
|
1039
|
+
assert.ok(hit.seen.length >= 4);
|
|
1040
|
+
assert.ok(hit.seen.every((x) => x.auth === "Bearer file-tok"), "every request carries the token_file bearer");
|
|
1041
|
+
assert.ok(calls <= 1, `keychain ran ${calls} times`);
|
|
1042
|
+
env.COMPUTESHARE_TOKEN = "env-late";
|
|
1043
|
+
await p.capacity();
|
|
1044
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer env-late");
|
|
1045
|
+
let tcalls = 0;
|
|
1046
|
+
const tenv = {};
|
|
1047
|
+
const t = remoteTransportFor({ endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN", tokenFile: file }, { env: tenv, router: null, fetchFn: hit.fetchFn, keychain: () => { tcalls++; return null; } });
|
|
1048
|
+
for (let i = 0; i < 4; i++) await t.capacity();
|
|
1049
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer file-tok");
|
|
1050
|
+
assert.equal(tcalls, 1, "remoteTransportFor: one keychain lookup across requests");
|
|
1051
|
+
tenv.COMPUTESHARE_TOKEN = "env-late-2";
|
|
1052
|
+
await t.capacity();
|
|
1053
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
|
|
1054
|
+
});
|
|
1055
|
+
|
|
963
1056
|
it("5131: remoteTransportFor's default credentials (gc) fall back to the matching router token", async () => {
|
|
964
1057
|
const hit = recorder();
|
|
965
1058
|
const t = remoteTransportFor({ endpoint: "http://127.0.0.1:7710" }, { env: {}, router: { endpoint: "http://127.0.0.1:7710", token: "router-tok" }, fetchFn: hit.fetchFn });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* `forgedock fix-close-reasons <owner/repo> [--milestone <m>] [--apply]` (#5177, deferred from #5152). No LLM.
|
|
5
|
+
* Migration helper: an issue closed `not planned` that carries `workflow:merged` or `workflow:decomposed` was delivered
|
|
6
|
+
* (or moved to sub-issues), not dropped. It is re-closed as `completed` with a comment. One PATCH changes the reason in place
|
|
7
|
+
* (no reopen window). A `workflow:invalid` issue is a real drop and is never touched. Dry-run unless `--apply`.
|
|
8
|
+
*/
|
|
9
|
+
const DELIVERED = ["workflow:merged", "workflow:decomposed"];
|
|
10
|
+
const isNotPlanned = (r) => String(r ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
|
|
11
|
+
|
|
12
|
+
const commentFor = (label) => label === "workflow:decomposed"
|
|
13
|
+
? "Close reason corrected to `completed`: this issue was decomposed and its scope moved to its sub-issues, it was not dropped (`workflow:decomposed`). Re-closed by `forgedock fix-close-reasons` (#5177)."
|
|
14
|
+
: "Close reason corrected to `completed`: the work was delivered and merged, it was not dropped (`workflow:merged`). Re-closed by `forgedock fix-close-reasons` (#5177).";
|
|
15
|
+
|
|
16
|
+
/** Closed `not planned` issues that were delivered: [{number, label}], by issue number, once each. */
|
|
17
|
+
export async function findMisclosed({ gh, repo, milestone }) {
|
|
18
|
+
const found = new Map();
|
|
19
|
+
for (const label of DELIVERED) {
|
|
20
|
+
const out = await gh(["issue", "list", "-R", repo, "--state", "closed", "--label", label, ...(milestone ? ["--milestone", milestone] : []), "--limit", "1000", "--json", "number,stateReason,labels"]);
|
|
21
|
+
for (const i of JSON.parse(String(out || "[]"))) {
|
|
22
|
+
const names = (i.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
|
|
23
|
+
if (!isNotPlanned(i.stateReason) || names.includes("workflow:invalid") || found.has(i.number)) continue;
|
|
24
|
+
found.set(i.number, { number: i.number, label });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...found.values()].sort((a, b) => a.number - b.number);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runFixCloseReasonsCli(argv, deps = {}) {
|
|
31
|
+
const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
|
|
32
|
+
const repo = argv[0] && !argv[0].startsWith("--") ? argv[0] : (val("--repo") ?? deps.repo ?? null);
|
|
33
|
+
if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error("usage: forgedock fix-close-reasons <owner/repo> [--milestone <title|number>] [--apply]");
|
|
34
|
+
const milestone = val("--milestone");
|
|
35
|
+
if (argv.includes("--milestone") && (!milestone || milestone.startsWith("--"))) throw new Error("--milestone requires a milestone title or number");
|
|
36
|
+
const apply = argv.includes("--apply");
|
|
37
|
+
const gh = deps.gh ?? (await import("./effects.mjs")).makeLeanIo({ repo }).gh;
|
|
38
|
+
const print = deps.print ?? ((t) => console.log(t));
|
|
39
|
+
const items = await findMisclosed({ gh, repo, milestone });
|
|
40
|
+
let changed = 0, failed = 0;
|
|
41
|
+
for (const { number, label } of items) {
|
|
42
|
+
if (!apply) { print(`would re-close #${number} as completed (${label})`); continue; }
|
|
43
|
+
try {
|
|
44
|
+
await gh(["api", "-X", "PATCH", `repos/${repo}/issues/${number}`, "-f", "state=closed", "-f", "state_reason=completed"]);
|
|
45
|
+
await gh(["issue", "comment", String(number), "-R", repo, "--body", commentFor(label)]);
|
|
46
|
+
changed++;
|
|
47
|
+
print(`re-closed #${number} as completed (${label})`);
|
|
48
|
+
} catch (e) { failed++; print(`#${number}: failed: ${String(e?.message ?? e).slice(0, 160)}`); }
|
|
49
|
+
}
|
|
50
|
+
print(`fix-close-reasons: ${items.length} found, ${changed} changed, ${failed} failed${apply ? "" : " (dry run, pass --apply to write)"}`);
|
|
51
|
+
return failed ? 1 : 0;
|
|
52
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { runFixCloseReasonsCli } from "./fixclosereasons.mjs";
|
|
6
|
+
|
|
7
|
+
const L = (...n) => n.map((name) => ({ name }));
|
|
8
|
+
function fake({ lists, failOn } = {}) {
|
|
9
|
+
const calls = [], out = [];
|
|
10
|
+
const gh = async (a) => {
|
|
11
|
+
calls.push(a);
|
|
12
|
+
if (a[0] === "issue" && a[1] === "list") return JSON.stringify(lists[a[a.indexOf("--label") + 1]] ?? []);
|
|
13
|
+
if (a[0] === "api" && failOn === a[3].split("/").pop()) throw new Error("boom");
|
|
14
|
+
return "";
|
|
15
|
+
};
|
|
16
|
+
return { gh, calls, out, print: (t) => out.push(t) };
|
|
17
|
+
}
|
|
18
|
+
const lists = {
|
|
19
|
+
"workflow:merged": [
|
|
20
|
+
{ number: 1, stateReason: "NOT_PLANNED", labels: L("workflow:merged") },
|
|
21
|
+
{ number: 2, stateReason: "COMPLETED", labels: L("workflow:merged") },
|
|
22
|
+
{ number: 3, stateReason: "NOT_PLANNED", labels: L("workflow:merged", "workflow:invalid") },
|
|
23
|
+
],
|
|
24
|
+
"workflow:decomposed": [
|
|
25
|
+
{ number: 4, stateReason: "NOT_PLANNED", labels: L("workflow:decomposed") },
|
|
26
|
+
{ number: 1, stateReason: "NOT_PLANNED", labels: L("workflow:merged", "workflow:decomposed") },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("#5177 fix-close-reasons", () => {
|
|
31
|
+
it("re-closes NOT_PLANNED merged and decomposed issues as completed with a comment; leaves the rest", async () => {
|
|
32
|
+
const f = fake({ lists });
|
|
33
|
+
assert.equal(await runFixCloseReasonsCli(["o/r", "--apply"], f), 0);
|
|
34
|
+
const patches = f.calls.filter((a) => a[0] === "api");
|
|
35
|
+
assert.deepEqual(patches.map((a) => a[3]), ["repos/o/r/issues/1", "repos/o/r/issues/4"]);
|
|
36
|
+
for (const p of patches) assert.deepEqual(p.slice(0, 3).concat(p.slice(4)), ["api", "-X", "PATCH", "-f", "state=closed", "-f", "state_reason=completed"]);
|
|
37
|
+
const comments = f.calls.filter((a) => a[1] === "comment");
|
|
38
|
+
assert.deepEqual(comments.map((a) => a[2]), ["1", "4"]);
|
|
39
|
+
assert.match(comments[0][comments[0].indexOf("--body") + 1], /delivered and merged/);
|
|
40
|
+
assert.match(comments[1][comments[1].indexOf("--body") + 1], /decomposed/);
|
|
41
|
+
assert.match(f.out.at(-1), /2 found, 2 changed, 0 failed/);
|
|
42
|
+
});
|
|
43
|
+
it("is a dry run without --apply: no write", async () => {
|
|
44
|
+
const f = fake({ lists });
|
|
45
|
+
assert.equal(await runFixCloseReasonsCli(["o/r"], f), 0);
|
|
46
|
+
assert.equal(f.calls.filter((a) => a[0] !== "issue" || a[1] !== "list").length, 0);
|
|
47
|
+
assert.match(f.out.join("\n"), /would re-close #1/);
|
|
48
|
+
});
|
|
49
|
+
it("--milestone narrows the list query", async () => {
|
|
50
|
+
const f = fake({ lists: {} });
|
|
51
|
+
await runFixCloseReasonsCli(["o/r", "--milestone", "M1"], f);
|
|
52
|
+
for (const c of f.calls) assert.equal(c[c.indexOf("--milestone") + 1], "M1");
|
|
53
|
+
assert.equal(f.calls.length, 2);
|
|
54
|
+
});
|
|
55
|
+
it("counts a failed write and exits 1, continuing with the next issue", async () => {
|
|
56
|
+
const f = fake({ lists, failOn: "1" });
|
|
57
|
+
assert.equal(await runFixCloseReasonsCli(["o/r", "--apply"], f), 1);
|
|
58
|
+
assert.match(f.out.at(-1), /2 found, 1 changed, 1 failed/);
|
|
59
|
+
});
|
|
60
|
+
it("throws usage without a repo", async () => {
|
|
61
|
+
await assert.rejects(runFixCloseReasonsCli([], fake()), /usage: forgedock fix-close-reasons/);
|
|
62
|
+
await assert.rejects(runFixCloseReasonsCli(["--apply"], fake()), /usage/);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/bin/lean/heavy.mjs
CHANGED
|
@@ -266,10 +266,16 @@ export function heavyShimScript(name, real, { node = process.execPath, script =
|
|
|
266
266
|
return `${SHIM_HEADER}export FORGE_HEAVY_SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec ${q(node)} ${q(script)} run --name ${q(name)} -- ${q(real)} "$@"\n`;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
/**
|
|
269
|
+
/**
|
|
270
|
+
* True when `text` is a shell-style script that looks `name` up on PATH itself (a pass-through wrapper, #4680). The lookup
|
|
271
|
+
* target may be the literal name, or (#5148) a variable/substitution when the script derives that name from `$0`
|
|
272
|
+
* (a `${0##…}` strip or `basename "$0"`), the shape of a wrapper symlinked under several command names.
|
|
273
|
+
*/
|
|
270
274
|
export function isPassThroughWrapper(text, name) {
|
|
271
275
|
const t = String(text ?? "");
|
|
272
|
-
|
|
276
|
+
if (!t.startsWith("#!") || !/\b(command\s+-v|which|type\s+-p)\b/.test(t)) return false;
|
|
277
|
+
if (t.includes(name)) return true;
|
|
278
|
+
return /(?:command\s+-v|which|type\s+-p)\s+(?:-\s+)?["']?\$/.test(t) && /\$\{0##\*\/\}|basename\s+(?:--\s+)?["']?\$\{?0\b/.test(t);
|
|
273
279
|
}
|
|
274
280
|
const head = (p) => { try { return readFileSync(p, "utf8").slice(0, 4096); } catch { return ""; } };
|
|
275
281
|
|
|
@@ -332,6 +338,8 @@ export function findShimLoops(path, { names = HEAVY_BINS, find = which, read = h
|
|
|
332
338
|
}
|
|
333
339
|
|
|
334
340
|
export const SHIM_LOOP_MAX = 4;
|
|
341
|
+
/** #5148: hard bound on shim entries (FORGE_HEAVY_DEPTH) whatever the wrapper shape; far above legitimate `npm run` nesting. */
|
|
342
|
+
export const SHIM_DEPTH_MAX = 32;
|
|
335
343
|
/**
|
|
336
344
|
* Re-entry guard (#4680): FORGE_HEAVY_CHAIN lists `name:cmd` per shim run. A repeat of the same name+cmd where cmd is a
|
|
337
345
|
* pass-through wrapper means it sent the call back to the shim; a repeat of a real binary is a legitimate nested call
|
|
@@ -342,15 +350,17 @@ export function guardChain(name, cmd, env, read = head) {
|
|
|
342
350
|
const chain = String(env.FORGE_HEAVY_CHAIN ?? "").split("|").filter(Boolean);
|
|
343
351
|
const dirs = chain.map((e) => dirname(e.slice(e.indexOf(":") + 1)));
|
|
344
352
|
const loop = (why) => ({ error: `env.shim_loop: ${name} ${why} (PATH dirs: ${[env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean).join(", ") || "none"})` });
|
|
353
|
+
const depth = Math.max(0, Number.parseInt(env.FORGE_HEAVY_DEPTH, 10) || 0);
|
|
354
|
+
if (depth >= SHIM_DEPTH_MAX) return loop(`re-entered the heavy shim ${depth} levels deep (max ${SHIM_DEPTH_MAX})`);
|
|
345
355
|
const repeat = chain.includes(`${name}:${cmd}`);
|
|
346
|
-
if (repeat && !isPassThroughWrapper(read(cmd), name)) return { cmd, env };
|
|
356
|
+
if (repeat && !isPassThroughWrapper(read(cmd), name)) return { cmd, env: { ...env, FORGE_HEAVY_DEPTH: String(depth + 1) } };
|
|
347
357
|
if (chain.length >= SHIM_LOOP_MAX) return loop(`re-entered the heavy shim more than ${SHIM_LOOP_MAX} times`);
|
|
348
358
|
if (repeat) {
|
|
349
359
|
const next = which(name, env.PATH ?? "", [env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean));
|
|
350
360
|
if (!next) return loop(`re-entered via ${cmd} and no other ${name} is on PATH`);
|
|
351
361
|
cmd = next;
|
|
352
362
|
}
|
|
353
|
-
return { cmd, env: { ...env, FORGE_HEAVY_CHAIN: [...chain, `${name}:${cmd}`].join("|") } };
|
|
363
|
+
return { cmd, env: { ...env, FORGE_HEAVY_DEPTH: String(depth + 1), FORGE_HEAVY_CHAIN: [...chain, `${name}:${cmd}`].join("|") } };
|
|
354
364
|
}
|
|
355
365
|
|
|
356
366
|
/**
|
package/bin/lean/heavy.test.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
-
import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
|
|
9
|
+
import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, SHIM_DEPTH_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
|
|
10
10
|
import { checkAdmission, ADMISSION_DEFAULTS, localLaneCap } from "./admission.mjs";
|
|
11
11
|
import { formatAlertLines, formatWaitSummary } from "./orchestrate.mjs";
|
|
12
12
|
import { readHeavy, readAdmission, liveLaneCap } from "../engine-cli.mjs";
|
|
@@ -347,3 +347,57 @@ describe("validation steps and the local lane cap (#4777)", () => {
|
|
|
347
347
|
assert.equal(liveLaneCap(null, { cfg: readAdmission(d), host: { cores: () => 16, memAvailableMb: () => 64000 } })(), 2);
|
|
348
348
|
});
|
|
349
349
|
});
|
|
350
|
+
|
|
351
|
+
describe("#5148 dynamic-name pass-through wrapper", () => {
|
|
352
|
+
const WRAP = (log) => `#!/bin/bash\nname=\${0##*/}\necho w >> "${log}"\n[ "$(wc -l < "${log}")" -gt 12 ] && exit 99\nself_dir=$(cd "\${0%/*}" && pwd)\nreal=$(PATH=$(printf '%s' "$PATH" | tr ':' '\\n' | grep -vxF "$self_dir" | paste -sd:) command -v "$name")\nexec "$real" "$@"\n`;
|
|
353
|
+
const fixture = () => {
|
|
354
|
+
const d = tmpDir("heavy-5148-"), wrap = join(d, "wrap"), real = join(d, "real"), bin = join(d, "bin"), log = join(d, "log");
|
|
355
|
+
for (const x of [wrap, real, bin]) mkdirSync(x, { recursive: true });
|
|
356
|
+
writeFileSync(log, "");
|
|
357
|
+
writeFileSync(join(wrap, "docker"), WRAP(log));
|
|
358
|
+
writeFileSync(join(real, "docker"), `#!/bin/sh\necho r >> ${log}\necho real-docker\n`);
|
|
359
|
+
for (const x of [wrap, real]) chmodSync(join(x, "docker"), 0o755);
|
|
360
|
+
return { d, wrap, real, bin, log };
|
|
361
|
+
};
|
|
362
|
+
const lines = (log) => readFileSync(log, "utf8").split("\n").filter(Boolean);
|
|
363
|
+
|
|
364
|
+
it("#5148: a ${0##*/} pass-through wrapper on PATH does not recurse through the heavy shim", async () => {
|
|
365
|
+
const { wrap, real, bin, log } = fixture();
|
|
366
|
+
assert.ok(isPassThroughWrapper(readFileSync(join(wrap, "docker"), "utf8"), "docker"));
|
|
367
|
+
assert.ok(isPassThroughWrapper('#!/bin/sh\nexec "$(command -v "$(basename "$0")")" "$@"', "docker"));
|
|
368
|
+
assert.ok(!isPassThroughWrapper("#!/bin/sh\necho hi\n", "docker"));
|
|
369
|
+
assert.ok(!isPassThroughWrapper('#!/bin/sh\nx=git\nexec "$(command -v "$x")" "$@"', "docker"), "dynamic lookup without a $0-derived name is not a wrapper");
|
|
370
|
+
assert.deepEqual(await writeHeavyShims(bin, { path: `${wrap}:${real}`, warn: () => {} }), ["docker"]);
|
|
371
|
+
assert.match(readFileSync(join(bin, "docker"), "utf8"), new RegExp(`-- '${real}/docker' "\\$@"`));
|
|
372
|
+
const env = { ...process.env, PATH: `${bin}:${wrap}:${real}:/usr/bin:/bin`, FORGE_HEAVY_DISABLED: "1" };
|
|
373
|
+
const r = spawnSync(join(bin, "docker"), ["ps"], { env, encoding: "utf8", timeout: 20000 });
|
|
374
|
+
assert.equal(r.status, 0, r.stderr);
|
|
375
|
+
assert.match(r.stdout, /real-docker/);
|
|
376
|
+
assert.deepEqual(lines(log), ["r"], "the wrapper never ran; the real binary ran once");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("#5148: with the shim baking the wrapper, the call reaches the real binary within SHIM_LOOP_MAX levels", () => {
|
|
380
|
+
const { wrap, real, bin, log } = fixture();
|
|
381
|
+
writeFileSync(join(bin, "docker"), heavyShimScript("docker", join(wrap, "docker"))); chmodSync(join(bin, "docker"), 0o755);
|
|
382
|
+
const env = { ...process.env, PATH: `${bin}:${wrap}:${real}:/usr/bin:/bin`, FORGE_HEAVY_DISABLED: "1" };
|
|
383
|
+
const r = spawnSync(join(bin, "docker"), ["ps"], { env, encoding: "utf8", timeout: 20000 });
|
|
384
|
+
assert.equal(r.status, 0, r.stderr);
|
|
385
|
+
assert.ok(lines(log).length <= SHIM_LOOP_MAX + 1, lines(log).join(","));
|
|
386
|
+
assert.equal(lines(log).at(-1), "r");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("#5148: guardChain stops at SHIM_DEPTH_MAX even for a repeat it judges legitimate, and counts each entry", () => {
|
|
390
|
+
const env = { PATH: "/s:/r", FORGE_HEAVY_SHIM_DIR: "/s", FORGE_HEAVY_CHAIN: "npm:/r/npm", FORGE_HEAVY_DEPTH: String(SHIM_DEPTH_MAX) };
|
|
391
|
+
assert.match(guardChain("npm", "/r/npm", env, () => "").error, /^env\.shim_loop: npm .*levels deep/);
|
|
392
|
+
const ok = guardChain("npm", "/r/npm", { ...env, FORGE_HEAVY_DEPTH: String(SHIM_DEPTH_MAX - 1) }, () => "");
|
|
393
|
+
assert.equal(ok.error, undefined); assert.equal(ok.env.FORGE_HEAVY_DEPTH, String(SHIM_DEPTH_MAX));
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("#5148: under systemd-run TasksMax the shimmed call finishes without hitting the limit", { skip: spawnSync("systemd-run", ["--user", "--scope", "-q", "true"], { timeout: 10000 }).status !== 0 && "no systemd --user manager" }, () => {
|
|
397
|
+
const { wrap, real, bin, log } = fixture();
|
|
398
|
+
writeFileSync(join(bin, "docker"), heavyShimScript("docker", join(wrap, "docker"))); chmodSync(join(bin, "docker"), 0o755);
|
|
399
|
+
const r = spawnSync("systemd-run", ["--user", "--scope", "-q", "-p", "TasksMax=64", "env", `PATH=${bin}:${wrap}:${real}:/usr/bin:/bin`, "FORGE_HEAVY_DISABLED=1", join(bin, "docker"), "ps"], { encoding: "utf8", timeout: 30000 });
|
|
400
|
+
assert.equal(r.status, 0, r.stderr);
|
|
401
|
+
assert.ok(lines(log).length < 13);
|
|
402
|
+
});
|
|
403
|
+
});
|