forgedock 1.26.0 → 1.28.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/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
+
import { computeTrigger, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
|
|
11
|
+
|
|
12
|
+
const HOT = { enabled: true, file_lines: 2000, growth_lines: 150, files: [] };
|
|
13
|
+
const clean = (patch = {}) => computeTrigger({ files: ["src/foo.js", "test/foo.test.js"], hotspots: HOT, ...patch });
|
|
14
|
+
const codes = (t) => t.reasons.map((r) => r.code);
|
|
15
|
+
|
|
16
|
+
describe("computeTrigger (#4273)", () => {
|
|
17
|
+
it("a clean diff with passing gates would skip, with no reasons", () => {
|
|
18
|
+
assert.deepEqual(clean(), { would_skip: true, reasons: [] });
|
|
19
|
+
});
|
|
20
|
+
it("risk_path: a built-in risk word in any path segment, camelCase included", () => {
|
|
21
|
+
for (const f of ["src/auth/login.ts", "api/payments/charge.py", "lib/security/x.go", "app/userDelete.js", "db/migrations/001_init.sql", "src/AuthService.ts"]) {
|
|
22
|
+
const t = clean({ files: [f] });
|
|
23
|
+
assert.equal(t.would_skip, false, f);
|
|
24
|
+
assert.deepEqual(codes(t), ["risk_path"], f);
|
|
25
|
+
}
|
|
26
|
+
assert.equal(riskClassOf("src/billing/x.ts"), "payments");
|
|
27
|
+
assert.equal(riskClassOf("src/authorship.md"), null); // whole words only
|
|
28
|
+
});
|
|
29
|
+
it("risk_path: a configured review.risk_paths glob", () => {
|
|
30
|
+
const t = clean({ files: ["infra/main.tf"], riskPaths: ["infra/**"] });
|
|
31
|
+
assert.deepEqual(t.reasons, [{ code: "risk_path", detail: "infra/main.tf (review.risk_paths infra/**)" }]);
|
|
32
|
+
assert.equal(clean({ files: ["infra/main.tf"], riskPaths: ["other/**"] }).would_skip, true);
|
|
33
|
+
});
|
|
34
|
+
it("package_edge: any added package edge, declared or not", () => {
|
|
35
|
+
const t = clean({ structure: { package_edges_added: [{ from: "@a/web", to: "@a/db" }], import_pairs: [] } });
|
|
36
|
+
assert.deepEqual(t.reasons, [{ code: "package_edge", detail: "@a/web → @a/db" }]);
|
|
37
|
+
assert.equal(clean({ structure: { package_edges_added: [] } }).would_skip, true);
|
|
38
|
+
});
|
|
39
|
+
it("hotspot: a changed file over the threshold, unless excluded or not over", () => {
|
|
40
|
+
const files = [{ path: "src/foo.js", lines: 2400, over: true, excluded: false }];
|
|
41
|
+
assert.deepEqual(codes(clean({ hotspots: { ...HOT, files } })), ["hotspot"]);
|
|
42
|
+
assert.equal(clean({ hotspots: { ...HOT, files: [{ ...files[0], excluded: true }] } }).would_skip, true);
|
|
43
|
+
assert.equal(clean({ hotspots: { ...HOT, files: [{ ...files[0], over: false }] } }).would_skip, true);
|
|
44
|
+
assert.equal(clean({ hotspots: { enabled: false, files: [] } }).would_skip, true); // disabled is an answer
|
|
45
|
+
});
|
|
46
|
+
it("gate.failed: precheck failures, declared check commands still failing, CI fallback", () => {
|
|
47
|
+
assert.deepEqual(clean({ gates: { precheckFailures: [{ code: "conformance.scope" }] } }).reasons, [{ code: "gate.failed", detail: "precheck conformance.scope" }]);
|
|
48
|
+
assert.deepEqual(codes(clean({ gates: { checkCommandsFailing: true } })), ["gate.failed"]);
|
|
49
|
+
assert.deepEqual(codes(clean({ gates: { ciFellBack: true } })), ["gate.failed"]);
|
|
50
|
+
});
|
|
51
|
+
it("gate.ambiguous: hotspot facts unavailable, or a deferred/post-deploy requirement", () => {
|
|
52
|
+
assert.deepEqual(codes(computeTrigger({ files: ["src/foo.js"], hotspots: null })), ["gate.ambiguous"]);
|
|
53
|
+
assert.deepEqual(codes(computeTrigger({ files: ["src/foo.js"] })), ["gate.ambiguous"]);
|
|
54
|
+
const contract = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "post-deploy" }] };
|
|
55
|
+
assert.deepEqual(clean({ contract }).reasons, [{ code: "gate.ambiguous", detail: "contract defers R2 (only the reviewer judges deferrals)" }]);
|
|
56
|
+
assert.equal(clean({ contract: { requirements: [{ id: "R1", disposition: "deliver" }] } }).would_skip, true);
|
|
57
|
+
});
|
|
58
|
+
it("is deterministic: same facts in any order give the same sorted reasons", () => {
|
|
59
|
+
const a = computeTrigger({ files: ["src/auth/a.ts", "db/migrations/1.sql"], hotspots: null, gates: { ciFellBack: true } });
|
|
60
|
+
const b = computeTrigger({ files: ["db/migrations/1.sql", "src/auth/a.ts"], hotspots: null, gates: { ciFellBack: true } });
|
|
61
|
+
assert.deepEqual(a, b);
|
|
62
|
+
assert.deepEqual(codes(a), ["gate.ambiguous", "gate.failed", "risk_path", "risk_path"]);
|
|
63
|
+
});
|
|
64
|
+
it("parseRiskPaths takes a comma string or a list", () => {
|
|
65
|
+
assert.deepEqual(parseRiskPaths(" a/** , ,*.sql"), ["a/**", "*.sql"]);
|
|
66
|
+
assert.deepEqual(parseRiskPaths(["x", " ", "y "]), ["x", "y"]);
|
|
67
|
+
assert.deepEqual(parseRiskPaths(undefined), []);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const start = (repo) => ({ event: "RUN_START", repo });
|
|
72
|
+
const trig = (would_skip, skipped = false) => ({ event: "REVIEW_TRIGGER", mode: skipped ? "enforce" : "shadow", would_skip, reasons: would_skip ? [] : ["risk_path"], skipped });
|
|
73
|
+
const conf = (state) => ({ event: "CONFORMANCE", round: 1, state });
|
|
74
|
+
|
|
75
|
+
describe("triggerReport (#4273)", () => {
|
|
76
|
+
it("per repo: runs, would-skip, would-skip the reviewer flagged nonconforming, enforce skips", () => {
|
|
77
|
+
const logs = [
|
|
78
|
+
[start("o/a"), trig(true), conf("nonconforming"), conf("conforms")],
|
|
79
|
+
[start("o/a"), trig(true), conf("conforms")],
|
|
80
|
+
[start("o/a"), trig(false), conf("nonconforming")],
|
|
81
|
+
[start("o/b"), trig(true, true)],
|
|
82
|
+
// only the last RUN_START slice counts: the earlier flagged run is superseded
|
|
83
|
+
[start("o/b"), trig(true), conf("nonconforming"), start("o/b"), trig(true), conf("conforms")],
|
|
84
|
+
[start("o/b"), conf("conforms")], // trigger off: not counted
|
|
85
|
+
[trig(true)], // no RUN_START
|
|
86
|
+
];
|
|
87
|
+
assert.deepEqual(triggerReport(logs), {
|
|
88
|
+
"o/a": { runs: 3, would_skip: 2, would_skip_flagged: 1, skipped: 0 },
|
|
89
|
+
"o/b": { runs: 2, would_skip: 2, would_skip_flagged: 0, skipped: 1 },
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
it("reads *.jsonl run logs, skipping corrupt lines; a missing dir is an empty report", () => {
|
|
93
|
+
const dir = tmpDir("rtrig-");
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
writeFileSync(join(dir, "7.jsonl"), [start("o/a"), trig(true), conf("nonconforming")].map((e) => JSON.stringify(e)).join("\n") + "\n{broken\n");
|
|
96
|
+
writeFileSync(join(dir, "notes.txt"), "ignored");
|
|
97
|
+
assert.deepEqual(triggerReport(readRunLogs(dir)), { "o/a": { runs: 1, would_skip: 1, would_skip_flagged: 1, skipped: 0 } });
|
|
98
|
+
assert.deepEqual(readRunLogs(join(dir, "missing")), []);
|
|
99
|
+
assert.match(formatReport(triggerReport(readRunLogs(dir))), /\| o\/a \| 1 \| 1 \| 1 \| 0 \|/);
|
|
100
|
+
const cli = spawnSync(process.execPath, [fileURLToPath(new URL("./review-trigger.mjs", import.meta.url)), "--dir", dir, "--json"], { encoding: "utf8" });
|
|
101
|
+
assert.equal(cli.status, 0);
|
|
102
|
+
assert.deepEqual(JSON.parse(cli.stdout)["o/a"].would_skip_flagged, 1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Sandbox provider contract (#4254): one suite every provider passes (container over a fake docker, remote over a fake
|
|
4
|
+
// transport), plus the remote adapter, credentials, config and gc specifics.
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
+
import { containerProvider, parseExecution, providerByName, providerLanes, remoteTransportFor } from "./execution.mjs";
|
|
11
|
+
import { remoteProvider, httpTransport, isActionsEndpoint, redact } from "./sandbox-remote.mjs";
|
|
12
|
+
import { resolveCredential } from "./credentials.mjs";
|
|
13
|
+
import { makeLedger } from "./ledger.mjs";
|
|
14
|
+
import { openBatch } from "./batch.mjs";
|
|
15
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
16
|
+
import { planGc, applyGc } from "../gc.mjs";
|
|
17
|
+
|
|
18
|
+
const HOST = "testhost";
|
|
19
|
+
const SECRET = "s3cret-value-123";
|
|
20
|
+
const TOKEN = "tok-credential-456";
|
|
21
|
+
|
|
22
|
+
/** Fake docker daemon for the container provider: `run` starts, `inspect` reports, `rm` removes. */
|
|
23
|
+
function containerFixture(batch, { startError = null } = {}) {
|
|
24
|
+
const live = new Map(); // id -> state
|
|
25
|
+
const docker = async (args) => {
|
|
26
|
+
if (args[0] === "run") {
|
|
27
|
+
if (startError) throw Object.assign(new Error("x"), { stderr: startError });
|
|
28
|
+
live.set(args[args.indexOf("--name") + 1], "running"); return "cid\n";
|
|
29
|
+
}
|
|
30
|
+
if (args[0] === "inspect") { const id = args.at(-1); if (!live.has(id)) throw new Error("Error: No such object"); return `${live.get(id)}\n`; }
|
|
31
|
+
if (args[0] === "rm") { const id = args.at(-1); if (!live.delete(id)) throw Object.assign(new Error("x"), { stderr: `No such container: ${id}` }); return ""; }
|
|
32
|
+
return "";
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
provider: containerProvider({ batch, repo: "o/r", image: "img:1", gitDir: "/g/.git", cacheDir: null, docker, user: null }),
|
|
36
|
+
finish: (h, result) => { mkdirSync(dirname(batch.resultFile(h.issue)), { recursive: true }); writeFileSync(batch.resultFile(h.issue), JSON.stringify(result)); live.set(h.id, "exited"); },
|
|
37
|
+
exists: (id) => live.has(id),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** In-memory transport: the "remote" end of the protocol. */
|
|
42
|
+
function fakeTransport({ launchError = null } = {}) {
|
|
43
|
+
const t = {
|
|
44
|
+
boxes: new Map(), specs: [], destroyed: [],
|
|
45
|
+
async launch(spec) { if (launchError) throw launchError; t.specs.push(spec); t.boxes.set(spec.id, { state: "running", log: "", result: null, minutes: 0 }); return { id: spec.id }; },
|
|
46
|
+
async status(id) { return t.boxes.get(id)?.state ?? "gone"; },
|
|
47
|
+
async logs(id, cursor = 0) { const b = t.boxes.get(id); if (!b) return { text: "", cursor }; return { text: b.log.slice(cursor), cursor: b.log.length }; },
|
|
48
|
+
async result(id) { return t.boxes.get(id)?.result ?? null; },
|
|
49
|
+
async usage(id) { const b = t.boxes.get(id); return b ? { minutes: b.minutes } : null; },
|
|
50
|
+
async destroy(id) { t.destroyed.push(id); t.boxes.delete(id); return true; },
|
|
51
|
+
};
|
|
52
|
+
return t;
|
|
53
|
+
}
|
|
54
|
+
function remoteFixture(batch, { launchError = null } = {}) {
|
|
55
|
+
const transport = fakeTransport({ launchError });
|
|
56
|
+
const provider = remoteProvider({ batch, repo: "o/r", settings: { image: "img:1", cacheStore: "s3://cache", tokenEnv: "FD_TOKEN", secrets: ["GH_TOKEN"] }, transport,
|
|
57
|
+
env: { GH_TOKEN: SECRET, FD_TOKEN: TOKEN }, credentials: (n) => ({ GH_TOKEN: SECRET, FD_TOKEN: TOKEN })[n] ?? null });
|
|
58
|
+
return {
|
|
59
|
+
provider, transport,
|
|
60
|
+
finish: (h, result) => { const b = transport.boxes.get(h.id); b.log += `lane done; token ${SECRET} and ${TOKEN}\n`; b.result = result; b.state = "exited"; b.minutes = 3.5; },
|
|
61
|
+
exists: (id) => transport.boxes.has(id),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const FIXTURES = { container: containerFixture, remote: remoteFixture };
|
|
66
|
+
|
|
67
|
+
for (const [name, make] of Object.entries(FIXTURES)) {
|
|
68
|
+
describe(`sandbox provider contract: ${name} (#4254)`, () => {
|
|
69
|
+
const setup = (opts) => { const work = tmpDir(`contract-${name}-`); const batch = openBatch(join(work, "batches"), "bt"); return { work, batch, ...make(batch, opts) }; };
|
|
70
|
+
|
|
71
|
+
it("idFor is stable and equals the started handle id; start -> running; result -> fetched, exited", async () => {
|
|
72
|
+
const { provider, finish } = setup();
|
|
73
|
+
assert.equal(provider.idFor(7), provider.idFor(7));
|
|
74
|
+
const h = await provider.start({ issue: 7, args: ["--repo", "o/r", "--lane", "staging"], env: { GH_TOKEN: SECRET }, secrets: ["GH_TOKEN"] });
|
|
75
|
+
assert.equal(h.id, provider.idFor(7));
|
|
76
|
+
assert.equal(h.provider, provider.name);
|
|
77
|
+
assert.equal(h.issue, 7);
|
|
78
|
+
assert.equal(await provider.status(h), "running");
|
|
79
|
+
assert.equal(await provider.fetchResult(h), null);
|
|
80
|
+
finish(h, { terminalReason: "merged", pr: 1 });
|
|
81
|
+
assert.deepEqual(await provider.fetchResult(h), { terminalReason: "merged", pr: 1 });
|
|
82
|
+
assert.equal(await provider.status(h), "exited");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("teardown is true and idempotent; a gone sandbox is torn down", async () => {
|
|
86
|
+
const { provider, exists } = setup();
|
|
87
|
+
const h = await provider.start({ issue: 8, args: [], env: {}, secrets: [] });
|
|
88
|
+
assert.equal(await provider.teardown(h), true);
|
|
89
|
+
assert.equal(exists(h.id), false);
|
|
90
|
+
assert.equal(await provider.teardown(h), true);
|
|
91
|
+
assert.equal(await provider.status(h), "gone");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("providerLanes: ledger before start, released after teardown; secrets never in handle, ledger, state or log", async () => {
|
|
95
|
+
const { work, provider, finish, batch } = setup();
|
|
96
|
+
const root = join(work, "ledger");
|
|
97
|
+
const ledgerFor = (n) => makeLedger({ root, batch: "bt", lane: `sandbox-${n}`, owner: "o", issue: n, host: HOST, pid: process.pid });
|
|
98
|
+
let atStart = null;
|
|
99
|
+
const wrapped = { ...provider, start: async (a) => { atStart = ledgerFor(a.issue).entries("sandbox").map((e) => e.value); const h = await provider.start(a); setTimeout(() => finish(h, { terminalReason: "merged", pr: 4 }), 5); return h; } };
|
|
100
|
+
const started = [];
|
|
101
|
+
const r = await providerLanes({ provider: wrapped, laneArgs: () => ["--repo", "o/r"], ledgerFor, env: { GH_TOKEN: SECRET }, secrets: ["GH_TOKEN"], pollMs: 2 }).runOne(9, "staging", { onStart: (x) => started.push(x) });
|
|
102
|
+
assert.equal(r.terminalReason, "merged");
|
|
103
|
+
assert.deepEqual(atStart, [provider.idFor(9)]);
|
|
104
|
+
assert.equal(ledgerFor(9).read(), null);
|
|
105
|
+
const files = [JSON.stringify(started), JSON.stringify(r)];
|
|
106
|
+
if (existsSync(join(root, "bt"))) for (const f of readdirSync(join(root, "bt"))) files.push(readFileSync(join(root, "bt", f), "utf8"));
|
|
107
|
+
if (existsSync(batch.logFile(9))) files.push(readFileSync(batch.logFile(9), "utf8"));
|
|
108
|
+
for (const f of files) { assert.equal(f.includes(SECRET), false); assert.equal(f.includes(TOKEN), false); }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("a sandbox that fails to start: needs-human sandbox.start_failed, ledger released, orchestrate does not relaunch it", async () => {
|
|
112
|
+
const opts = name === "container" ? { startError: "Unable to find image 'img:1' locally" } : { launchError: new Error("sandbox launch: HTTP 400") };
|
|
113
|
+
const { work, provider } = setup(opts);
|
|
114
|
+
const root = join(work, "ledger");
|
|
115
|
+
const ledgerFor = (n) => makeLedger({ root, batch: "bt", lane: `sandbox-${n}`, owner: "o", issue: n, host: HOST, pid: process.pid });
|
|
116
|
+
const lanes = providerLanes({ provider, laneArgs: () => [], ledgerFor, pollMs: 1 });
|
|
117
|
+
let starts = 0;
|
|
118
|
+
const rows = await orchestrate({ issues: [5], parallel: 1, readBody: async () => "", runOne: async (n, l) => { starts++; return lanes.runOne(n, l); } });
|
|
119
|
+
assert.deepEqual(rows.map((x) => [x.issue, x.outcome, x.code]), [[5, "needs-human", "sandbox.start_failed"]]);
|
|
120
|
+
assert.equal(starts, 1, "not relaunched");
|
|
121
|
+
assert.equal(ledgerFor(5).read(), null);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("remote adapter (#4254)", () => {
|
|
127
|
+
const setup = () => { const batch = openBatch(join(tmpDir("remote-"), "batches"), "bt"); return { batch, ...remoteFixture(batch) }; };
|
|
128
|
+
|
|
129
|
+
it("launch spec: clones the target repo at the lane, mounts the shared cache store, scoped env only, run-issue command", async () => {
|
|
130
|
+
const { provider, transport } = setup();
|
|
131
|
+
await provider.start({ issue: 7, args: ["--repo", "o/r", "--lane", "staging"], env: { GH_TOKEN: SECRET, AWS_SECRET_ACCESS_KEY: "leak-leak" }, secrets: ["GH_TOKEN"] });
|
|
132
|
+
const spec = transport.specs[0];
|
|
133
|
+
assert.equal(spec.id, "fdrsbx-bt-7");
|
|
134
|
+
assert.deepEqual(spec.repo, { slug: "o/r", url: "https://github.com/o/r.git", ref: "staging" });
|
|
135
|
+
assert.deepEqual(spec.cache, { store: "s3://cache", key: "o_r" });
|
|
136
|
+
assert.deepEqual(spec.env, { FORGEDOCK_BATCH_ID: "bt", GH_REPO: "o/r", GH_TOKEN: SECRET });
|
|
137
|
+
assert.deepEqual(spec.command.slice(0, 3), ["forgedock", "run-issue", "7"]);
|
|
138
|
+
assert.deepEqual(spec.command.slice(-2), ["--result-file", "/result/7.json"]);
|
|
139
|
+
assert.equal(JSON.stringify(spec).includes(TOKEN), false, "the transport credential is never in the spec");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("a secret missing from the env is read from the keychain (credential resolver)", async () => {
|
|
143
|
+
const batch = openBatch(join(tmpDir("remote-kc-"), "batches"), "bt");
|
|
144
|
+
const transport = fakeTransport();
|
|
145
|
+
const p = remoteProvider({ batch, repo: "o/r", settings: { secrets: ["GH_TOKEN"] }, transport, env: {}, credentials: (n) => (n === "GH_TOKEN" ? "from-keychain" : null) });
|
|
146
|
+
await p.start({ issue: 1, args: [], env: {}, secrets: ["GH_TOKEN"] });
|
|
147
|
+
assert.equal(transport.specs[0].env.GH_TOKEN, "from-keychain");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("streams the lane log into the batch log, redacted; billable minutes on the handle and on the lane result", async () => {
|
|
151
|
+
const { provider, finish, batch } = setup();
|
|
152
|
+
const lanes = providerLanes({ provider: { ...provider, start: async (a) => { const h = await provider.start(a); setTimeout(() => finish(h, { terminalReason: "merged", pr: 2 }), 5); return h; } },
|
|
153
|
+
laneArgs: () => [], ledgerFor: () => null, env: { GH_TOKEN: SECRET }, secrets: ["GH_TOKEN"], pollMs: 2 });
|
|
154
|
+
const r = await lanes.runOne(3, null);
|
|
155
|
+
assert.equal(r.terminalReason, "merged");
|
|
156
|
+
assert.deepEqual(r.sandbox, { provider: "remote", id: "fdrsbx-bt-3", minutes: 3.5, cost_usd: 0 }); // #4255: cost from cost_per_minute (0 here)
|
|
157
|
+
const log = readFileSync(batch.logFile(3), "utf8");
|
|
158
|
+
assert.match(log, /lane done/);
|
|
159
|
+
assert.equal(log.includes(SECRET) || log.includes(TOKEN), false);
|
|
160
|
+
assert.match(log, /\[redacted\]/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("a capacity error defers (sandbox.capacity, retryInSeconds); persistent status errors end the lane", async () => {
|
|
164
|
+
const batch = openBatch(join(tmpDir("remote-cap-"), "batches"), "bt");
|
|
165
|
+
const transport = fakeTransport({ launchError: Object.assign(new Error("sandbox launch: HTTP 429"), { retryable: true, retryInSeconds: 30 }) });
|
|
166
|
+
const p = remoteProvider({ batch, repo: "o/r", transport, env: {} });
|
|
167
|
+
const r = await providerLanes({ provider: p, laneArgs: () => [], ledgerFor: () => null, pollMs: 1 }).runOne(4, null);
|
|
168
|
+
assert.deepEqual([r.terminalReason, r.code, r.retryInSeconds], ["deferred", "sandbox.capacity", 30]);
|
|
169
|
+
const flaky = { ...fakeTransport(), status: async () => { throw new Error("ECONNRESET"); } };
|
|
170
|
+
const q = remoteProvider({ batch, repo: "o/r", transport: flaky, env: {} });
|
|
171
|
+
const h = await q.start({ issue: 6, args: [] });
|
|
172
|
+
const seen = [];
|
|
173
|
+
for (let i = 0; i < 5; i++) seen.push(await q.status(h));
|
|
174
|
+
assert.deepEqual(seen, ["running", "running", "running", "running", "gone"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
|
|
178
|
+
const calls = [];
|
|
179
|
+
const reply = (status, body, headers = {}) => ({ status, ok: status >= 200 && status < 300, text: async () => (body === undefined ? "" : JSON.stringify(body)), headers: { get: (k) => headers[k] ?? null } });
|
|
180
|
+
let launchStatus = 201;
|
|
181
|
+
const fetchFn = async (url, init) => {
|
|
182
|
+
calls.push([init.method, url, init.headers.authorization]);
|
|
183
|
+
if (init.method === "POST") return launchStatus === 201 ? reply(201, { id: "x" }) : reply(launchStatus, { error: TOKEN }, { "retry-after": "12" });
|
|
184
|
+
if (url.endsWith("/usage")) return reply(200, { minutes: 2 });
|
|
185
|
+
if (url.endsWith("/result")) return reply(404);
|
|
186
|
+
if (url.includes("/logs")) return reply(200, { text: "hi", cursor: 2 });
|
|
187
|
+
if (init.method === "DELETE") return reply(404);
|
|
188
|
+
return reply(200, { state: "running" });
|
|
189
|
+
};
|
|
190
|
+
const t = httpTransport({ endpoint: "https://pool.example/v1/", token: TOKEN, fetchFn });
|
|
191
|
+
await t.launch({ id: "x" });
|
|
192
|
+
assert.deepEqual(calls[0], ["POST", "https://pool.example/v1/sandboxes", `Bearer ${TOKEN}`]);
|
|
193
|
+
assert.equal(await t.status("x"), "running");
|
|
194
|
+
assert.deepEqual(await t.logs("x", 0), { text: "hi", cursor: 2 });
|
|
195
|
+
assert.equal(await t.result("x"), null);
|
|
196
|
+
assert.deepEqual(await t.usage("x"), { minutes: 2 });
|
|
197
|
+
assert.equal(await t.destroy("x"), true);
|
|
198
|
+
launchStatus = 429;
|
|
199
|
+
await assert.rejects(t.launch({ id: "y" }), (e) => e.retryable === true && e.retryInSeconds === 12 && !e.message.includes(TOKEN));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("redact replaces every secret value", () => {
|
|
203
|
+
assert.equal(redact(`a ${SECRET} b ${SECRET}`, [SECRET, "", null]), "a [redacted] b [redacted]");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("remote config and the Actions constraint (#4254)", () => {
|
|
208
|
+
it("parseExecution reads the remote settings; remote is never the default provider", () => {
|
|
209
|
+
assert.deepEqual(parseExecution({ mode: "remote", max_remote: "2", provider: "remote", endpoint: "https://pool.example/v1", token_env: "FD_POOL_TOKEN", cache_store: "s3://c", allow_actions: "true" }),
|
|
210
|
+
{ mode: "remote", maxRemote: 2, provider: "remote", image: null, secrets: [], budget: { batchMinutes: null, batchUsd: null, dayMinutes: null, dayUsd: null, onBudget: "stop", costPerMinute: 0 }, endpoint: "https://pool.example/v1", tokenEnv: "FD_POOL_TOKEN", cacheStore: "s3://c", allowActions: true });
|
|
211
|
+
for (const sec of [{}, { mode: "remote", max_remote: "3" }, { mode: "hybrid" }, { mode: "remote", endpoint: "https://api.github.com/repos/o/r/actions/workflows/x/dispatches" }]) {
|
|
212
|
+
assert.notEqual(parseExecution(sec).provider, "remote");
|
|
213
|
+
}
|
|
214
|
+
assert.equal(parseExecution({ endpoint: "not a url", token_env: "bad-name" }).endpoint, undefined);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("providerByName remote: needs an endpoint; a GitHub Actions endpoint is refused unless allow_actions", () => {
|
|
218
|
+
const batch = openBatch(tmpDir("remote-cfg-"), "bt");
|
|
219
|
+
assert.throws(() => providerByName("remote", { batch, repo: "o/r", exec: parseExecution({ mode: "remote", provider: "remote" }) }), (e) => e.code === "execution.provider.misconfigured");
|
|
220
|
+
const actions = { mode: "remote", provider: "remote", endpoint: "https://api.github.com/repos/o/r/actions/workflows/lane.yml/dispatches" };
|
|
221
|
+
assert.throws(() => providerByName("remote", { batch, repo: "o/r", exec: parseExecution(actions) }), (e) => e.code === "execution.provider.actions_refused");
|
|
222
|
+
assert.equal(providerByName("remote", { batch, repo: "o/r", exec: parseExecution({ ...actions, allow_actions: "true" }), env: {} }).name, "remote");
|
|
223
|
+
assert.equal(providerByName("remote", { batch, repo: "o/r", exec: parseExecution({ provider: "remote", endpoint: "https://pool.example/v1" }), env: {} }).name, "remote");
|
|
224
|
+
assert.ok(isActionsEndpoint("https://ghe.example/api/v3/repos/o/r/actions/runs") && !isActionsEndpoint("https://pool.example/v1"));
|
|
225
|
+
assert.equal(remoteTransportFor({}, { transport: fakeTransport() }) !== null, true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("resolveCredential: env first, then keychain, failures are null", () => {
|
|
229
|
+
assert.equal(resolveCredential("FD_T", { env: { FD_T: "e" }, keychain: () => "k" }), "e");
|
|
230
|
+
assert.equal(resolveCredential("FD_T", { env: { FD_T: "" }, keychain: (n) => (n === "FD_T" ? "k" : null) }), "k");
|
|
231
|
+
assert.equal(resolveCredential("FD_T", { env: {}, keychain: () => { throw new Error("locked"); } }), null);
|
|
232
|
+
assert.equal(resolveCredential("bad name", { env: { "bad name": "x" } }), null);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("gc reaps a remote sandbox left by a dead dispatcher (#4254)", () => {
|
|
237
|
+
it("destroys it through the transport and releases the ledger; failure keeps it listed", async () => {
|
|
238
|
+
const home = tmpDir("remote-gc-");
|
|
239
|
+
const root = join(home, ".forge", "ledger");
|
|
240
|
+
mkdirSync(join(home, ".forge", "tmp"), { recursive: true });
|
|
241
|
+
const led = makeLedger({ root, batch: "bt", lane: "sandbox-7", owner: "orch_1", issue: 7, host: HOST, pid: 424242 });
|
|
242
|
+
led.register("sandbox", "fdrsbx-bt-7", { provider: "remote", handle: { provider: "remote", id: "fdrsbx-bt-7", issue: 7 } });
|
|
243
|
+
const transport = fakeTransport();
|
|
244
|
+
const probes = { cwd: home, host: HOST, ledgerRoot: root, forgeTmpRoot: join(home, ".forge", "tmp"), worktreeRoot: join(home, "wt"), alive: () => false, ppid: () => 0,
|
|
245
|
+
procsInDir: () => [], dirSize: () => 0, docker: async () => "", git: async () => "", gh: async () => "[]", sandboxTransport: transport };
|
|
246
|
+
const plan = await planGc({ probes, scopes: new Set(["sandboxes"]) });
|
|
247
|
+
assert.deepEqual(plan.items.map((i) => [i.type, i.id, i.action]), [["sandbox", "fdrsbx-bt-7", "reclaim"]]);
|
|
248
|
+
// Without a configured transport (no forge.yaml execution endpoint) the teardown fails and the entry stays.
|
|
249
|
+
const failed = await applyGc(plan, { ...probes, sandboxTransport: undefined });
|
|
250
|
+
assert.equal(failed.reclaimed.length, 0);
|
|
251
|
+
assert.equal(existsSync(led.path), true);
|
|
252
|
+
const res = await applyGc(plan, probes);
|
|
253
|
+
assert.deepEqual(res.reclaimed.map((i) => i.id), ["fdrsbx-bt-7"]);
|
|
254
|
+
assert.deepEqual(transport.destroyed, ["fdrsbx-bt-7"]);
|
|
255
|
+
assert.equal(existsSync(led.path), false);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Remote sandbox provider (#4254) on the execution.mjs provider seam: an ephemeral VM or container from a runner pool
|
|
5
|
+
* or a remote agent session, reached through a transport. The transport is the only provider-specific part:
|
|
6
|
+
* launch(spec) · status(id) -> running|exited|gone · logs(id, cursor) -> {text, cursor} · result(id) -> object|null
|
|
7
|
+
* usage(id) -> {minutes}|null · destroy(id) -> true (a gone sandbox is destroyed)
|
|
8
|
+
* The sandbox is addressed by the id ForgeDock picks (`idFor`), so a ledger entry written before `launch` is enough to
|
|
9
|
+
* tear it down later. The launch spec asks the sandbox to clone the target repo, mount the configured shared cache
|
|
10
|
+
* store, and run `forgedock run-issue`; its env is only `scopedEnv` (the repo's allowlisted secret names, resolved from
|
|
11
|
+
* the env or the keychain, plus GH_REPO and FORGEDOCK_BATCH_ID). The lane log is streamed into the batch log file with
|
|
12
|
+
* every secret value and the transport credential redacted. Billable minutes go on the handle (`handle.minutes`);
|
|
13
|
+
* the handle never holds a secret.
|
|
14
|
+
*
|
|
15
|
+
* `httpTransport` is the one shipped transport: a small JSON protocol (docs/CONFIG.md `execution`) that a runner pool
|
|
16
|
+
* or a session gateway fronts. Tests use an in-memory transport.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { dirname } from "node:path";
|
|
20
|
+
import { safeSegment } from "./ledger.mjs";
|
|
21
|
+
import { scopedEnv } from "./execution.mjs";
|
|
22
|
+
import { resolveCredential } from "./credentials.mjs";
|
|
23
|
+
|
|
24
|
+
const REDACTED = "[redacted]";
|
|
25
|
+
const STATUS_ERRORS = 5;
|
|
26
|
+
/** Replace every value of `secrets` (4+ chars, longest first) in `text`. */
|
|
27
|
+
export function redact(text, secrets = []) {
|
|
28
|
+
let s = String(text ?? "");
|
|
29
|
+
for (const v of [...new Set(secrets.filter((x) => typeof x === "string" && x.length >= 4))].sort((a, b) => b.length - a.length)) s = s.split(v).join(REDACTED);
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when an endpoint is a GitHub Actions API (spends Actions minutes): api.github.com or an `/actions/` path. */
|
|
34
|
+
export function isActionsEndpoint(endpoint) {
|
|
35
|
+
try {
|
|
36
|
+
const u = new URL(String(endpoint));
|
|
37
|
+
return u.hostname.toLowerCase() === "api.github.com" || /\/actions(\/|$)/i.test(u.pathname);
|
|
38
|
+
} catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generic HTTP transport: POST {endpoint}/sandboxes (body = launch spec), GET /sandboxes/:id -> {state},
|
|
43
|
+
* GET /sandboxes/:id/logs?cursor=N -> {text, cursor}, GET /sandboxes/:id/result -> lane result (404 = none yet),
|
|
44
|
+
* GET /sandboxes/:id/usage -> {minutes}, DELETE /sandboxes/:id (404 = gone). `Authorization: Bearer <token>`.
|
|
45
|
+
* 429/503 on launch is a retryable capacity error (`Retry-After` seconds). Error text never carries the token.
|
|
46
|
+
*/
|
|
47
|
+
export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fetch }) {
|
|
48
|
+
const root = String(endpoint ?? "").replace(/\/+$/, "");
|
|
49
|
+
const call = async (method, path, body) => {
|
|
50
|
+
const res = await fetchFn(`${root}${path}`, { method, headers: { accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
51
|
+
...(body ? { body: JSON.stringify(body) } : {}) });
|
|
52
|
+
let data = null;
|
|
53
|
+
try { const t = await res.text(); data = t ? JSON.parse(t) : null; } catch { data = null; }
|
|
54
|
+
return { status: res.status, ok: res.ok, data, retryAfter: res.headers?.get?.("retry-after") ?? null };
|
|
55
|
+
};
|
|
56
|
+
const fail = (what, r) => {
|
|
57
|
+
const e = new Error(`sandbox ${what}: HTTP ${r.status}`);
|
|
58
|
+
if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
|
|
59
|
+
return e;
|
|
60
|
+
};
|
|
61
|
+
const p = (id) => `/sandboxes/${encodeURIComponent(id)}`;
|
|
62
|
+
return {
|
|
63
|
+
async launch(spec) { const r = await call("POST", "/sandboxes", spec); if (!r.ok) throw fail("launch", r); return r.data ?? {}; },
|
|
64
|
+
async status(id) {
|
|
65
|
+
const r = await call("GET", p(id));
|
|
66
|
+
if (r.status === 404) return "gone";
|
|
67
|
+
if (!r.ok) throw fail("status", r);
|
|
68
|
+
const s = String(r.data?.state ?? "");
|
|
69
|
+
return s === "running" || s === "pending" || s === "starting" ? "running" : s === "gone" ? "gone" : "exited";
|
|
70
|
+
},
|
|
71
|
+
async logs(id, cursor = 0) { const r = await call("GET", `${p(id)}/logs?cursor=${encodeURIComponent(cursor)}`); return r.ok ? { text: String(r.data?.text ?? ""), cursor: r.data?.cursor ?? cursor } : { text: "", cursor }; },
|
|
72
|
+
async result(id) { const r = await call("GET", `${p(id)}/result`); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
|
|
73
|
+
async usage(id) { const r = await call("GET", `${p(id)}/usage`); return r.ok && Number.isFinite(Number(r.data?.minutes)) ? { minutes: Number(r.data.minutes) } : null; },
|
|
74
|
+
async destroy(id) { const r = await call("DELETE", p(id)); if (r.ok || r.status === 404) return true; throw fail("destroy", r); },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The remote provider. `settings` is parseExecution's output (image, cacheStore, tokenEnv, secrets); `env` is the
|
|
80
|
+
* dispatcher's env (secret values for redaction on adopt); `credentials(name)` resolves a secret (env, then keychain).
|
|
81
|
+
*/
|
|
82
|
+
export function remoteProvider({ batch, repo, settings = {}, transport, env = process.env, credentials = (name) => resolveCredential(name, { env }), now = () => Date.now() }) {
|
|
83
|
+
if (!transport) throw new Error("remote provider needs a transport");
|
|
84
|
+
const idFor = (issue) => `fdrsbx-${safeSegment(batch.id)}-${issue}`;
|
|
85
|
+
const errs = new Map(); // sandbox id -> consecutive status errors
|
|
86
|
+
const known = new Set(); // secret values seen this process, for redaction
|
|
87
|
+
const remember = (v) => { if (typeof v === "string" && v.length >= 4) known.add(v); };
|
|
88
|
+
for (const k of settings.secrets ?? []) remember(credentials(k));
|
|
89
|
+
if (settings.tokenEnv) remember(credentials(settings.tokenEnv));
|
|
90
|
+
const minutesOf = async (h) => {
|
|
91
|
+
let m = null;
|
|
92
|
+
try { const u = await transport.usage?.(h.id); if (u && Number.isFinite(Number(u.minutes))) m = Number(u.minutes); } catch { m = null; }
|
|
93
|
+
if (m === null && Number.isFinite(h.startedAt)) m = Math.round(((now() - h.startedAt) / 60000) * 10) / 10;
|
|
94
|
+
if (m !== null) h.minutes = Math.max(h.minutes ?? 0, m);
|
|
95
|
+
};
|
|
96
|
+
const pullLogs = async (h) => {
|
|
97
|
+
if (!transport.logs) return;
|
|
98
|
+
try {
|
|
99
|
+
const { text, cursor } = (await transport.logs(h.id, h.logCursor ?? 0)) ?? {};
|
|
100
|
+
if (text) { const f = batch.logFile(h.issue); mkdirSync(dirname(f), { recursive: true }); appendFileSync(f, redact(text, [...known])); }
|
|
101
|
+
if (cursor !== undefined) h.logCursor = cursor;
|
|
102
|
+
} catch { /* the next poll retries */ }
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
name: "remote",
|
|
106
|
+
idFor,
|
|
107
|
+
async start({ issue, args = [], env: laneEnv = env, secrets = settings.secrets ?? [] }) {
|
|
108
|
+
const id = idFor(issue);
|
|
109
|
+
// Each allowlisted secret from the env, else the keychain (#4254); nothing else from the host.
|
|
110
|
+
const resolved = {};
|
|
111
|
+
for (const k of secrets) { const v = laneEnv?.[k] !== undefined && laneEnv[k] !== "" ? String(laneEnv[k]) : credentials(k); if (v) { resolved[k] = v; remember(v); } }
|
|
112
|
+
const li = args.indexOf("--lane");
|
|
113
|
+
const resultFile = `/result/${issue}.json`;
|
|
114
|
+
const spec = {
|
|
115
|
+
id, issue, batch: batch.id, image: settings.image ?? null,
|
|
116
|
+
repo: { slug: repo, url: repo ? `https://github.com/${repo}.git` : null, ref: li >= 0 ? args[li + 1] ?? null : null },
|
|
117
|
+
cache: settings.cacheStore ? { store: settings.cacheStore, key: safeSegment(String(repo ?? "repo")) } : null,
|
|
118
|
+
env: scopedEnv({ env: resolved, secrets, batchId: batch.id, repo }),
|
|
119
|
+
command: ["forgedock", "run-issue", String(issue), ...args, "--result-file", resultFile], result_file: resultFile,
|
|
120
|
+
};
|
|
121
|
+
const startedAt = now();
|
|
122
|
+
await transport.launch(spec);
|
|
123
|
+
return { provider: "remote", id, issue, startedAt, minutes: 0 };
|
|
124
|
+
},
|
|
125
|
+
async status(h) {
|
|
126
|
+
let s;
|
|
127
|
+
// A transient transport error is not an exit; STATUS_ERRORS in a row is (the lane then ends without a result).
|
|
128
|
+
try { s = await transport.status(h.id); errs.delete(h.id); } catch { const k = (errs.get(h.id) ?? 0) + 1; errs.set(h.id, k); return k >= STATUS_ERRORS ? "gone" : "running"; }
|
|
129
|
+
await pullLogs(h);
|
|
130
|
+
return s === "running" ? "running" : s === "gone" ? "gone" : "exited";
|
|
131
|
+
},
|
|
132
|
+
async fetchResult(h) {
|
|
133
|
+
const r = await transport.result(h.id);
|
|
134
|
+
if (r) { await pullLogs(h); await minutesOf(h); }
|
|
135
|
+
return r ?? null;
|
|
136
|
+
},
|
|
137
|
+
async teardown(h) {
|
|
138
|
+
await minutesOf(h);
|
|
139
|
+
try { return (await transport.destroy(h.id)) !== false; } catch { return false; }
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|