@outerlayer/cli 0.2.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -0
- package/README.md +229 -60
- package/dist/agent-setup-IFH3FB5X.js +197 -0
- package/dist/build-TZUUTRGH.js +9 -0
- package/dist/build-info.json +1 -1
- package/dist/check-7MFH6HNC.js +94 -0
- package/dist/chunk-3TFPDVTI.js +94 -0
- package/dist/{chunk-4TNZMO7V.js → chunk-4C4THABW.js} +5566 -754
- package/dist/{chunk-R5KBGVII.js → chunk-5QRP3MVS.js} +30 -14
- package/dist/chunk-5Y3QQRUH.js +300 -0
- package/dist/chunk-774HEQL6.js +178 -0
- package/dist/{work-pr-cmd-AZQPWH4H.js → chunk-7WERLFVR.js} +9 -26
- package/dist/chunk-ABIWDUMS.js +104 -0
- package/dist/chunk-AUFG23AA.js +534 -0
- package/dist/chunk-B2G7JHHB.js +42 -0
- package/dist/chunk-BCJNHMZT.js +34 -0
- package/dist/chunk-BCLJSQCV.js +56 -0
- package/dist/chunk-BFESLKPP.js +61 -0
- package/dist/chunk-BJ3KTMDH.js +63 -0
- package/dist/chunk-BKO6JEZI.js +47 -0
- package/dist/chunk-BOLTI6LR.js +37 -0
- package/dist/chunk-CBPPJSAR.js +89 -0
- package/dist/chunk-DM3VFDS3.js +1461 -0
- package/dist/{chunk-65WXAANR.js → chunk-DVDEBNQJ.js} +17 -2
- package/dist/{chunk-TFUIDMOB.js → chunk-EABW6AJQ.js} +12 -1
- package/dist/{chunk-KFYJV2ZG.js → chunk-EMY4I27X.js} +1 -1
- package/dist/{chunk-NTTPJV35.js → chunk-F47JBIAW.js} +3 -1
- package/dist/chunk-F6GFZXZ3.js +60 -0
- package/dist/{chunk-YOOSBOKS.js → chunk-F7CU5ABH.js} +1 -1
- package/dist/{emit-cmd-TWSYYEZI.js → chunk-H5NCNVDA.js} +68 -17
- package/dist/chunk-HZRNMGLF.js +2233 -0
- package/dist/chunk-LF6MLJCY.js +38 -0
- package/dist/{chunk-U32VSRLO.js → chunk-LT5TZHYH.js} +95 -634
- package/dist/chunk-LYCDNMOH.js +83 -0
- package/dist/chunk-M5POMKKW.js +1051 -0
- package/dist/{mcp-install-cmd-FDQH6SEN.js → chunk-MQ3IPHIZ.js} +35 -18
- package/dist/{chunk-D77LS3UI.js → chunk-N5FOE5PS.js} +33 -4
- package/dist/chunk-N6LURUEF.js +64 -0
- package/dist/chunk-OGFGZQA3.js +23 -0
- package/dist/chunk-QDQEUVUF.js +9 -0
- package/dist/chunk-QOZKPLVJ.js +226 -0
- package/dist/chunk-QY22NZUW.js +1764 -0
- package/dist/chunk-RA3O54FC.js +30 -0
- package/dist/chunk-RFUPN6KX.js +71 -0
- package/dist/chunk-SY4GK4SP.js +55 -0
- package/dist/chunk-TBT347UY.js +41 -0
- package/dist/chunk-TWNWNS2Q.js +1084 -0
- package/dist/{chunk-NXLLURA4.js → chunk-UFFXNXLQ.js} +115 -71
- package/dist/chunk-USO2DKBD.js +421 -0
- package/dist/chunk-W4SQCOZU.js +206 -0
- package/dist/{chunk-Q6CL5THG.js → chunk-W5FVUZXV.js} +208 -61
- package/dist/chunk-WFJ65NUD.js +383 -0
- package/dist/{chunk-A3WLZX2F.js → chunk-WIOZAJ2W.js} +15 -2
- package/dist/chunk-WO2BXCTQ.js +83 -0
- package/dist/{chunk-I3ETLSNE.js → chunk-XCCLFVXM.js} +120 -11
- package/dist/{context-materialize-6WKT3RBQ.js → chunk-XDDW4FRS.js} +134 -26
- package/dist/chunk-Y7KJLXYN.js +232 -0
- package/dist/chunk-YFHIIH4B.js +102 -0
- package/dist/chunk-YYYXRJUW.js +101 -0
- package/dist/chunk-ZM2IMMYN.js +76 -0
- package/dist/chunk-ZMYPLFG3.js +71 -0
- package/dist/chunk-ZNA27WEV.js +470 -0
- package/dist/{cli-G7DILYJY.js → cli-W62AFRSW.js} +616 -878
- package/dist/{paths-D2VGWWFI.js → cli-build-K56DK4DS.js} +1 -1
- package/dist/config-XVJYZ4IQ.js +9 -0
- package/dist/connect-cmd-ZTJONP37.js +16 -0
- package/dist/context-adopt-UMA4O5NY.js +105 -0
- package/dist/context-materialize-G2FUFC72.js +19 -0
- package/dist/dist-44CQVWGT.js +6 -0
- package/dist/docker-NEGDLU6D.js +7 -0
- package/dist/doctor-53F2PYY7.js +43 -0
- package/dist/doctor-ZLGZYMDK.js +426 -0
- package/dist/{emit-artifact-cmd-UQVT3OKW.js → emit-artifact-cmd-AKZP7TMF.js} +87 -23
- package/dist/emit-cmd-DCERAU27.js +9 -0
- package/dist/emit-criteria-cmd-7MXQVCKD.js +166 -0
- package/dist/{emit-finding-cmd-FDOU2OPD.js → emit-finding-cmd-N2FTGCVC.js} +34 -31
- package/dist/{emit-result-cmd-H2T4C2K3.js → emit-result-cmd-4CPDBMN2.js} +34 -62
- package/dist/exec-client-4XGXV3XN.js +8 -0
- package/dist/guest-init-GENRHNP7.js +8 -0
- package/dist/hook-fast-EYENPK6F.js +9 -0
- package/dist/{hook-wrap-fast-KXYNX3AD.js → hook-wrap-fast-NPLHJXFK.js} +2 -2
- package/dist/host-key-KDYZ5ECC.js +7 -0
- package/dist/import-capture-cmd-AVZ4VIVJ.js +68 -0
- package/dist/{import-ruler-cmd-7LH2QOMG.js → import-ruler-cmd-GUHL6IY5.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/init-55Y4LHWF.js +36 -0
- package/dist/init-XXEKWZQP.js +221 -0
- package/dist/init-cmd-WLST2F7G.js +135 -0
- package/dist/install-cmd-7WXOIFDO.js +55 -0
- package/dist/lima-XN65D7GN.js +55 -0
- package/dist/login-browser-7XGSV7MA.js +10 -0
- package/dist/{config-POF7DEQW.js → logout-cmd-QAUFDJ6J.js} +3 -1
- package/dist/{logs-TRNPQM42.js → logs-7RYUH5A6.js} +1 -1
- package/dist/loop-JFIBP4B7.js +43 -0
- package/dist/machine-ZBPT2J3R.js +35 -0
- package/dist/mcp-install-cmd-RLK4SO3N.js +9 -0
- package/dist/{mcp-serve-cmd-57EZZOTL.js → mcp-serve-cmd-N6UD2KBX.js} +20 -9
- package/dist/paths-OYKMVYJP.js +6 -0
- package/dist/{pidfile-PTW76F56.js → pidfile-UZRH774M.js} +2 -3
- package/dist/real-deps-SU24ZA2K.js +21 -0
- package/dist/relay-L76HDX72.js +46 -0
- package/dist/settings-J2652U5N.js +7 -0
- package/dist/starter-pack-LIZYMKYQ.js +10 -0
- package/dist/{status-I27IST4L.js → status-UJXWZRN5.js} +30 -13
- package/dist/{statusline-fast-3C5OXHDD.js → statusline-fast-SVTMBMD7.js} +3 -2
- package/dist/sync-cmd-4G4A7EVN.js +28 -0
- package/dist/version-BWM6VLDI.js +6 -0
- package/dist/{watch-3DTPJETH.js → watch-7SKJKGAA.js} +19 -8
- package/dist/{work-claim-cmd-5AUNCLS2.js → work-claim-cmd-5F7VZ42N.js} +34 -21
- package/dist/work-cmd-QAUZ7MTD.js +17 -0
- package/dist/work-comment-cmd-BNCSBTLH.js +106 -0
- package/dist/{work-launch-LT663PB3.js → work-launch-YI4CJDAP.js} +1 -1
- package/dist/work-open-pr-cmd-VFYZHIIW.js +156 -0
- package/dist/work-pr-cmd-PV32ZLSI.js +16 -0
- package/package.json +13 -3
- package/skill-pack/maintained/amend/SKILL.md +104 -0
- package/skill-pack/maintained/emitting-evidence/SKILL.md +108 -0
- package/skill-pack/maintained/emitting-evidence/references/agents-snippet.md +20 -0
- package/skill-pack/maintained/outerlayer/SKILL.md +55 -0
- package/skill-pack/maintained/reporting-findings/SKILL.md +148 -0
- package/skill-pack/template/build/SKILL.md +193 -0
- package/skill-pack/template/build/references/agent-briefs.md +243 -0
- package/skill-pack/template/build/references/criteria-judge.md +91 -0
- package/skill-pack/template/build/references/evidence.md +42 -0
- package/skill-pack/template/build/references/release.md +74 -0
- package/skill-pack/template/build/references/review-briefs.md +275 -0
- package/skill-pack/template/build/references/review-loop.md +158 -0
- package/skill-pack/template/build/scripts/record-criteria.mjs +235 -0
- package/skill-pack/template/spec/SKILL.md +84 -0
- package/skill-pack/template/writing-specs/SKILL.md +134 -0
- package/dist/chunk-DCNOXRMV.js +0 -589
- package/dist/chunk-JJP7YLMN.js +0 -25
- package/dist/chunk-OZ7C3XUE.js +0 -34
- package/dist/chunk-WQ6VGRGZ.js +0 -150
- package/dist/hook-fast-J5LCDHUJ.js +0 -8
- package/dist/import-capture-cmd-EUMBGIV3.js +0 -176
- package/dist/init-PTBITAUO.js +0 -103
- package/dist/login-cmd-IRX6LZT7.js +0 -62
- package/dist/loop-ASZRX3CZ.js +0 -648
- package/dist/sync-cmd-BBAUZ5JD.js +0 -17
- package/dist/work-cmd-2P4BVX47.js +0 -18
|
@@ -0,0 +1,1461 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { DOCKERD_LOG } from './chunk-CBPPJSAR.js';
|
|
4
|
+
import { installScrubContext } from './chunk-LYCDNMOH.js';
|
|
5
|
+
import { AssetError, describeCallFailure, obtainImage, ensureRootfs, gitRemoteFor, startServicesFor, createBuildDisk, machineConfig, refusalCode, resolveAgentUser, AgentUserError, runBuildSteps, removeStagedContext, stageGovernedContext, shellQuote, ADDED_USER, createRunnerLog, createContainerRuntime, stopContainer, syncInContainer, formatDuration, cleanupContainer, retrySend, formatClock, GH_CONFIG_YML, ghHostsYml, isLiteralAddress, deleteBuildDisk } from './chunk-HZRNMGLF.js';
|
|
6
|
+
import { CLAUDE_CODE_VERSION } from './chunk-ZNA27WEV.js';
|
|
7
|
+
import { isSameProcess, startTimeOf } from './chunk-N5FOE5PS.js';
|
|
8
|
+
import { childEnvironment } from './chunk-774HEQL6.js';
|
|
9
|
+
import { waitForAgent, vmExecArgs } from './chunk-W4SQCOZU.js';
|
|
10
|
+
import { socketPathProblem, vsockLinks, linkVsockSockets } from './chunk-B2G7JHHB.js';
|
|
11
|
+
import { WorkCommandError, postJson, isSignatureRefusal, getJson } from './chunk-W5FVUZXV.js';
|
|
12
|
+
import { planArtifactUpload } from './chunk-WFJ65NUD.js';
|
|
13
|
+
import { scrubText } from './chunk-4C4THABW.js';
|
|
14
|
+
import { isBuiltinContainer, isBuiltinProcess, isBuiltinVm } from './chunk-5Y3QQRUH.js';
|
|
15
|
+
import { parseSize } from './chunk-F6GFZXZ3.js';
|
|
16
|
+
import { claudeCredential, buildEnvironment, JOB_MOUNT, CHECKOUT_DIR, parseIsolation, proxyEnvironment, containerLocalAddress, destinationEnvironment, CLAUDE_CREDENTIAL_VARIABLES, builtInEnvironment, withHooksPath, startBuildServices, BUILT_IN_NAMES, AGENT_CREDENTIAL_MISSING, sharedUserEnvironment, headerVariables, authorEnvironment, withTunnelRecord, commitIdentityRecord, RELAY_PORT } from './chunk-AUFG23AA.js';
|
|
17
|
+
import { readCloudConfig } from './chunk-EABW6AJQ.js';
|
|
18
|
+
import { resolveVersion } from './chunk-RA3O54FC.js';
|
|
19
|
+
import { signerFor } from './chunk-M5POMKKW.js';
|
|
20
|
+
import { runnerDir, buildDir, vsockPath, RUNNER_DIR_MODE, RUNNER_FILE_MODE } from './chunk-ZM2IMMYN.js';
|
|
21
|
+
import { homeFromEnv } from './chunk-VNVZDWO3.js';
|
|
22
|
+
import { spawn, spawnSync } from 'child_process';
|
|
23
|
+
import { randomBytes } from 'crypto';
|
|
24
|
+
import { mkdirSync, chmodSync, writeFileSync, appendFileSync, statSync, accessSync, constants, existsSync, readFileSync, openSync, closeSync, rmSync, readdirSync } from 'fs';
|
|
25
|
+
import { join, isAbsolute, dirname, delimiter } from 'path';
|
|
26
|
+
|
|
27
|
+
createRequire(import.meta.url);
|
|
28
|
+
var DEFAULT_MEMORY = "8g";
|
|
29
|
+
var DEFAULT_DISK = "40g";
|
|
30
|
+
var DEFAULT_CPUS = 4;
|
|
31
|
+
var BOOT_ATTEMPTS = 120;
|
|
32
|
+
var BOOT_POLL_MS = 500;
|
|
33
|
+
var HOOK_ERRORS_LIMIT = 1024 * 1024;
|
|
34
|
+
var HOOK_ERRORS_IN_GUEST = "/build/home/.outerlayer/spool/hook-errors.log";
|
|
35
|
+
var DOCKER_WAIT_SECONDS = 60;
|
|
36
|
+
var DOCKER_SOCKET = "/var/run/docker.sock";
|
|
37
|
+
var DOCKER_LOG_LINES = 20;
|
|
38
|
+
var GUEST_GH_DIR = `${JOB_MOUNT}/gh`;
|
|
39
|
+
var GUEST_HOOKS_DIR = `${JOB_MOUNT}/git-hooks`;
|
|
40
|
+
var GUEST_CONTEXT_DIR = `${JOB_MOUNT}/context`;
|
|
41
|
+
function firstLine(text2) {
|
|
42
|
+
return text2.trim().split("\n")[0] ?? "";
|
|
43
|
+
}
|
|
44
|
+
function execEnvironment(image, env) {
|
|
45
|
+
const merged = { ...image, ...env.literals };
|
|
46
|
+
for (const [name, value] of Object.entries(env.passed)) if (value !== void 0) merged[name] = value;
|
|
47
|
+
return merged;
|
|
48
|
+
}
|
|
49
|
+
function tarOf(dir) {
|
|
50
|
+
const packed = spawnSync("tar", ["-C", dir, "-cf", "-", "."], { maxBuffer: 1024 * 1024 * 1024 });
|
|
51
|
+
if (packed.status !== 0) throw new Error(`tar could not pack ${dir}: ${packed.stderr.toString("utf8").trim()}`);
|
|
52
|
+
return packed.stdout;
|
|
53
|
+
}
|
|
54
|
+
function asRoot(runtime, state, argv, stdin) {
|
|
55
|
+
return runtime.vm.exec(state.vsockPath, { argv, user: "root", cwd: "/", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } }, stdin === void 0 ? void 0 : { stdin });
|
|
56
|
+
}
|
|
57
|
+
async function writeGuestGhConfig(runtime, state, token) {
|
|
58
|
+
const script = [
|
|
59
|
+
"set -e",
|
|
60
|
+
"umask 022",
|
|
61
|
+
`mkdir -p ${GUEST_GH_DIR}`,
|
|
62
|
+
`cat > ${GUEST_GH_DIR}/hosts.yml.partial`,
|
|
63
|
+
`mv ${GUEST_GH_DIR}/hosts.yml.partial ${GUEST_GH_DIR}/hosts.yml`,
|
|
64
|
+
`printf '%s' ${shellQuote(GH_CONFIG_YML)} > ${GUEST_GH_DIR}/config.yml.partial`,
|
|
65
|
+
`mv ${GUEST_GH_DIR}/config.yml.partial ${GUEST_GH_DIR}/config.yml`
|
|
66
|
+
].join("\n");
|
|
67
|
+
const result = await asRoot(runtime, state, ["/bin/sh", "-c", script], ghHostsYml(token));
|
|
68
|
+
if (result.status !== 0) throw new Error(`writing gh's configuration in the guest failed (exit ${result.status}): ${firstLine(result.stderr)}`);
|
|
69
|
+
}
|
|
70
|
+
async function unpackIntoGuest(runtime, state, dir, target) {
|
|
71
|
+
const script = `set -e; mkdir -p ${target}; tar -x --no-same-owner -C ${target}; chmod -R a+rX ${target}`;
|
|
72
|
+
const result = await asRoot(runtime, state, ["/bin/sh", "-c", script], tarOf(dir));
|
|
73
|
+
if (result.status !== 0) throw new Error(`unpacking into ${target} in the guest failed (exit ${result.status}): ${firstLine(result.stderr)}`);
|
|
74
|
+
}
|
|
75
|
+
async function deliverCommitHooks(input) {
|
|
76
|
+
await unpackIntoGuest(input.runtime, input.state, input.hooksDir, GUEST_HOOKS_DIR);
|
|
77
|
+
}
|
|
78
|
+
async function provisionMicroVm(input) {
|
|
79
|
+
const { config, runtime, item, itemKey, jobDir, log, tokens } = input;
|
|
80
|
+
const { vm } = runtime;
|
|
81
|
+
const tag = `#${item.number}`;
|
|
82
|
+
const logPath = join(jobDir, "provision.log");
|
|
83
|
+
const fail = (reason) => ({ ok: false, reason });
|
|
84
|
+
if (claudeCredential(runtime.hostEnv) === void 0) return fail(AGENT_CREDENTIAL_MISSING);
|
|
85
|
+
const repository = item.specRepository;
|
|
86
|
+
if (repository === void 0 || repository === null || repository === "") {
|
|
87
|
+
return fail("the work item names no repository to clone");
|
|
88
|
+
}
|
|
89
|
+
const runnerDirPath = runnerDir(config.configPath);
|
|
90
|
+
const tooDeep = socketPathProblem(buildDir(config.configPath, "0".repeat(12)));
|
|
91
|
+
if (tooDeep !== void 0) return fail(tooDeep);
|
|
92
|
+
let assets;
|
|
93
|
+
try {
|
|
94
|
+
assets = await vm.assets(runnerDirPath);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return fail(err instanceof AssetError ? err.message : `the microVM's assets could not be had: ${describeCallFailure(err)}`);
|
|
97
|
+
}
|
|
98
|
+
const obtained = await obtainImage({
|
|
99
|
+
config,
|
|
100
|
+
runtime,
|
|
101
|
+
tokens,
|
|
102
|
+
repository,
|
|
103
|
+
tag,
|
|
104
|
+
log,
|
|
105
|
+
aborted: input.aborted,
|
|
106
|
+
deadlineMs: input.deadlineMs,
|
|
107
|
+
now: input.now,
|
|
108
|
+
guestHelper: { path: assets.helper, sha256: assets.helperSha256 }
|
|
109
|
+
});
|
|
110
|
+
if (!obtained.ok) return obtained.aborted === true ? { ok: false, reason: obtained.reason, aborted: true } : fail(obtained.reason);
|
|
111
|
+
const { report } = obtained.built;
|
|
112
|
+
const buildId = randomBytes(6).toString("hex");
|
|
113
|
+
const buildDir2 = buildDir(config.configPath, buildId);
|
|
114
|
+
const state = {
|
|
115
|
+
buildId,
|
|
116
|
+
buildDir: buildDir2,
|
|
117
|
+
repository,
|
|
118
|
+
imageId: report.imageId,
|
|
119
|
+
containerEnv: report.apply.containerEnv,
|
|
120
|
+
builtIns: true
|
|
121
|
+
};
|
|
122
|
+
const env = buildEnvironment({
|
|
123
|
+
config,
|
|
124
|
+
item: item.number,
|
|
125
|
+
itemKey,
|
|
126
|
+
repository,
|
|
127
|
+
hostEnv: runtime.hostEnv,
|
|
128
|
+
containerEnv: report.apply.containerEnv,
|
|
129
|
+
builtIns: true
|
|
130
|
+
});
|
|
131
|
+
if (env.skippedTokenVariables.length > 0) {
|
|
132
|
+
log.line(`${tag} not passing ${env.skippedTokenVariables.join(", ")} from runner.build.variables into the build: its repository token comes from the runner`);
|
|
133
|
+
}
|
|
134
|
+
const environment = {
|
|
135
|
+
isolation: "vm",
|
|
136
|
+
recipe: { sourceCommit: report.sourceCommit, key: report.recipeKey, path: report.recipe },
|
|
137
|
+
imageId: report.imageId,
|
|
138
|
+
ignoredProperties: report.ignored.map(({ property, source }) => ({ property, source })),
|
|
139
|
+
variables: env.variables,
|
|
140
|
+
cliVersion: input.cliVersion
|
|
141
|
+
};
|
|
142
|
+
input.save(state, environment);
|
|
143
|
+
const bail = async (reason, aborted = false) => {
|
|
144
|
+
try {
|
|
145
|
+
await cleanupMicroVm(runtime, state);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log.error(`${tag} cleanup failed: ${describeCallFailure(err)}`);
|
|
148
|
+
}
|
|
149
|
+
return aborted ? { ok: false, reason: "ended before provision finished", aborted: true } : fail(reason);
|
|
150
|
+
};
|
|
151
|
+
if (input.aborted()) return bail("", true);
|
|
152
|
+
let imageConfig;
|
|
153
|
+
try {
|
|
154
|
+
state.rootfs = await ensureRootfs({ host: vm.rootfs, runnerDir: runnerDirPath, imageId: report.imageId });
|
|
155
|
+
imageConfig = await vm.rootfs.imageConfig(report.imageId);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return bail(`the image could not be converted to a root disk: ${describeCallFailure(err)}`);
|
|
158
|
+
}
|
|
159
|
+
state.imageEnv = imageConfig.env;
|
|
160
|
+
state.buildDisk = join(buildDir2, "build.ext4");
|
|
161
|
+
state.vsockPath = vsockPath(buildDir2);
|
|
162
|
+
input.save(state, environment);
|
|
163
|
+
if (input.aborted()) return bail("", true);
|
|
164
|
+
mkdirSync(buildDir2, { recursive: true, mode: 448 });
|
|
165
|
+
chmodSync(buildDir2, 448);
|
|
166
|
+
try {
|
|
167
|
+
const gitRemote = gitRemoteFor(runtime, { repository, branch: input.branch, tokens, buildDir: buildDir2, log: (line) => log.line(`${tag} ${line}`) });
|
|
168
|
+
runtime.tunnels.set(buildId, await startServicesFor(runtime, config, buildDir2, jobDir, itemKey, true, gitRemote));
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return bail(`the tunnel could not start: ${describeCallFailure(err)}`);
|
|
171
|
+
}
|
|
172
|
+
const limits = {
|
|
173
|
+
cpus: config.build?.cpus ?? DEFAULT_CPUS,
|
|
174
|
+
memoryBytes: parseSize(config.build?.memory ?? DEFAULT_MEMORY) ?? parseSize(DEFAULT_MEMORY),
|
|
175
|
+
diskBytes: parseSize(config.build?.disk ?? DEFAULT_DISK) ?? parseSize(DEFAULT_DISK)
|
|
176
|
+
};
|
|
177
|
+
const links = vsockLinks({ destinations: config.build?.destinations?.length ?? 0, builtIns: true });
|
|
178
|
+
const configPath = join(buildDir2, "vm.json");
|
|
179
|
+
try {
|
|
180
|
+
linkVsockSockets({ buildDir: buildDir2, vsockPath: state.vsockPath, links });
|
|
181
|
+
await createBuildDisk({ host: vm.rootfs, runnerDir: runnerDirPath, bytes: limits.diskBytes, dest: state.buildDisk });
|
|
182
|
+
writeFileSync(
|
|
183
|
+
configPath,
|
|
184
|
+
JSON.stringify(
|
|
185
|
+
machineConfig(limits, { kernel: assets.kernel, rootfs: state.rootfs, buildDisk: state.buildDisk, vsock: state.vsockPath, relayPorts: links.map((link) => link.port), docker: report.docker }),
|
|
186
|
+
null,
|
|
187
|
+
2
|
|
188
|
+
),
|
|
189
|
+
{ mode: 384 }
|
|
190
|
+
);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return bail(`the build's disk and machine could not be made: ${describeCallFailure(err)}`);
|
|
193
|
+
}
|
|
194
|
+
if (input.aborted()) return bail("", true);
|
|
195
|
+
const consolePath = join(jobDir, "vm-console.log");
|
|
196
|
+
try {
|
|
197
|
+
state.firecracker = await vm.firecracker.spawn({ binary: assets.firecracker, configPath, consolePath });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return bail(`Firecracker did not start: ${describeCallFailure(err)}`);
|
|
200
|
+
}
|
|
201
|
+
input.save(state, environment);
|
|
202
|
+
if (input.aborted()) return bail("", true);
|
|
203
|
+
const ready = await waitForAgent(state.vsockPath, { attempts: BOOT_ATTEMPTS, delayMs: BOOT_POLL_MS, sleep: runtime.sleep });
|
|
204
|
+
if (!ready) {
|
|
205
|
+
const why = vm.firecracker.alive(state.firecracker) ? "its exec agent did not answer" : "it exited before its exec agent answered";
|
|
206
|
+
return bail(`the microVM did not start: ${why}; its console is in ${consolePath}`);
|
|
207
|
+
}
|
|
208
|
+
let pushes = Promise.resolve();
|
|
209
|
+
let firstWriteFailure;
|
|
210
|
+
let firstWriteSettled = false;
|
|
211
|
+
const reportWrite = (err) => {
|
|
212
|
+
if (!firstWriteSettled && firstWriteFailure === void 0) firstWriteFailure = err;
|
|
213
|
+
log.error(`${tag} the read token could not be written for gh in the guest: ${describeCallFailure(err)}`);
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
await tokens.token("read");
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return bail(
|
|
219
|
+
err instanceof WorkCommandError ? `the build's read token could not be had: ${refusalCode(err)}` : `the build's gh configuration could not be written: ${describeCallFailure(err)}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
tokens.onRead((token) => {
|
|
223
|
+
pushes = pushes.then(async () => {
|
|
224
|
+
if (state.firecracker === void 0 || !vm.firecracker.alive(state.firecracker)) return;
|
|
225
|
+
await writeGuestGhConfig(runtime, state, token);
|
|
226
|
+
}, void 0).catch(reportWrite);
|
|
227
|
+
});
|
|
228
|
+
await pushes;
|
|
229
|
+
firstWriteSettled = true;
|
|
230
|
+
if (firstWriteFailure !== void 0) return bail(`the build's gh configuration could not be written: ${describeCallFailure(firstWriteFailure)}`);
|
|
231
|
+
tokens.markUsed("read");
|
|
232
|
+
environment.credentials = tokens.used();
|
|
233
|
+
const uidOf = async (user) => {
|
|
234
|
+
const probe = await asRoot(runtime, state, ["id", "-u", user]);
|
|
235
|
+
const uid = Number(probe.stdout);
|
|
236
|
+
return probe.status === 0 && Number.isInteger(uid) ? uid : null;
|
|
237
|
+
};
|
|
238
|
+
let agentUser;
|
|
239
|
+
try {
|
|
240
|
+
agentUser = await resolveAgentUser({ remoteUser: report.apply.remoteUser, imageUser: imageConfig.user, probe: uidOf });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (err instanceof AgentUserError) return bail(err.message);
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
state.agentUser = agentUser;
|
|
246
|
+
const tools = await asRoot(runtime, state, ["cat", "/opt/outerlayer/tools.json"]);
|
|
247
|
+
environment.agentVersion = agentVersionOf(tools.status === 0 ? tools.stdout : "");
|
|
248
|
+
input.save(state, environment);
|
|
249
|
+
if (report.docker === true) {
|
|
250
|
+
const failure = await waitForDocker(runtime, state, agentUser, input.now ?? (() => /* @__PURE__ */ new Date()));
|
|
251
|
+
if (failure !== void 0) return bail(failure);
|
|
252
|
+
}
|
|
253
|
+
const request = (argv, workdir) => ({ argv, user: agentUser, cwd: workdir, env: execEnvironment(state.imageEnv, env) });
|
|
254
|
+
const steps = await runBuildSteps({
|
|
255
|
+
repository,
|
|
256
|
+
branch: input.branch,
|
|
257
|
+
lifecycle: report.apply.lifecycle,
|
|
258
|
+
exec: async (argv, workdir, opts) => {
|
|
259
|
+
const ran = await vm.exec(state.vsockPath, request(argv, workdir));
|
|
260
|
+
if (opts?.quiet !== true) appendFileSync(logPath, ran.stdout + ran.stderr);
|
|
261
|
+
return ran;
|
|
262
|
+
},
|
|
263
|
+
log,
|
|
264
|
+
tag,
|
|
265
|
+
aborted: input.aborted,
|
|
266
|
+
stageContext: () => stageGovernedContext({
|
|
267
|
+
config,
|
|
268
|
+
repository,
|
|
269
|
+
buildId,
|
|
270
|
+
buildDir: buildDir2,
|
|
271
|
+
fetchImpl: input.fetchImpl,
|
|
272
|
+
controlPlaneUrl: (controlPlane) => runtime.remoteFor?.(controlPlane) ?? `${runtime.github.gitUrl}/${controlPlane}.git`
|
|
273
|
+
}),
|
|
274
|
+
// The guest cannot read the build's directory, so the staged files are unpacked into its job directory.
|
|
275
|
+
deliverContext: async (staged) => {
|
|
276
|
+
try {
|
|
277
|
+
await unpackIntoGuest(runtime, state, staged.dir, GUEST_CONTEXT_DIR);
|
|
278
|
+
return { ok: true };
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return { ok: false, reason: `context_unavailable: ${describeCallFailure(err)}` };
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
removeStagedContext: () => removeStagedContext(buildDir2)
|
|
284
|
+
});
|
|
285
|
+
if (!steps.ok) return bail(steps.reason, steps.aborted === true);
|
|
286
|
+
return { ok: true, workdir: CHECKOUT_DIR, state, environment };
|
|
287
|
+
}
|
|
288
|
+
async function waitForDocker(runtime, state, agentUser, now) {
|
|
289
|
+
const grant = `[ -S ${DOCKER_SOCKET} ] && chgrp "$(id -g ${shellQuote(agentUser)})" ${DOCKER_SOCKET} && chmod 660 ${DOCKER_SOCKET}`;
|
|
290
|
+
const request = { argv: ["timeout", "5", "docker", "info"], user: agentUser, cwd: "/", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } };
|
|
291
|
+
const deadline = now().getTime() + DOCKER_WAIT_SECONDS * 1e3;
|
|
292
|
+
let lastError = "";
|
|
293
|
+
for (; ; ) {
|
|
294
|
+
const granted = await asRoot(runtime, state, ["/bin/sh", "-c", grant]);
|
|
295
|
+
if (granted.status !== 0) {
|
|
296
|
+
if (firstLine(granted.stderr) !== "") lastError = `giving the agent's group the Docker socket failed: ${firstLine(granted.stderr)}`;
|
|
297
|
+
} else {
|
|
298
|
+
const answered = await runtime.vm.exec(state.vsockPath, request);
|
|
299
|
+
if (answered.status === 0) return void 0;
|
|
300
|
+
lastError = firstLine(answered.stderr) === "" ? "" : `docker info said: ${firstLine(answered.stderr)}`;
|
|
301
|
+
}
|
|
302
|
+
if (now().getTime() >= deadline) break;
|
|
303
|
+
await runtime.sleep(1e3);
|
|
304
|
+
}
|
|
305
|
+
const tail = await asRoot(runtime, state, ["tail", "-n", String(DOCKER_LOG_LINES), DOCKERD_LOG]).catch(() => void 0);
|
|
306
|
+
const log = tail !== void 0 && tail.status === 0 ? tail.stdout.trim() : "";
|
|
307
|
+
const detail = [lastError, log === "" ? "" : `the daemon's log ends: ${log}`].filter((part) => part !== "");
|
|
308
|
+
return `the Docker daemon in the microVM did not answer docker info within ${DOCKER_WAIT_SECONDS} seconds${detail.length === 0 ? "" : `; ${detail.join("; ")}`}`;
|
|
309
|
+
}
|
|
310
|
+
function agentVersionOf(toolsJson) {
|
|
311
|
+
try {
|
|
312
|
+
const version = JSON.parse(toolsJson).claudeCodeVersion;
|
|
313
|
+
if (typeof version === "string" && version !== "") return version;
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
return CLAUDE_CODE_VERSION;
|
|
317
|
+
}
|
|
318
|
+
function microVmCommandLine(input) {
|
|
319
|
+
const state = input.job.microvm;
|
|
320
|
+
const env = buildEnvironment({
|
|
321
|
+
config: input.config,
|
|
322
|
+
item: input.job.item,
|
|
323
|
+
itemKey: input.job.itemKey,
|
|
324
|
+
branch: input.job.branch,
|
|
325
|
+
repository: state.repository,
|
|
326
|
+
hostEnv: input.runtime.hostEnv,
|
|
327
|
+
containerEnv: state.containerEnv ?? {},
|
|
328
|
+
builtIns: state.builtIns === true,
|
|
329
|
+
identity: input.job.commitIdentity
|
|
330
|
+
});
|
|
331
|
+
const options = {
|
|
332
|
+
socket: state.vsockPath,
|
|
333
|
+
user: state.agentUser ?? ADDED_USER,
|
|
334
|
+
cwd: CHECKOUT_DIR,
|
|
335
|
+
env: { ...state.imageEnv, ...env.literals },
|
|
336
|
+
pass: Object.keys(env.passed).filter((name) => env.passed[name] !== void 0),
|
|
337
|
+
argv: ["/bin/sh", "-c", input.cmdline]
|
|
338
|
+
};
|
|
339
|
+
return {
|
|
340
|
+
line: [process.execPath, input.cli, "vm-exec", ...vmExecArgs(options)].map(shellQuote).join(" "),
|
|
341
|
+
options,
|
|
342
|
+
env: { ...childEnvironment(input.runtime.hostEnv), ...env.passed }
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function isRunning(runtime, state) {
|
|
346
|
+
return state.firecracker !== void 0 && runtime.vm.firecracker.alive(state.firecracker);
|
|
347
|
+
}
|
|
348
|
+
async function syncInMicroVm(input) {
|
|
349
|
+
const { runtime, job } = input;
|
|
350
|
+
const state = job.microvm;
|
|
351
|
+
if (!isRunning(runtime, state)) return { ok: false, reason: "the microVM is gone, so its sessions cannot be read" };
|
|
352
|
+
try {
|
|
353
|
+
const env = buildEnvironment({
|
|
354
|
+
config: input.config,
|
|
355
|
+
item: job.item,
|
|
356
|
+
itemKey: job.itemKey,
|
|
357
|
+
branch: job.branch,
|
|
358
|
+
repository: state.repository,
|
|
359
|
+
hostEnv: runtime.hostEnv,
|
|
360
|
+
containerEnv: state.containerEnv ?? {},
|
|
361
|
+
builtIns: state.builtIns === true,
|
|
362
|
+
identity: job.commitIdentity
|
|
363
|
+
});
|
|
364
|
+
const result = await runtime.vm.exec(state.vsockPath, {
|
|
365
|
+
argv: ["outerlayer", "sync"],
|
|
366
|
+
user: state.agentUser ?? ADDED_USER,
|
|
367
|
+
cwd: job.workdir ?? CHECKOUT_DIR,
|
|
368
|
+
env: execEnvironment(state.imageEnv, env)
|
|
369
|
+
});
|
|
370
|
+
appendFileSync(join(input.jobDir, "sync.log"), result.stdout + result.stderr);
|
|
371
|
+
return result.status === 0 ? { ok: true } : { ok: false, reason: `exit ${result.status}` };
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return { ok: false, reason: describeCallFailure(err) };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async function copyHookErrors(runtime, state, jobDir) {
|
|
377
|
+
if (!isRunning(runtime, state)) return;
|
|
378
|
+
try {
|
|
379
|
+
const log = await asRoot(runtime, state, ["head", "-c", String(HOOK_ERRORS_LIMIT), HOOK_ERRORS_IN_GUEST]);
|
|
380
|
+
if (log.status === 0 && log.stdout !== "") writeFileSync(join(jobDir, "hook-errors.log"), log.stdout);
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async function cleanupMicroVm(runtime, state, jobDir) {
|
|
385
|
+
const failures = [];
|
|
386
|
+
if (jobDir !== void 0) await copyHookErrors(runtime, state, jobDir);
|
|
387
|
+
if (state.firecracker !== void 0) {
|
|
388
|
+
try {
|
|
389
|
+
await runtime.vm.firecracker.stop(state.firecracker);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
failures.push(`the microVM (pid ${state.firecracker.pid}) was not stopped: ${describeCallFailure(err)}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (state.buildDisk !== void 0) deleteBuildDisk(state.buildDisk);
|
|
395
|
+
const tunnel = runtime.tunnels.get(state.buildId);
|
|
396
|
+
if (tunnel !== void 0) {
|
|
397
|
+
runtime.tunnels.delete(state.buildId);
|
|
398
|
+
await tunnel.close();
|
|
399
|
+
}
|
|
400
|
+
rmSync(state.buildDir, { recursive: true, force: true });
|
|
401
|
+
if (failures.length > 0) throw new Error(failures.join("; "));
|
|
402
|
+
}
|
|
403
|
+
async function stopOrphanedMicroVms(input) {
|
|
404
|
+
const stopped = [];
|
|
405
|
+
for (const { job } of input.jobs) {
|
|
406
|
+
const state = job.microvm;
|
|
407
|
+
if (state === void 0) continue;
|
|
408
|
+
if (job.pid !== void 0 && job.start !== void 0 && isSameProcess(job.pid, job.start)) {
|
|
409
|
+
try {
|
|
410
|
+
process.kill(-job.pid, "SIGKILL");
|
|
411
|
+
} catch {
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (state.firecracker !== void 0 && input.runtime.vm.firecracker.alive(state.firecracker)) {
|
|
415
|
+
await input.runtime.vm.firecracker.stop(state.firecracker);
|
|
416
|
+
stopped.push(state.buildId);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return stopped;
|
|
420
|
+
}
|
|
421
|
+
function provisionEnvironment(base, input) {
|
|
422
|
+
return {
|
|
423
|
+
...base,
|
|
424
|
+
OUTERLAYER_BUILD_DIR: input.buildDir,
|
|
425
|
+
...input.repository === void 0 || input.repository === null || input.repository === "" ? {} : { OUTERLAYER_REPOSITORY: input.repository },
|
|
426
|
+
...input.recipeCommit === void 0 ? {} : { OUTERLAYER_RECIPE_COMMIT: input.recipeCommit },
|
|
427
|
+
OUTERLAYER_MEMORY: input.limits.memory,
|
|
428
|
+
OUTERLAYER_CPUS: String(input.limits.cpus),
|
|
429
|
+
OUTERLAYER_PIDS: String(input.limits.pids),
|
|
430
|
+
OUTERLAYER_DISK: input.limits.disk
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function unspecified(address) {
|
|
434
|
+
if (!address.includes(":")) return address === "0.0.0.0";
|
|
435
|
+
const normal = new URL(`http://[${address}]/`).hostname;
|
|
436
|
+
return normal === "[::]" || normal === "[::ffff:0:0]";
|
|
437
|
+
}
|
|
438
|
+
function parseBroker(text2) {
|
|
439
|
+
const match = /^(?:\[([0-9a-fA-F:.]+)\]|([0-9.]+)):(\d{1,5})$/.exec(text2);
|
|
440
|
+
if (match === null) return void 0;
|
|
441
|
+
const host = match[1] ?? match[2];
|
|
442
|
+
const port = Number(match[3]);
|
|
443
|
+
if (!isLiteralAddress(host) || unspecified(host) || port < 1 || port > 65535) return void 0;
|
|
444
|
+
return { host, port, text: text2 };
|
|
445
|
+
}
|
|
446
|
+
function readReport(path) {
|
|
447
|
+
const named = `provision.out names recipe ${path}`;
|
|
448
|
+
if (!isAbsolute(path)) return { problem: `${named}, which is not an absolute path` };
|
|
449
|
+
let text2;
|
|
450
|
+
try {
|
|
451
|
+
text2 = readFileSync(path, "utf8");
|
|
452
|
+
} catch {
|
|
453
|
+
return { problem: `${named}, which does not exist` };
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const parsed = JSON.parse(text2);
|
|
457
|
+
const lifecycle = parsed.apply?.lifecycle;
|
|
458
|
+
if (typeof parsed.sourceCommit === "string" && typeof parsed.recipeKey === "string" && typeof parsed.imageId === "string" && typeof lifecycle === "object" && lifecycle !== null && Object.values(lifecycle).every((entries) => Array.isArray(entries) && entries.every((entry) => typeof entry === "object" && entry !== null))) {
|
|
459
|
+
return { report: { ...parsed, ignored: Array.isArray(parsed.ignored) ? parsed.ignored : [] } };
|
|
460
|
+
}
|
|
461
|
+
} catch {
|
|
462
|
+
}
|
|
463
|
+
return { problem: `${named}, which is not a recipe report` };
|
|
464
|
+
}
|
|
465
|
+
function resolveHostBuild(keys) {
|
|
466
|
+
const build = { isolation: parseIsolation(keys.isolation) };
|
|
467
|
+
if (keys.exec !== void 0) {
|
|
468
|
+
const named = `provision.out names exec ${keys.exec}`;
|
|
469
|
+
if (!isAbsolute(keys.exec)) return { ok: false, reason: `${named}, which is not an absolute path` };
|
|
470
|
+
try {
|
|
471
|
+
if (!statSync(keys.exec).isFile()) return { ok: false, reason: `${named}, which is not an executable file` };
|
|
472
|
+
accessSync(keys.exec, constants.X_OK);
|
|
473
|
+
} catch {
|
|
474
|
+
return { ok: false, reason: `${named}, which is not an executable file` };
|
|
475
|
+
}
|
|
476
|
+
build.exec = keys.exec;
|
|
477
|
+
}
|
|
478
|
+
if (keys.broker !== void 0) {
|
|
479
|
+
const broker = parseBroker(keys.broker);
|
|
480
|
+
if (broker === void 0) {
|
|
481
|
+
return { ok: false, reason: `provision.out names broker ${keys.broker}, which is not <IP>:<port> with a literal address that is not 0.0.0.0 or ::` };
|
|
482
|
+
}
|
|
483
|
+
build.broker = broker;
|
|
484
|
+
}
|
|
485
|
+
if (keys.recipe !== void 0) {
|
|
486
|
+
const read = readReport(keys.recipe);
|
|
487
|
+
if ("problem" in read) return { ok: false, reason: read.problem };
|
|
488
|
+
build.recipe = read.report;
|
|
489
|
+
build.recipePath = keys.recipe;
|
|
490
|
+
}
|
|
491
|
+
return { ok: true, build };
|
|
492
|
+
}
|
|
493
|
+
function hostEnvironment(build, cliVersion) {
|
|
494
|
+
const report = build.recipe;
|
|
495
|
+
return {
|
|
496
|
+
isolation: build.isolation,
|
|
497
|
+
...report === void 0 ? {} : {
|
|
498
|
+
recipe: { sourceCommit: report.sourceCommit, key: report.recipeKey, path: report.recipe },
|
|
499
|
+
imageId: report.imageId,
|
|
500
|
+
ignoredProperties: report.ignored.map(({ property, source }) => ({ property, source }))
|
|
501
|
+
},
|
|
502
|
+
cliVersion
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
var CD_INTO_WORKDIR = 'cd -- "$0" && exec "$@"';
|
|
506
|
+
function execArgv(workdir, argv) {
|
|
507
|
+
return ["/bin/sh", "-c", CD_INTO_WORKDIR, workdir, ...argv];
|
|
508
|
+
}
|
|
509
|
+
function execCommandLine(exec, workdir, cmdline) {
|
|
510
|
+
return [exec, ...execArgv(workdir, ["/bin/sh", "-c", cmdline])].map(shellQuote).join(" ");
|
|
511
|
+
}
|
|
512
|
+
function proxyUrlFor(build) {
|
|
513
|
+
if (build.broker === void 0) return `http://127.0.0.1:${RELAY_PORT}`;
|
|
514
|
+
return `http://${build.broker}`;
|
|
515
|
+
}
|
|
516
|
+
function hostCommandEnvironment(base, build, config, hooksPath) {
|
|
517
|
+
let env = { ...base };
|
|
518
|
+
if (build.exec !== void 0) {
|
|
519
|
+
const brokerHost = build.broker === void 0 ? void 0 : build.broker.slice(0, build.broker.lastIndexOf(":")).replace(/^\[|\]$/g, "");
|
|
520
|
+
Object.assign(env, proxyEnvironment(proxyUrlFor(build), brokerHost));
|
|
521
|
+
} else if (build.proxyPort !== void 0) {
|
|
522
|
+
Object.assign(env, proxyEnvironment(`http://127.0.0.1:${build.proxyPort}`));
|
|
523
|
+
}
|
|
524
|
+
const ports = build.destinations ?? [];
|
|
525
|
+
const portOf = (name) => ports.find((entry) => entry.name === name)?.port;
|
|
526
|
+
(config.build?.destinations ?? []).forEach((destination, index) => {
|
|
527
|
+
const port = portOf(destination.name);
|
|
528
|
+
const address = build.exec !== void 0 ? containerLocalAddress(index) : port === void 0 ? void 0 : `http://127.0.0.1:${port}/`;
|
|
529
|
+
if (address !== void 0) Object.assign(env, destinationEnvironment([destination], () => address));
|
|
530
|
+
});
|
|
531
|
+
if (build.builtIns === true) {
|
|
532
|
+
const credential = claudeCredential(base);
|
|
533
|
+
for (const name of CLAUDE_CREDENTIAL_VARIABLES) delete env[name];
|
|
534
|
+
Object.assign(env, builtInEnvironment((name) => `http://127.0.0.1:${portOf(name) ?? 0}`, credential));
|
|
535
|
+
}
|
|
536
|
+
if (build.exec === void 0 && hooksPath !== void 0) env = withHooksPath(env, hooksPath);
|
|
537
|
+
return env;
|
|
538
|
+
}
|
|
539
|
+
async function startHostServices(input) {
|
|
540
|
+
const { runtime, config, build } = input;
|
|
541
|
+
const destinations = config.build?.destinations ?? [];
|
|
542
|
+
const services = await startBuildServices({
|
|
543
|
+
startTunnel: runtime.startTunnel,
|
|
544
|
+
tunnelOptions: runtime.tunnelOptions,
|
|
545
|
+
buildDir: build.buildDir,
|
|
546
|
+
jobDir: input.jobDir,
|
|
547
|
+
destinations,
|
|
548
|
+
allowHosts: config.build?.allowHosts,
|
|
549
|
+
serve: input.process ? "tcp" : "both",
|
|
550
|
+
proxy: input.process,
|
|
551
|
+
...input.process ? { builtIns: { gatewayUrl: config.url, itemKey: input.itemKey, claudeUpstream: runtime.claudeUpstream } } : {},
|
|
552
|
+
ports: {
|
|
553
|
+
proxy: build.proxyPort,
|
|
554
|
+
destinations: build.destinations?.filter((d) => !BUILT_IN_NAMES.includes(d.name)).map((d) => d.port),
|
|
555
|
+
builtIns: Object.fromEntries((build.destinations ?? []).filter((d) => BUILT_IN_NAMES.includes(d.name)).map((d) => [d.name, d.port]))
|
|
556
|
+
},
|
|
557
|
+
hostEnv: runtime.hostEnv,
|
|
558
|
+
destinationOptions: runtime.destinationOptions
|
|
559
|
+
});
|
|
560
|
+
build.proxyPort = services.proxyPort;
|
|
561
|
+
build.destinations = [
|
|
562
|
+
...destinations.map((destination, index) => ({ name: destination.name, port: services.destinationPorts[index] })),
|
|
563
|
+
...Object.entries(services.builtInPorts).map(([name, port]) => ({ name, port }))
|
|
564
|
+
];
|
|
565
|
+
if (input.process) build.builtIns = true;
|
|
566
|
+
runtime.tunnels.set(build.buildId, services);
|
|
567
|
+
if (build.broker !== void 0) await listenOnBroker(services, build.broker);
|
|
568
|
+
return services;
|
|
569
|
+
}
|
|
570
|
+
async function listenOnBroker(tunnel, broker) {
|
|
571
|
+
const parsed = parseBroker(broker);
|
|
572
|
+
if (parsed === void 0) throw new Error(`${broker} is not a broker address`);
|
|
573
|
+
await tunnel.listenTcp({ host: parsed.host, port: parsed.port });
|
|
574
|
+
}
|
|
575
|
+
async function closeHostBuild(runtime, build) {
|
|
576
|
+
const tunnel = runtime.tunnels.get(build.buildId);
|
|
577
|
+
if (tunnel !== void 0) {
|
|
578
|
+
runtime.tunnels.delete(build.buildId);
|
|
579
|
+
await tunnel.close();
|
|
580
|
+
}
|
|
581
|
+
rmSync(build.buildDir, { recursive: true, force: true });
|
|
582
|
+
if (build.workDir !== void 0) rmSync(build.workDir, { recursive: true, force: true });
|
|
583
|
+
}
|
|
584
|
+
function readJobFile(path) {
|
|
585
|
+
if (!existsSync(path)) return null;
|
|
586
|
+
try {
|
|
587
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
588
|
+
} catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function writeJobFile(path, job) {
|
|
593
|
+
mkdirSync(dirname(path), { recursive: true, mode: RUNNER_DIR_MODE });
|
|
594
|
+
writeFileSync(path, JSON.stringify(job, null, 2), { mode: RUNNER_FILE_MODE });
|
|
595
|
+
}
|
|
596
|
+
var ENDED = "ended before provision finished";
|
|
597
|
+
async function provisionProcess(input) {
|
|
598
|
+
const { config, runtime, item, jobDir } = input;
|
|
599
|
+
if (claudeCredential(runtime.hostEnv) === void 0) return { ok: false, reason: AGENT_CREDENTIAL_MISSING };
|
|
600
|
+
const repository = item.specRepository;
|
|
601
|
+
if (repository === void 0 || repository === null || repository === "") {
|
|
602
|
+
return { ok: false, reason: "the work item names no repository to clone" };
|
|
603
|
+
}
|
|
604
|
+
const buildId = randomBytes(6).toString("hex");
|
|
605
|
+
const buildDir2 = buildDir(config.configPath, buildId);
|
|
606
|
+
const workDir = join(jobDir, "work");
|
|
607
|
+
const state = { buildId, buildDir: buildDir2, workDir };
|
|
608
|
+
const environment = sharedUserEnvironment(input.cliVersion);
|
|
609
|
+
input.save(state, environment);
|
|
610
|
+
const bail = async (reason) => {
|
|
611
|
+
try {
|
|
612
|
+
await cleanupProcess(runtime, state);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
input.log.error(`#${item.number} cleanup failed: ${describeCallFailure(err)}`);
|
|
615
|
+
}
|
|
616
|
+
return { ok: false, reason };
|
|
617
|
+
};
|
|
618
|
+
if (input.aborted()) return bail(ENDED);
|
|
619
|
+
mkdirSync(buildDir2, { recursive: true });
|
|
620
|
+
chmodSync(buildDir2, 493);
|
|
621
|
+
try {
|
|
622
|
+
await startHostServices({ runtime, config, build: state, jobDir, itemKey: input.itemKey, process: true });
|
|
623
|
+
} catch (err) {
|
|
624
|
+
return bail(`the tunnel could not start: ${describeCallFailure(err)}`);
|
|
625
|
+
}
|
|
626
|
+
input.save(state, environment);
|
|
627
|
+
if (input.aborted()) return bail(ENDED);
|
|
628
|
+
const checkout = join(workDir, "checkout");
|
|
629
|
+
const remote = runtime.remoteFor?.(repository) ?? `https://github.com/${repository}.git`;
|
|
630
|
+
const cloned = await runtime.clone({ remote, dest: checkout });
|
|
631
|
+
if (!cloned.ok) return bail(cloned.reason);
|
|
632
|
+
if (input.aborted()) return bail(ENDED);
|
|
633
|
+
return { ok: true, workdir: checkout, state, environment };
|
|
634
|
+
}
|
|
635
|
+
function cleanupProcess(runtime, state) {
|
|
636
|
+
return closeHostBuild(runtime, state);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/runner/verdict.ts
|
|
640
|
+
var VERDICT_SETTLE_MS = 5 * 6e4;
|
|
641
|
+
var VERDICT_READ_EVERY_MS = 3e4;
|
|
642
|
+
var REASON_MAX = 200;
|
|
643
|
+
function text(value) {
|
|
644
|
+
return typeof value === "string" ? value : "";
|
|
645
|
+
}
|
|
646
|
+
function fields(value) {
|
|
647
|
+
return Object(value);
|
|
648
|
+
}
|
|
649
|
+
function parseEvaluation(value) {
|
|
650
|
+
if (value === void 0) return void 0;
|
|
651
|
+
const raw = fields(value);
|
|
652
|
+
const verdict = raw.verdict;
|
|
653
|
+
if (verdict !== "pass" && verdict !== "flag" && verdict !== "unverifiable" && verdict !== "waiting") return null;
|
|
654
|
+
const open = [];
|
|
655
|
+
for (const row of Array.isArray(raw.open) ? raw.open : []) {
|
|
656
|
+
const r = fields(row);
|
|
657
|
+
if (r.status !== "flag" && r.status !== "pending") continue;
|
|
658
|
+
open.push({ label: text(r.label), status: r.status, sentence: text(r.sentence) });
|
|
659
|
+
}
|
|
660
|
+
return { verdict, sentence: text(raw.sentence), waitingOn: raw.waitingOn, open };
|
|
661
|
+
}
|
|
662
|
+
async function readItemEvaluation(config, item, fetchImpl) {
|
|
663
|
+
try {
|
|
664
|
+
const { data } = await getJson(config.url, `/v1/work-items/${item}`, config.apiKey, config.appId, fetchImpl, await signerFor(config.configPath));
|
|
665
|
+
return { ok: true, evaluation: parseEvaluation(fields(data).evaluation) };
|
|
666
|
+
} catch (err) {
|
|
667
|
+
return { ok: false, reason: describeCallFailure(err) };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function rowText(row) {
|
|
671
|
+
const name = row.label !== "" && row.sentence !== "" && row.label !== row.sentence ? `${row.label}: ${row.sentence}` : row.sentence || row.label;
|
|
672
|
+
return row.status === "pending" ? `${name} (no result)` : name;
|
|
673
|
+
}
|
|
674
|
+
function fit(prefix, parts) {
|
|
675
|
+
let text2 = prefix + parts.join("; ");
|
|
676
|
+
for (let kept = parts.length - 1; text2.length > REASON_MAX && kept > 0; kept--) {
|
|
677
|
+
text2 = `${prefix}${parts.slice(0, kept).join("; ")}; and ${parts.length - kept} more`;
|
|
678
|
+
}
|
|
679
|
+
return text2.length > REASON_MAX ? `${text2.slice(0, REASON_MAX - 1)}\u2026` : text2;
|
|
680
|
+
}
|
|
681
|
+
function decideFromEvaluation(read) {
|
|
682
|
+
if (!read.ok) return { outcome: "incomplete", reason: fit("the item's status could not be read: ", [read.reason]) };
|
|
683
|
+
const evaluation = read.evaluation;
|
|
684
|
+
if (evaluation === void 0) return { outcome: "ok" };
|
|
685
|
+
if (evaluation === null) return { outcome: "incomplete", reason: "the item has no evidence evaluation yet" };
|
|
686
|
+
if (evaluation.open.length > 0) return { outcome: "incomplete", reason: fit("checks not passing: ", evaluation.open.map(rowText)) };
|
|
687
|
+
if (evaluation.verdict === "pass") return { outcome: "ok" };
|
|
688
|
+
if (evaluation.verdict === "waiting" && evaluation.waitingOn === "review") return { outcome: "ok" };
|
|
689
|
+
if (evaluation.verdict === "waiting" && evaluation.waitingOn === "links") {
|
|
690
|
+
return { outcome: "incomplete", reason: "the item's evidence was still waiting on session links to be confirmed" };
|
|
691
|
+
}
|
|
692
|
+
if (evaluation.verdict === "waiting" && evaluation.waitingOn === "claim") {
|
|
693
|
+
return { outcome: "incomplete", reason: "the item's required checks had no result when the build ended" };
|
|
694
|
+
}
|
|
695
|
+
return { outcome: "incomplete", reason: fit("the item's verdict is not passing: ", [evaluation.sentence || evaluation.verdict]) };
|
|
696
|
+
}
|
|
697
|
+
function waitMs(ms) {
|
|
698
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
699
|
+
}
|
|
700
|
+
async function settleVerdict(deps) {
|
|
701
|
+
const settleMs = deps.settleMs ?? VERDICT_SETTLE_MS;
|
|
702
|
+
const everyMs = deps.everyMs ?? VERDICT_READ_EVERY_MS;
|
|
703
|
+
const startedMs = deps.now().getTime();
|
|
704
|
+
for (; ; ) {
|
|
705
|
+
const decision = decideFromEvaluation(await deps.read());
|
|
706
|
+
if (decision.outcome === "ok") return decision;
|
|
707
|
+
if (deps.now().getTime() - startedMs + everyMs > settleMs) return decision;
|
|
708
|
+
await deps.sleep(everyMs);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/runner/job.ts
|
|
713
|
+
var LEASE_SECONDS = 900;
|
|
714
|
+
var RENEW_INTERVAL_SECONDS = 300;
|
|
715
|
+
var IDLE_CPU_USEC_PER_MINUTE_FLOOR = 2e6;
|
|
716
|
+
function jobEnv(config, item, itemKey, jobDirPath, baseEnv = process.env, branch, author) {
|
|
717
|
+
const secrets = new Set(headerVariables(config.build?.destinations));
|
|
718
|
+
const passthrough = {};
|
|
719
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
720
|
+
if (value === void 0) continue;
|
|
721
|
+
if (key.startsWith("OUTERLAYER_")) continue;
|
|
722
|
+
if (secrets.has(key)) continue;
|
|
723
|
+
passthrough[key] = value;
|
|
724
|
+
}
|
|
725
|
+
const path = cliFirstPath(passthrough.PATH);
|
|
726
|
+
return {
|
|
727
|
+
...passthrough,
|
|
728
|
+
...path === void 0 ? {} : { PATH: path },
|
|
729
|
+
OUTERLAYER_URL: config.url,
|
|
730
|
+
...itemKey === void 0 ? {} : { OUTERLAYER_API_KEY: itemKey },
|
|
731
|
+
OUTERLAYER_APP_ID: config.appId,
|
|
732
|
+
OUTERLAYER_WORK: String(item),
|
|
733
|
+
OUTERLAYER_JOB_DIR: jobDirPath,
|
|
734
|
+
// The branch the claim named. A hook that makes its own checkout puts it
|
|
735
|
+
// on this branch, and the build's commands push to it.
|
|
736
|
+
...branch === void 0 ? {} : { OUTERLAYER_BRANCH: branch },
|
|
737
|
+
// Git prefers these to any configuration, so a host's own git identity never authors a commit of the build.
|
|
738
|
+
...authorEnvironment(author)
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function cliFirstPath(currentPath) {
|
|
742
|
+
const entry = process.argv[1];
|
|
743
|
+
const cliDir = entry === void 0 || entry === "" ? void 0 : dirname(entry);
|
|
744
|
+
if (cliDir === void 0) return currentPath;
|
|
745
|
+
const parts = (currentPath ?? "").split(delimiter).filter((p) => p !== "");
|
|
746
|
+
if (parts[0] === cliDir) return currentPath;
|
|
747
|
+
return [cliDir, ...parts.filter((p) => p !== cliDir)].join(delimiter);
|
|
748
|
+
}
|
|
749
|
+
var HOOK_ENDED_EXIT_CODE = 124;
|
|
750
|
+
function runHook(hookPath, env, cwd, logPath, spawnFn = spawn, limits, args = []) {
|
|
751
|
+
return new Promise((resolve, reject) => {
|
|
752
|
+
let fd;
|
|
753
|
+
try {
|
|
754
|
+
fd = openSync(logPath, "a", RUNNER_FILE_MODE);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
let timer;
|
|
760
|
+
let endedBy;
|
|
761
|
+
const settle = () => {
|
|
762
|
+
if (timer) clearInterval(timer);
|
|
763
|
+
try {
|
|
764
|
+
closeSync(fd);
|
|
765
|
+
} catch {
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
const child = spawnFn(hookPath, args, { env, cwd, detached: true, stdio: ["ignore", fd, fd] });
|
|
769
|
+
if (limits !== void 0 && child.pid !== void 0) {
|
|
770
|
+
const pid = child.pid;
|
|
771
|
+
const readClock = limits.now ?? (() => /* @__PURE__ */ new Date());
|
|
772
|
+
let lastActivityAtMs = readClock().getTime();
|
|
773
|
+
let lastSeen = readLogState(logPath);
|
|
774
|
+
timer = setInterval(() => {
|
|
775
|
+
const nowMs = readClock().getTime();
|
|
776
|
+
const seen = readLogState(logPath);
|
|
777
|
+
if (seen.size !== lastSeen.size || seen.mtimeMs !== lastSeen.mtimeMs) {
|
|
778
|
+
lastSeen = seen;
|
|
779
|
+
lastActivityAtMs = nowMs;
|
|
780
|
+
}
|
|
781
|
+
const reason = nowMs >= limits.endBy ? "time limit" : nowMs - lastActivityAtMs >= limits.idleMs ? "idle" : void 0;
|
|
782
|
+
if (reason === void 0) return;
|
|
783
|
+
endedBy = reason;
|
|
784
|
+
if (timer) clearInterval(timer);
|
|
785
|
+
void stopProcessGroup(pid);
|
|
786
|
+
}, limits.pollMs ?? 1e3);
|
|
787
|
+
timer.unref?.();
|
|
788
|
+
}
|
|
789
|
+
child.on("error", (err) => {
|
|
790
|
+
settle();
|
|
791
|
+
reject(hookSpawnError(hookPath, err));
|
|
792
|
+
});
|
|
793
|
+
child.on("exit", (code, signal) => {
|
|
794
|
+
settle();
|
|
795
|
+
if (endedBy !== void 0) {
|
|
796
|
+
resolve({ code: HOOK_ENDED_EXIT_CODE, endedBy });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (signal !== null) {
|
|
800
|
+
resolve({ code: 1, signal });
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
resolve({ code: code ?? 1 });
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
function hookSpawnError(hookPath, err) {
|
|
808
|
+
const code = err?.code;
|
|
809
|
+
if (code === "ENOENT") {
|
|
810
|
+
return new Error(
|
|
811
|
+
`${hookPath}: no such executable. A hook is run directly, not through a shell, so the path must name an executable file with no arguments and no variables to expand.`
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
if (code === "EACCES") {
|
|
815
|
+
return new Error(`${hookPath}: not executable. Give the file the execute bit, or name one that has it.`);
|
|
816
|
+
}
|
|
817
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
818
|
+
}
|
|
819
|
+
function workdirProblem(workdir) {
|
|
820
|
+
let stats;
|
|
821
|
+
try {
|
|
822
|
+
stats = statSync(workdir);
|
|
823
|
+
} catch {
|
|
824
|
+
return "does not exist";
|
|
825
|
+
}
|
|
826
|
+
return stats.isDirectory() ? void 0 : "is not a directory";
|
|
827
|
+
}
|
|
828
|
+
function readProvisionOut(jobDirPath) {
|
|
829
|
+
const path = join(jobDirPath, "provision.out");
|
|
830
|
+
if (!existsSync(path)) return { ok: false };
|
|
831
|
+
let text2;
|
|
832
|
+
try {
|
|
833
|
+
text2 = readFileSync(path, "utf8");
|
|
834
|
+
} catch {
|
|
835
|
+
return { ok: false };
|
|
836
|
+
}
|
|
837
|
+
const kv = {};
|
|
838
|
+
for (const line of text2.split("\n")) {
|
|
839
|
+
const eq = line.indexOf("=");
|
|
840
|
+
if (eq === -1) continue;
|
|
841
|
+
kv[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
842
|
+
}
|
|
843
|
+
if (!kv.workdir) return { ok: false };
|
|
844
|
+
return {
|
|
845
|
+
ok: true,
|
|
846
|
+
workdir: kv.workdir,
|
|
847
|
+
...kv.cgroup ? { cgroup: kv.cgroup } : {},
|
|
848
|
+
...kv.isolation !== void 0 ? { isolation: kv.isolation } : {},
|
|
849
|
+
...kv.exec !== void 0 ? { exec: kv.exec } : {},
|
|
850
|
+
...kv.broker !== void 0 ? { broker: kv.broker } : {},
|
|
851
|
+
...kv.recipe !== void 0 ? { recipe: kv.recipe } : {}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
var EXIT_CODE_FILE = "exit-code";
|
|
855
|
+
function commandWithExitCodeTrailer(cmdline) {
|
|
856
|
+
return [
|
|
857
|
+
"__ol_record_status() {",
|
|
858
|
+
" __ol_status=$?",
|
|
859
|
+
' if [ -n "$OUTERLAYER_JOB_DIR" ]; then',
|
|
860
|
+
` echo "$__ol_status" > "$OUTERLAYER_JOB_DIR/${EXIT_CODE_FILE}.partial" 2>/dev/null &&`,
|
|
861
|
+
` mv -f "$OUTERLAYER_JOB_DIR/${EXIT_CODE_FILE}.partial" "$OUTERLAYER_JOB_DIR/${EXIT_CODE_FILE}" 2>/dev/null`,
|
|
862
|
+
" fi",
|
|
863
|
+
' return "$__ol_status"',
|
|
864
|
+
"}",
|
|
865
|
+
"trap __ol_record_status EXIT",
|
|
866
|
+
cmdline
|
|
867
|
+
].join("\n");
|
|
868
|
+
}
|
|
869
|
+
function readExitCode(jobDirPath) {
|
|
870
|
+
let text2;
|
|
871
|
+
try {
|
|
872
|
+
text2 = readFileSync(join(jobDirPath, EXIT_CODE_FILE), "utf8");
|
|
873
|
+
} catch {
|
|
874
|
+
return void 0;
|
|
875
|
+
}
|
|
876
|
+
const trimmed = text2.trim();
|
|
877
|
+
return /^\d+$/.test(trimmed) ? Number(trimmed) : void 0;
|
|
878
|
+
}
|
|
879
|
+
function outcomeFromExitCode(jobDirPath) {
|
|
880
|
+
const code = readExitCode(jobDirPath);
|
|
881
|
+
if (code === void 0) return void 0;
|
|
882
|
+
return { outcome: code === 0 ? "ok" : "failed", code };
|
|
883
|
+
}
|
|
884
|
+
var REASON_FILE = "reason";
|
|
885
|
+
var REASON_MAX2 = 200;
|
|
886
|
+
function cutReasonLine(line, max = REASON_MAX2) {
|
|
887
|
+
const cut = line.slice(0, max);
|
|
888
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
889
|
+
return last >= 55296 && last <= 56319 ? cut.slice(0, -1) : cut;
|
|
890
|
+
}
|
|
891
|
+
function scrubList(value) {
|
|
892
|
+
return Array.isArray(value) ? value : [];
|
|
893
|
+
}
|
|
894
|
+
function mergeScrubConfig(a, b) {
|
|
895
|
+
if (!a && !b) return void 0;
|
|
896
|
+
return {
|
|
897
|
+
literals: [...scrubList(a?.literals), ...scrubList(b?.literals)],
|
|
898
|
+
patterns: [...scrubList(a?.patterns), ...scrubList(b?.patterns)]
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function releaseScrub(config, home) {
|
|
902
|
+
const merged = mergeScrubConfig(config.scrub, readCloudConfig(home).scrub);
|
|
903
|
+
const secrets = [...headerVariables(config.build?.destinations), ...CLAUDE_CREDENTIAL_VARIABLES].map((name) => process.env[name] ?? "");
|
|
904
|
+
return { literals: [...scrubList(merged?.literals), ...secrets], patterns: scrubList(merged?.patterns) };
|
|
905
|
+
}
|
|
906
|
+
function stripUnsendableReasonChars(text2) {
|
|
907
|
+
return text2.replace(/\u0000/g, "").replace(/\p{Cs}/gu, "");
|
|
908
|
+
}
|
|
909
|
+
function readReasonLine(jobDirPath, config, home = homeFromEnv()) {
|
|
910
|
+
let raw;
|
|
911
|
+
try {
|
|
912
|
+
raw = readFileSync(join(jobDirPath, REASON_FILE), "utf8");
|
|
913
|
+
} catch {
|
|
914
|
+
return void 0;
|
|
915
|
+
}
|
|
916
|
+
return scrubReason(raw.split(/\r\n|\r|\n/)[0] ?? "", config, home);
|
|
917
|
+
}
|
|
918
|
+
function scrubReason(line, config, home = homeFromEnv()) {
|
|
919
|
+
installScrubContext(home, releaseScrub(config, home));
|
|
920
|
+
const oneLine = scrubText(stripUnsendableReasonChars(line)).replace(/[\r\n]+/g, " ");
|
|
921
|
+
const trimmed = cutReasonLine(oneLine).trim();
|
|
922
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
923
|
+
}
|
|
924
|
+
function scrubTunnelRecord(record, config) {
|
|
925
|
+
if (record === void 0) return void 0;
|
|
926
|
+
const home = homeFromEnv();
|
|
927
|
+
installScrubContext(home, releaseScrub(config, home));
|
|
928
|
+
return {
|
|
929
|
+
hosts: record.hosts.map((host) => scrubText(host)),
|
|
930
|
+
hostsMore: record.hostsMore,
|
|
931
|
+
refusalsMore: record.refusalsMore,
|
|
932
|
+
refusals: record.refusals.map((r) => ({ ...r, host: scrubText(r.host) })),
|
|
933
|
+
destinations: (record.destinations ?? []).map((name) => scrubText(name))
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
function runCommand(cmdline, env, cwd, logPath, spawnFn = spawn) {
|
|
937
|
+
const fd = openSync(logPath, "a", RUNNER_FILE_MODE);
|
|
938
|
+
const child = spawnFn("/bin/sh", ["-c", commandWithExitCodeTrailer(cmdline)], {
|
|
939
|
+
env,
|
|
940
|
+
cwd,
|
|
941
|
+
detached: true,
|
|
942
|
+
stdio: ["ignore", fd, fd]
|
|
943
|
+
});
|
|
944
|
+
const pid = child.pid;
|
|
945
|
+
if (pid === void 0) {
|
|
946
|
+
try {
|
|
947
|
+
closeSync(fd);
|
|
948
|
+
} catch {
|
|
949
|
+
}
|
|
950
|
+
throw new Error("command failed to spawn \u2014 no pid");
|
|
951
|
+
}
|
|
952
|
+
const onExit = new Promise((resolve) => {
|
|
953
|
+
const close = () => {
|
|
954
|
+
try {
|
|
955
|
+
closeSync(fd);
|
|
956
|
+
} catch {
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
child.on("exit", (code, signal) => {
|
|
960
|
+
close();
|
|
961
|
+
resolve({ code, signal });
|
|
962
|
+
});
|
|
963
|
+
child.on("error", (err) => {
|
|
964
|
+
close();
|
|
965
|
+
resolve({ code: null, signal: null, error: err instanceof Error ? err : new Error(String(err)) });
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
return { pid, start: startTimeOf(pid), onExit };
|
|
969
|
+
}
|
|
970
|
+
function isGroupAlive(pid) {
|
|
971
|
+
try {
|
|
972
|
+
process.kill(-pid, 0);
|
|
973
|
+
return true;
|
|
974
|
+
} catch {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
async function stopProcessGroup(pid, opts = {}) {
|
|
979
|
+
const graceMs = opts.graceMs ?? 15e3;
|
|
980
|
+
const pollMs = opts.pollMs ?? 200;
|
|
981
|
+
try {
|
|
982
|
+
process.kill(-pid, "SIGTERM");
|
|
983
|
+
} catch {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const deadline = Date.now() + graceMs;
|
|
987
|
+
while (isGroupAlive(pid) && Date.now() < deadline) {
|
|
988
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
989
|
+
}
|
|
990
|
+
if (isGroupAlive(pid)) {
|
|
991
|
+
try {
|
|
992
|
+
process.kill(-pid, "SIGKILL");
|
|
993
|
+
} catch {
|
|
994
|
+
}
|
|
995
|
+
const killDeadline = Date.now() + 2e3;
|
|
996
|
+
while (isGroupAlive(pid) && Date.now() < killDeadline) {
|
|
997
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function isTimedOut(startedAtMs, nowMs, timeLimitMinutes) {
|
|
1002
|
+
return nowMs - startedAtMs > timeLimitMinutes * 6e4;
|
|
1003
|
+
}
|
|
1004
|
+
function needsRenew(lastRenewAtMs, nowMs, intervalSeconds = RENEW_INTERVAL_SECONDS) {
|
|
1005
|
+
return nowMs - lastRenewAtMs >= intervalSeconds * 1e3;
|
|
1006
|
+
}
|
|
1007
|
+
function isIdle(logMtimeMs, nowMs, idleLimitMinutes, cpuUsecPerMinuteOverWindow) {
|
|
1008
|
+
const windowMs = nowMs - logMtimeMs;
|
|
1009
|
+
if (windowMs <= idleLimitMinutes * 6e4) return false;
|
|
1010
|
+
if (cpuUsecPerMinuteOverWindow === null) return true;
|
|
1011
|
+
return cpuUsecPerMinuteOverWindow < IDLE_CPU_USEC_PER_MINUTE_FLOOR;
|
|
1012
|
+
}
|
|
1013
|
+
function readCpuUsec(cgroupPath) {
|
|
1014
|
+
try {
|
|
1015
|
+
const text2 = readFileSync(join(cgroupPath, "cpu.stat"), "utf8");
|
|
1016
|
+
const match = /^usage_usec (\d+)$/m.exec(text2);
|
|
1017
|
+
return match ? Number(match[1]) : null;
|
|
1018
|
+
} catch {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function renewLease(config, item, fetchImpl) {
|
|
1023
|
+
try {
|
|
1024
|
+
await postJson(
|
|
1025
|
+
config.url,
|
|
1026
|
+
`/v1/work-items/${item}/claim/renew`,
|
|
1027
|
+
config.apiKey,
|
|
1028
|
+
config.appId,
|
|
1029
|
+
{ host: config.host, seconds: LEASE_SECONDS },
|
|
1030
|
+
fetchImpl,
|
|
1031
|
+
void 0,
|
|
1032
|
+
await signerFor(config.configPath)
|
|
1033
|
+
);
|
|
1034
|
+
return "renewed";
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
if (err instanceof WorkCommandError && isSignatureRefusal(err.code)) return "retry_later";
|
|
1037
|
+
if (err instanceof WorkCommandError && !err.retryable) return "lease_lost";
|
|
1038
|
+
return "retry_later";
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
var ALREADY_RELEASED = "already released";
|
|
1042
|
+
async function releaseLease(config, item, outcome, detail, fetchImpl) {
|
|
1043
|
+
const send = async (environment2, sent2) => postJson(
|
|
1044
|
+
config.url,
|
|
1045
|
+
`/v1/work-items/${item}/claim/release`,
|
|
1046
|
+
config.apiKey,
|
|
1047
|
+
config.appId,
|
|
1048
|
+
{ host: config.host, outcome: sent2, stage: detail.stage, exitCode: detail.exitCode, reason: detail.reason, environment: environment2 },
|
|
1049
|
+
fetchImpl,
|
|
1050
|
+
void 0,
|
|
1051
|
+
await signerFor(config.configPath)
|
|
1052
|
+
);
|
|
1053
|
+
let environment = detail.environment;
|
|
1054
|
+
let sent = outcome;
|
|
1055
|
+
let data;
|
|
1056
|
+
for (; ; ) {
|
|
1057
|
+
try {
|
|
1058
|
+
({ data } = await send(environment, sent));
|
|
1059
|
+
break;
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
if (!(err instanceof WorkCommandError) || err.status !== 400 || err.code !== "invalid_request_body") throw err;
|
|
1062
|
+
const namesOutcome = /\boutcome\b/.test(err.message);
|
|
1063
|
+
if (sent === "incomplete" && namesOutcome) {
|
|
1064
|
+
sent = "ok";
|
|
1065
|
+
} else if (environment?.commitIdentity !== void 0) {
|
|
1066
|
+
const { commitIdentity: _omitted, ...withoutIdentity } = environment;
|
|
1067
|
+
environment = withoutIdentity;
|
|
1068
|
+
} else if (sent === "incomplete") {
|
|
1069
|
+
sent = "ok";
|
|
1070
|
+
} else {
|
|
1071
|
+
throw err;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return {
|
|
1076
|
+
releasedAt: typeof data.releasedAt === "string" ? data.releasedAt : void 0,
|
|
1077
|
+
outcome: data.outcome === void 0 ? void 0 : data.outcome
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
var RELEASE_SKEW_MS = 6e4;
|
|
1081
|
+
function wasAlreadyReleased(result, sentOutcome, sentAtMs) {
|
|
1082
|
+
const stored = result.outcome ?? void 0;
|
|
1083
|
+
if (stored !== void 0 && stored !== sentOutcome) return true;
|
|
1084
|
+
if (result.releasedAt === void 0) return false;
|
|
1085
|
+
const releasedAtMs = Date.parse(result.releasedAt);
|
|
1086
|
+
return Number.isFinite(releasedAtMs) && releasedAtMs < sentAtMs - RELEASE_SKEW_MS;
|
|
1087
|
+
}
|
|
1088
|
+
function runSync(config, item, itemKey, workdir, jobDirPath, spawnFn = spawn, branch, author) {
|
|
1089
|
+
return new Promise((resolve) => {
|
|
1090
|
+
const env = jobEnv(config, item, itemKey, jobDirPath, process.env, branch, author);
|
|
1091
|
+
let child;
|
|
1092
|
+
try {
|
|
1093
|
+
child = spawnFn(process.execPath, [process.argv[1] ?? "outerlayer", "sync"], {
|
|
1094
|
+
cwd: workdir,
|
|
1095
|
+
env,
|
|
1096
|
+
stdio: "ignore"
|
|
1097
|
+
});
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
resolve({ ok: false, reason: describeCallFailure(err) });
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
child.on("exit", (code) => {
|
|
1103
|
+
resolve(code === 0 || code === null ? { ok: true } : { ok: false, reason: `exit ${code}` });
|
|
1104
|
+
});
|
|
1105
|
+
child.on("error", (err) => {
|
|
1106
|
+
resolve({ ok: false, reason: describeCallFailure(err) });
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
async function runSyncViaExec(config, job, jobDirPath, spawnFn = spawn) {
|
|
1111
|
+
const build = job.build;
|
|
1112
|
+
const env = hostCommandEnvironment(jobEnv(config, job.item, job.itemKey, jobDirPath, process.env, job.branch, job.commitIdentity?.author), build, config, job.commitIdentity?.hooksPath);
|
|
1113
|
+
try {
|
|
1114
|
+
const result = await runHook(
|
|
1115
|
+
build.exec,
|
|
1116
|
+
env,
|
|
1117
|
+
jobDirPath,
|
|
1118
|
+
join(jobDirPath, "sync.log"),
|
|
1119
|
+
spawnFn,
|
|
1120
|
+
void 0,
|
|
1121
|
+
execArgv(job.workdir, ["outerlayer", "sync"])
|
|
1122
|
+
);
|
|
1123
|
+
return result.code === 0 ? { ok: true } : { ok: false, reason: `exit ${result.code}` };
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
return { ok: false, reason: describeCallFailure(err) };
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
var MAX_NAMED_SPOOLED = 20;
|
|
1129
|
+
function spooledArtifactIds(home) {
|
|
1130
|
+
try {
|
|
1131
|
+
return planArtifactUpload({ home }).candidates.map((pending) => pending.record.artifactId);
|
|
1132
|
+
} catch {
|
|
1133
|
+
return [];
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function leaseExpiry(job, fallback) {
|
|
1137
|
+
const base = Date.parse(job.lastRenewAt ?? job.startedAt);
|
|
1138
|
+
return new Date((Number.isFinite(base) ? base : fallback.getTime()) + LEASE_SECONDS * 1e3);
|
|
1139
|
+
}
|
|
1140
|
+
function setStage(job, jobDirPath, stage) {
|
|
1141
|
+
job.stage = stage;
|
|
1142
|
+
writeJobFile(join(jobDirPath, "job.json"), job);
|
|
1143
|
+
}
|
|
1144
|
+
function readTunnelRecord(path) {
|
|
1145
|
+
try {
|
|
1146
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1147
|
+
} catch {
|
|
1148
|
+
return void 0;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
async function finishJob(job, jobDirPath, outcome, config, deps, ending) {
|
|
1152
|
+
const spawnFn = deps.spawnFn ?? spawn;
|
|
1153
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
1154
|
+
const log = deps.log ?? createRunnerLog(now);
|
|
1155
|
+
const jobFilePath = join(jobDirPath, "job.json");
|
|
1156
|
+
const logPath = join(jobDirPath, "command.log");
|
|
1157
|
+
const tag = `#${job.item}`;
|
|
1158
|
+
const build = job.container === void 0 ? void 0 : { state: job.container, runtime: deps.container ?? createContainerRuntime() };
|
|
1159
|
+
const vmBuild = job.microvm === void 0 ? void 0 : { state: job.microvm, runtime: deps.container ?? createContainerRuntime() };
|
|
1160
|
+
if (build !== void 0 && job.pid !== void 0 && isGroupAlive(job.pid)) {
|
|
1161
|
+
await stopContainer(build.runtime, build.state).catch((err) => {
|
|
1162
|
+
log.error(`${tag} stopping the container failed: ${describeCallFailure(err)} \xB7 continuing`);
|
|
1163
|
+
});
|
|
1164
|
+
writeJobFile(jobFilePath, job);
|
|
1165
|
+
}
|
|
1166
|
+
if (job.pid !== void 0 && isGroupAlive(job.pid)) {
|
|
1167
|
+
await stopProcessGroup(job.pid);
|
|
1168
|
+
}
|
|
1169
|
+
if (outcome !== void 0 && job.endStage === void 0) {
|
|
1170
|
+
const TEARDOWN_STAGES = ["sync", "cleanup", "report"];
|
|
1171
|
+
const staleTeardownStage = job.stage !== void 0 && TEARDOWN_STAGES.includes(job.stage);
|
|
1172
|
+
job.endStage = ending?.stage ?? (staleTeardownStage ? job.exitCode !== void 0 ? "command" : void 0 : job.stage);
|
|
1173
|
+
job.endExitCode = ending?.exitCode ?? (outcome === "ok" || outcome === "failed" ? job.exitCode : void 0);
|
|
1174
|
+
writeJobFile(jobFilePath, job);
|
|
1175
|
+
}
|
|
1176
|
+
if (outcome !== void 0 && ending?.reason !== void 0) {
|
|
1177
|
+
job.reason = scrubReason(ending.reason, config);
|
|
1178
|
+
writeJobFile(jobFilePath, job);
|
|
1179
|
+
}
|
|
1180
|
+
const hookLimits = () => hookLimitsFor(config, now().getTime(), deps.now);
|
|
1181
|
+
let tunnelRecord;
|
|
1182
|
+
if (!job.cleanupRan) {
|
|
1183
|
+
if (job.workdir) {
|
|
1184
|
+
setStage(job, jobDirPath, "sync");
|
|
1185
|
+
if (job.container === void 0 && job.microvm === void 0 && job.build?.exec === void 0 && workdirProblem(job.workdir) !== void 0) {
|
|
1186
|
+
log.line(`${tag} sync skipped: workdir ${job.workdir} is gone`);
|
|
1187
|
+
} else if (job.itemKey === void 0) {
|
|
1188
|
+
log.line(`${tag} sync skipped: the job file holds no item key`);
|
|
1189
|
+
} else {
|
|
1190
|
+
log.line(`${tag} sync started`);
|
|
1191
|
+
const startedMs = now().getTime();
|
|
1192
|
+
const sync = build !== void 0 ? await syncInContainer({ config, runtime: build.runtime, job, jobDir: jobDirPath }) : vmBuild !== void 0 ? await syncInMicroVm({ config, runtime: vmBuild.runtime, job, jobDir: jobDirPath }) : job.build?.exec !== void 0 ? await runSyncViaExec(config, job, jobDirPath, spawnFn) : await runSync(config, job.item, job.itemKey, job.workdir, jobDirPath, spawnFn, job.branch, job.commitIdentity?.author);
|
|
1193
|
+
const took = formatDuration(now().getTime() - startedMs);
|
|
1194
|
+
if (sync.ok) log.line(`${tag} sync ok (${took})`);
|
|
1195
|
+
else log.error(`${tag} sync failed: ${sync.reason ?? "no reason given"} \xB7 continuing`);
|
|
1196
|
+
const left = spooledArtifactIds(deps.home ?? homeFromEnv());
|
|
1197
|
+
if (left.length > 0) {
|
|
1198
|
+
job.spooledArtifacts = left.slice(0, MAX_NAMED_SPOOLED);
|
|
1199
|
+
const named = job.spooledArtifacts.join(", ");
|
|
1200
|
+
log.error(
|
|
1201
|
+
`${tag} ${left.length} spooled artifact(s) not uploaded: ${named}${left.length > MAX_NAMED_SPOOLED ? `, and ${left.length - MAX_NAMED_SPOOLED} more` : ""}`
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
setStage(job, jobDirPath, "cleanup");
|
|
1207
|
+
try {
|
|
1208
|
+
rmSync(join(jobDirPath, REASON_FILE));
|
|
1209
|
+
} catch {
|
|
1210
|
+
}
|
|
1211
|
+
log.line(`${tag} cleanup started`);
|
|
1212
|
+
const cleanupStartedMs = now().getTime();
|
|
1213
|
+
const hostMade = job.build !== void 0 && job.build.workDir === void 0;
|
|
1214
|
+
if (vmBuild !== void 0) {
|
|
1215
|
+
let cleanedUp = true;
|
|
1216
|
+
tunnelRecord = vmBuild.runtime.tunnels.get(vmBuild.state.buildId)?.record();
|
|
1217
|
+
try {
|
|
1218
|
+
await cleanupMicroVm(vmBuild.runtime, vmBuild.state, jobDirPath);
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
cleanedUp = false;
|
|
1221
|
+
log.error(`${tag} cleanup failed: ${describeCallFailure(err)} \xB7 continuing`);
|
|
1222
|
+
}
|
|
1223
|
+
if (cleanedUp) log.line(`${tag} cleanup ok (${formatDuration(now().getTime() - cleanupStartedMs)})`);
|
|
1224
|
+
} else if (build !== void 0 || !hostMade && isBuiltinContainer(config.hooks.cleanup)) {
|
|
1225
|
+
let cleanedUp = true;
|
|
1226
|
+
if (build !== void 0) {
|
|
1227
|
+
tunnelRecord = build.runtime.tunnels.get(build.state.buildId)?.record();
|
|
1228
|
+
try {
|
|
1229
|
+
await cleanupContainer(build.runtime, build.state, jobDirPath);
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
cleanedUp = false;
|
|
1232
|
+
log.error(`${tag} cleanup failed: ${describeCallFailure(err)} \xB7 continuing`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (cleanedUp) log.line(`${tag} cleanup ok (${formatDuration(now().getTime() - cleanupStartedMs)})`);
|
|
1236
|
+
} else if (!hostMade && (job.build !== void 0 || isBuiltinProcess(config.hooks.cleanup))) {
|
|
1237
|
+
let cleanedUp = true;
|
|
1238
|
+
if (job.build !== void 0) {
|
|
1239
|
+
const runtime = deps.container ?? createContainerRuntime();
|
|
1240
|
+
tunnelRecord = runtime.tunnels.get(job.build.buildId)?.record();
|
|
1241
|
+
try {
|
|
1242
|
+
await cleanupProcess(runtime, job.build);
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
cleanedUp = false;
|
|
1245
|
+
log.error(`${tag} cleanup failed: ${describeCallFailure(err)} \xB7 continuing`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
if (cleanedUp) log.line(`${tag} cleanup ok (${formatDuration(now().getTime() - cleanupStartedMs)})`);
|
|
1249
|
+
} else {
|
|
1250
|
+
if (!isBuiltinContainer(config.hooks.cleanup) && !isBuiltinProcess(config.hooks.cleanup) && !isBuiltinVm(config.hooks.cleanup)) {
|
|
1251
|
+
const cleanupEnv = {
|
|
1252
|
+
...jobEnv(config, job.item, job.itemKey, jobDirPath, process.env, job.branch, job.commitIdentity?.author),
|
|
1253
|
+
...job.build === void 0 ? {} : { OUTERLAYER_BUILD_DIR: job.build.buildDir },
|
|
1254
|
+
OUTERLAYER_OUTCOME: outcome ?? "",
|
|
1255
|
+
OUTERLAYER_LOG: logPath
|
|
1256
|
+
};
|
|
1257
|
+
const cleanup = await runHook(
|
|
1258
|
+
config.hooks.cleanup,
|
|
1259
|
+
cleanupEnv,
|
|
1260
|
+
jobDirPath,
|
|
1261
|
+
join(jobDirPath, "cleanup.log"),
|
|
1262
|
+
spawnFn,
|
|
1263
|
+
hookLimits()
|
|
1264
|
+
).catch((err) => {
|
|
1265
|
+
log.error(`${tag} cleanup failed: ${describeCallFailure(err)} \xB7 continuing`);
|
|
1266
|
+
return { code: -1 };
|
|
1267
|
+
});
|
|
1268
|
+
const cleanupTook = formatDuration(now().getTime() - cleanupStartedMs);
|
|
1269
|
+
if (cleanup.endedBy !== void 0) {
|
|
1270
|
+
log.error(`${tag} ${describeHookEnd("cleanup", cleanup.endedBy, config)} \xB7 continuing`);
|
|
1271
|
+
} else if (cleanup.code > 0) {
|
|
1272
|
+
log.error(`${tag} cleanup exited ${cleanup.code} (${cleanupTook}) \xB7 continuing`);
|
|
1273
|
+
} else if (cleanup.code === 0) {
|
|
1274
|
+
log.line(`${tag} cleanup ok (${cleanupTook})`);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
if (job.build !== void 0) {
|
|
1278
|
+
const runtime = deps.container ?? createContainerRuntime();
|
|
1279
|
+
tunnelRecord = runtime.tunnels.get(job.build.buildId)?.record();
|
|
1280
|
+
try {
|
|
1281
|
+
await closeHostBuild(runtime, job.build);
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
log.error(`${tag} closing the build's tunnel failed: ${describeCallFailure(err)} \xB7 continuing`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
job.cleanupRan = true;
|
|
1288
|
+
writeJobFile(jobFilePath, job);
|
|
1289
|
+
}
|
|
1290
|
+
let verdictReason;
|
|
1291
|
+
if (outcome === "ok") {
|
|
1292
|
+
const decision = await settleVerdict({
|
|
1293
|
+
read: () => readItemEvaluation(config, job.item, deps.fetchImpl),
|
|
1294
|
+
now,
|
|
1295
|
+
sleep: deps.sleep ?? waitMs
|
|
1296
|
+
});
|
|
1297
|
+
if (decision.outcome === "incomplete") {
|
|
1298
|
+
outcome = "incomplete";
|
|
1299
|
+
verdictReason = scrubReason(decision.reason, config);
|
|
1300
|
+
log.error(`${tag} the command exited 0 but the item is not done: ${verdictReason}`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (config.hooks.report) {
|
|
1304
|
+
setStage(job, jobDirPath, "report");
|
|
1305
|
+
log.line(`${tag} report started \xB7 phase end`);
|
|
1306
|
+
const reportStartedMs = now().getTime();
|
|
1307
|
+
const reportEnv = {
|
|
1308
|
+
...jobEnv(config, job.item, job.itemKey, jobDirPath, process.env, job.branch, job.commitIdentity?.author),
|
|
1309
|
+
OUTERLAYER_PHASE: "end",
|
|
1310
|
+
OUTERLAYER_OUTCOME: outcome ?? "",
|
|
1311
|
+
OUTERLAYER_LOG: logPath
|
|
1312
|
+
};
|
|
1313
|
+
const report = await runHook(
|
|
1314
|
+
config.hooks.report,
|
|
1315
|
+
reportEnv,
|
|
1316
|
+
jobDirPath,
|
|
1317
|
+
join(jobDirPath, "report.log"),
|
|
1318
|
+
spawnFn,
|
|
1319
|
+
hookLimits()
|
|
1320
|
+
).catch((err) => {
|
|
1321
|
+
log.error(`${tag} report failed: ${describeCallFailure(err)} \xB7 continuing`);
|
|
1322
|
+
return { code: -1 };
|
|
1323
|
+
});
|
|
1324
|
+
const reportTook = formatDuration(now().getTime() - reportStartedMs);
|
|
1325
|
+
if (report.endedBy !== void 0) {
|
|
1326
|
+
log.error(`${tag} ${describeHookEnd("report", report.endedBy, config)} \xB7 continuing`);
|
|
1327
|
+
} else if (report.code === 0) {
|
|
1328
|
+
log.line(`${tag} report ok (${reportTook})`);
|
|
1329
|
+
} else if (report.code > 0) {
|
|
1330
|
+
log.error(`${tag} report exited ${report.code} (${reportTook}) \xB7 continuing`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
const isolated = job.container !== void 0 || job.microvm !== void 0;
|
|
1334
|
+
const held = deps.container?.credentials.get(jobDirPath);
|
|
1335
|
+
if (held !== void 0) {
|
|
1336
|
+
await held.revokeAll();
|
|
1337
|
+
deps.container.credentials.delete(jobDirPath);
|
|
1338
|
+
}
|
|
1339
|
+
if (outcome !== void 0) {
|
|
1340
|
+
const fromHook = readReasonLine(jobDirPath, config);
|
|
1341
|
+
if (verdictReason !== void 0) {
|
|
1342
|
+
job.reason = verdictReason;
|
|
1343
|
+
} else if (ending?.reason === void 0) {
|
|
1344
|
+
job.reason = (outcome === "ok" ? void 0 : held?.reason) ?? (isolated ? fromHook ?? job.reason : fromHook);
|
|
1345
|
+
}
|
|
1346
|
+
const cliVersion = deps.cliVersion ?? resolveVersion();
|
|
1347
|
+
const base = job.environment ?? (job.microvm !== void 0 ? { isolation: "vm", cliVersion } : job.container === void 0 ? sharedUserEnvironment(cliVersion) : { isolation: "container", cliVersion });
|
|
1348
|
+
if (isolated || job.build !== void 0) {
|
|
1349
|
+
tunnelRecord ??= readTunnelRecord(join(jobDirPath, "tunnel.json"));
|
|
1350
|
+
job.environment = withTunnelRecord(base, scrubTunnelRecord(tunnelRecord, config), !isolated);
|
|
1351
|
+
} else {
|
|
1352
|
+
job.environment = base;
|
|
1353
|
+
}
|
|
1354
|
+
job.environment = { ...job.environment, commitIdentity: commitIdentityRecord(job.commitIdentity) };
|
|
1355
|
+
writeJobFile(jobFilePath, job);
|
|
1356
|
+
}
|
|
1357
|
+
const sentAtMs = now().getTime();
|
|
1358
|
+
const detail = outcome === void 0 ? {} : { stage: job.endStage, exitCode: job.endExitCode, reason: job.reason, environment: job.environment };
|
|
1359
|
+
const release = await retrySend(() => releaseLease(config, job.item, outcome, detail, deps.fetchImpl), {
|
|
1360
|
+
now: deps.now,
|
|
1361
|
+
sleep: deps.sleep
|
|
1362
|
+
});
|
|
1363
|
+
const alreadyReleased = release.ok && wasAlreadyReleased(release.value, outcome, sentAtMs);
|
|
1364
|
+
const endedAt = now();
|
|
1365
|
+
job.outcome = outcome;
|
|
1366
|
+
job.endedAt = endedAt.toISOString();
|
|
1367
|
+
job.releaseFailed = release.ok ? alreadyReleased ? ALREADY_RELEASED : void 0 : release.reason;
|
|
1368
|
+
writeJobFile(jobFilePath, job);
|
|
1369
|
+
const total = formatDuration(endedAt.getTime() - new Date(job.startedAt).getTime());
|
|
1370
|
+
if (alreadyReleased) {
|
|
1371
|
+
log.line(
|
|
1372
|
+
`${tag} lease was already released (outcome ${release.ok ? release.value.outcome ?? "none" : "unknown"}) \xB7 this attempt ended as ${outcome ?? "not attempted"}`
|
|
1373
|
+
);
|
|
1374
|
+
} else if (release.ok) {
|
|
1375
|
+
log.line(
|
|
1376
|
+
outcome === void 0 ? `${tag} released \xB7 not attempted (provision exit 75) \xB7 ${total}` : `${tag} released \xB7 outcome ${outcome} \xB7 ${total}`
|
|
1377
|
+
);
|
|
1378
|
+
} else {
|
|
1379
|
+
log.error(
|
|
1380
|
+
`${tag} release failed after ${release.attempts} attempt(s): ${release.reason}; the lease expires at ${formatClock(leaseExpiry(job, endedAt), endedAt)}`
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
return job;
|
|
1384
|
+
}
|
|
1385
|
+
function readLogState(logPath) {
|
|
1386
|
+
try {
|
|
1387
|
+
const stats = statSync(logPath);
|
|
1388
|
+
return { size: stats.size, mtimeMs: stats.mtimeMs };
|
|
1389
|
+
} catch {
|
|
1390
|
+
return { size: -1, mtimeMs: -1 };
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
function describeHookEnd(stage, endedBy, config) {
|
|
1394
|
+
return endedBy === "idle" ? `${stage} idle for ${config.idleLimitMinutes}m, ended` : `${stage} past the ${config.timeLimitMinutes}m time limit, ended`;
|
|
1395
|
+
}
|
|
1396
|
+
function hookLimitsFor(config, fromMs, now) {
|
|
1397
|
+
return {
|
|
1398
|
+
idleMs: config.idleLimitMinutes * 6e4,
|
|
1399
|
+
endBy: fromMs + config.timeLimitMinutes * 6e4,
|
|
1400
|
+
now
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
async function retryFailedReleases(jobsDirPath, config, deps) {
|
|
1404
|
+
if (!existsSync(jobsDirPath)) return [];
|
|
1405
|
+
const results = [];
|
|
1406
|
+
for (const entry of readdirSync(jobsDirPath, { withFileTypes: true })) {
|
|
1407
|
+
if (!entry.isDirectory()) continue;
|
|
1408
|
+
const dirPath = join(jobsDirPath, entry.name);
|
|
1409
|
+
const jobFilePath = join(dirPath, "job.json");
|
|
1410
|
+
const job = readJobFile(jobFilePath);
|
|
1411
|
+
if (!job || job.releaseFailed === void 0 || job.releaseFailed === ALREADY_RELEASED || isUnfinished(job)) {
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
const detail = job.outcome === void 0 ? {} : { stage: job.endStage, exitCode: job.endExitCode, reason: job.reason, environment: job.environment };
|
|
1415
|
+
const release = await retrySend(() => releaseLease(config, job.item, job.outcome, detail, deps.fetchImpl), {
|
|
1416
|
+
now: deps.now,
|
|
1417
|
+
sleep: deps.sleep
|
|
1418
|
+
});
|
|
1419
|
+
if (release.ok || !release.retryable) {
|
|
1420
|
+
job.releaseFailed = void 0;
|
|
1421
|
+
writeJobFile(jobFilePath, job);
|
|
1422
|
+
} else {
|
|
1423
|
+
job.releaseFailed = release.reason;
|
|
1424
|
+
writeJobFile(jobFilePath, job);
|
|
1425
|
+
}
|
|
1426
|
+
results.push({ job, released: release.ok });
|
|
1427
|
+
}
|
|
1428
|
+
return results;
|
|
1429
|
+
}
|
|
1430
|
+
function isUnfinished(job) {
|
|
1431
|
+
return job.outcome === void 0 && job.endedAt === void 0;
|
|
1432
|
+
}
|
|
1433
|
+
function unfinishedJobs(jobsDirPath) {
|
|
1434
|
+
if (!existsSync(jobsDirPath)) return [];
|
|
1435
|
+
const found = [];
|
|
1436
|
+
for (const entry of readdirSync(jobsDirPath, { withFileTypes: true })) {
|
|
1437
|
+
if (!entry.isDirectory()) continue;
|
|
1438
|
+
const dirPath = join(jobsDirPath, entry.name);
|
|
1439
|
+
const job = readJobFile(join(dirPath, "job.json"));
|
|
1440
|
+
if (job && isUnfinished(job)) found.push({ dirPath, job });
|
|
1441
|
+
}
|
|
1442
|
+
return found;
|
|
1443
|
+
}
|
|
1444
|
+
function listAdoptableJobs(jobsDirPath) {
|
|
1445
|
+
return unfinishedJobs(jobsDirPath).filter(
|
|
1446
|
+
({ job }) => job.microvm === void 0 && job.pid !== void 0 && job.start !== void 0 && isSameProcess(job.pid, job.start)
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
async function recoverLostJobs(jobsDirPath, config, deps) {
|
|
1450
|
+
const recovered = [];
|
|
1451
|
+
for (const { dirPath, job } of unfinishedJobs(jobsDirPath)) {
|
|
1452
|
+
const alive = job.pid !== void 0 && job.start !== void 0 && isSameProcess(job.pid, job.start);
|
|
1453
|
+
if (alive) continue;
|
|
1454
|
+
const recorded = outcomeFromExitCode(dirPath);
|
|
1455
|
+
if (recorded !== void 0) job.exitCode = recorded.code;
|
|
1456
|
+
recovered.push(await finishJob(job, dirPath, recorded?.outcome ?? "interrupted", config, deps));
|
|
1457
|
+
}
|
|
1458
|
+
return recovered;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
export { LEASE_SECONDS, RENEW_INTERVAL_SECONDS, deliverCommitHooks, describeHookEnd, execArgv, execCommandLine, finishJob, hookLimitsFor, hostCommandEnvironment, hostEnvironment, isGroupAlive, isIdle, isTimedOut, isUnfinished, jobEnv, listAdoptableJobs, listenOnBroker, microVmCommandLine, needsRenew, outcomeFromExitCode, provisionEnvironment, provisionMicroVm, provisionProcess, readCpuUsec, readJobFile, readProvisionOut, recoverLostJobs, releaseLease, renewLease, resolveHostBuild, retryFailedReleases, runCommand, runHook, setStage, startHostServices, stopOrphanedMicroVms, unfinishedJobs, workdirProblem, writeJobFile };
|