promigence 0.1.0-beta.12
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/NOTICE +14 -0
- package/README.md +88 -0
- package/dist/account.js +850 -0
- package/dist/agent.js +374 -0
- package/dist/argv.js +159 -0
- package/dist/bin.js +332 -0
- package/dist/bundle.js +133 -0
- package/dist/capacity.js +69 -0
- package/dist/commands.d.ts +110 -0
- package/dist/commands.js +2675 -0
- package/dist/connectreq.js +125 -0
- package/dist/credfile.js +188 -0
- package/dist/credits.js +197 -0
- package/dist/devcontainer.d.ts +99 -0
- package/dist/devcontainer.js +744 -0
- package/dist/device.js +129 -0
- package/dist/dirsnap.js +378 -0
- package/dist/errfix.js +262 -0
- package/dist/generated.d.ts +35 -0
- package/dist/generated.js +307 -0
- package/dist/github.js +307 -0
- package/dist/gitlab.js +210 -0
- package/dist/guide.d.ts +19 -0
- package/dist/guide.js +366 -0
- package/dist/handlers.js +511 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +7 -0
- package/dist/init.d.ts +40 -0
- package/dist/init.js +352 -0
- package/dist/install.d.ts +23 -0
- package/dist/install.js +127 -0
- package/dist/io.d.ts +37 -0
- package/dist/io.js +318 -0
- package/dist/live.js +469 -0
- package/dist/login.js +1063 -0
- package/dist/mcp.js +889 -0
- package/dist/money.js +5 -0
- package/dist/orgs.js +475 -0
- package/dist/pricing.d.ts +52 -0
- package/dist/pricing.js +181 -0
- package/dist/report.js +132 -0
- package/dist/routes.js +135 -0
- package/dist/runners-github.js +257 -0
- package/dist/runners-gitlab.js +125 -0
- package/dist/runners-spec.js +137 -0
- package/dist/runners.js +1458 -0
- package/dist/sdk/blake3.d.ts +10 -0
- package/dist/sdk/blake3.js +111 -0
- package/dist/sdk/bundle.d.ts +96 -0
- package/dist/sdk/bundle.js +227 -0
- package/dist/sdk/ca-guard.js +166 -0
- package/dist/sdk/caller.d.ts +73 -0
- package/dist/sdk/caller.js +47 -0
- package/dist/sdk/client.d.ts +3015 -0
- package/dist/sdk/client.js +2583 -0
- package/dist/sdk/command-errors.d.ts +41 -0
- package/dist/sdk/command-errors.js +36 -0
- package/dist/sdk/context.d.ts +119 -0
- package/dist/sdk/context.js +588 -0
- package/dist/sdk/core.d.ts +285 -0
- package/dist/sdk/core.js +266 -0
- package/dist/sdk/credstore.d.ts +90 -0
- package/dist/sdk/credstore.js +206 -0
- package/dist/sdk/dispatcher.js +86 -0
- package/dist/sdk/github.d.ts +158 -0
- package/dist/sdk/github.js +78 -0
- package/dist/sdk/gitlab.d.ts +104 -0
- package/dist/sdk/gitlab.js +20 -0
- package/dist/sdk/handles.d.ts +82 -0
- package/dist/sdk/handles.js +135 -0
- package/dist/sdk/hint.js +7 -0
- package/dist/sdk/http2.js +1921 -0
- package/dist/sdk/index.d.ts +34 -0
- package/dist/sdk/index.js +15 -0
- package/dist/sdk/keyed.gen.js +7 -0
- package/dist/sdk/models.gen.d.ts +6162 -0
- package/dist/sdk/models.gen.js +1 -0
- package/dist/sdk/proxy.js +249 -0
- package/dist/sdk/pty.d.ts +171 -0
- package/dist/sdk/pty.js +496 -0
- package/dist/sdk/sandbox.d.ts +537 -0
- package/dist/sdk/sandbox.js +725 -0
- package/dist/sdk/ssh.d.ts +25 -0
- package/dist/sdk/ssh.js +114 -0
- package/dist/sdk/trace.js +80 -0
- package/dist/sdk/transport-worker.js +466 -0
- package/dist/sdk/warm.js +40 -0
- package/dist/ssh.js +267 -0
- package/dist/surface.d.ts +95 -0
- package/dist/surface.js +1452 -0
- package/dist/templates.js +53 -0
- package/dist/term.js +50 -0
- package/dist/timing.js +71 -0
- package/dist/untar.js +324 -0
- package/dist/uploadguard.js +75 -0
- package/llms.txt +209 -0
- package/package.json +52 -0
package/dist/runners.js
ADDED
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { configDir, PromigenceError } from "./sdk/index.js";
|
|
6
|
+
import { bool, buildPriceOf, client, nexts, str } from "./commands.js";
|
|
7
|
+
import { fitLine } from "./capacity.js";
|
|
8
|
+
import { TIERS } from "./generated.js";
|
|
9
|
+
import { buildPriceIn, buildPriceWords, COMPILED_NOTE, livePricing } from "./pricing.js";
|
|
10
|
+
import { emit, errorEnvelope, errorTextLines, okEnvelope, parseCount, parseDuration, parseUsd, usageError, write } from "./io.js";
|
|
11
|
+
import { escapeLine, jsonLine, sh } from "./term.js";
|
|
12
|
+
import { GITHUB_CONFIG_PATH, GITHUB_LAUNCHER, GITLAB_CONFIG_PATH, GITLAB_LAUNCHER, RUNNER_TIERS, isRunnerTier, runnerAlias, runnerBuildSteps, runnerImageSpec, } from "./runners-spec.js";
|
|
13
|
+
import * as gh from "./runners-github.js";
|
|
14
|
+
import * as gl from "./runners-gitlab.js";
|
|
15
|
+
export const TICK_MS = 2500;
|
|
16
|
+
export const SELF_END_GRACE_S = 60;
|
|
17
|
+
const IDLE_READ_MS = 2500;
|
|
18
|
+
const BUSY_WAIT_MS = 25_000;
|
|
19
|
+
export const IDLE_TIMEOUT_DEFAULT_MS = 90_000;
|
|
20
|
+
const CANCEL_GRACE_MS = 20_000;
|
|
21
|
+
const QUIT_GRACE_MS = 60_000;
|
|
22
|
+
const SANDBOX_READY_MS = 120_000;
|
|
23
|
+
const BUILD_WAIT_MS = 30 * 60_000;
|
|
24
|
+
const DEFAULT_MAX = 10;
|
|
25
|
+
const CEILING_STEP_TICKS = 12;
|
|
26
|
+
const UNSERVABLE_MS = 10 * 60_000;
|
|
27
|
+
const UNHANDED_FLOOR_MS = 60_000;
|
|
28
|
+
const listPerSecond = (tier) => TIERS.find((t) => t.name === tier)?.per_second_usd ?? 0;
|
|
29
|
+
const shape = (tier) => {
|
|
30
|
+
const t = TIERS.find((x) => x.name === tier);
|
|
31
|
+
return t ? `${t.vcpus} vCPU, ${t.mem_gib} GiB, ${t.disk_gib} GiB disk` : tier;
|
|
32
|
+
};
|
|
33
|
+
const usd = (n) => `$${n < 0.01 && n > 0 ? n.toFixed(4) : n.toFixed(2)}`;
|
|
34
|
+
const secs = (ms) => {
|
|
35
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
36
|
+
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
|
|
37
|
+
};
|
|
38
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
39
|
+
export function sanitizeRunnerLine(line, secrets) {
|
|
40
|
+
return gh.redact(line, secrets)
|
|
41
|
+
.replace(/\b(runner|token|jitconfig)=\S+/gi, "$1=[redacted]")
|
|
42
|
+
.replace(/\bwith token \S+/gi, "with token [redacted]")
|
|
43
|
+
.replace(/\b(glrt-|glpat-|ghp_|gho_|ghs_|github_pat_)[A-Za-z0-9_-]+/g, "[redacted]");
|
|
44
|
+
}
|
|
45
|
+
export function runnerArgv(launcher, graceS) {
|
|
46
|
+
const g = Math.max(1, Math.round(graceS));
|
|
47
|
+
return [
|
|
48
|
+
"/bin/sh",
|
|
49
|
+
"-c",
|
|
50
|
+
`trap : HUP INT QUIT TERM; "$0"; rc=$?; ` +
|
|
51
|
+
`setsid sh -c 'sleep ${g}; echo b > /proc/sysrq-trigger' </dev/null >/dev/null 2>&1 & exit $rc`,
|
|
52
|
+
launcher,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
const poolsDir = () => join(configDir(process.env), "runner-pools");
|
|
56
|
+
export function registerPool(e) {
|
|
57
|
+
try {
|
|
58
|
+
const dir = poolsDir();
|
|
59
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
chmodSync(dir, 0o700);
|
|
61
|
+
const tmp = join(dir, `.${e.pool_id}.${process.pid}.tmp`);
|
|
62
|
+
writeFileSync(tmp, JSON.stringify(e) + "\n", { mode: 0o600 });
|
|
63
|
+
renameSync(tmp, join(dir, `${e.pool_id}.json`));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function unregisterPool(poolId) {
|
|
71
|
+
try {
|
|
72
|
+
rmSync(join(poolsDir(), `${poolId}.json`), { force: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const pidAlive = (pid) => {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return e?.code === "EPERM";
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
export function deadPools(scope, self) {
|
|
87
|
+
let names;
|
|
88
|
+
try {
|
|
89
|
+
names = readdirSync(poolsDir());
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const n of names) {
|
|
96
|
+
if (!/^[0-9a-f]{8}\.json$/.test(n))
|
|
97
|
+
continue;
|
|
98
|
+
try {
|
|
99
|
+
const e = JSON.parse(readFileSync(join(poolsDir(), n), "utf8"));
|
|
100
|
+
if (e.pool_id !== n.slice(0, -5) || e.pool_id === self || e.scope !== scope || !Number.isInteger(e.pid))
|
|
101
|
+
continue;
|
|
102
|
+
if (!pidAlive(e.pid))
|
|
103
|
+
out.push(e);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
class Pool {
|
|
111
|
+
ctx;
|
|
112
|
+
c;
|
|
113
|
+
opts;
|
|
114
|
+
provider;
|
|
115
|
+
runners = [];
|
|
116
|
+
jobs = [];
|
|
117
|
+
tasks = new Set();
|
|
118
|
+
stopping;
|
|
119
|
+
killing = false;
|
|
120
|
+
fatal;
|
|
121
|
+
notes = [];
|
|
122
|
+
queued = 0;
|
|
123
|
+
#nextIndex = 0;
|
|
124
|
+
#lastLine = "";
|
|
125
|
+
#tableLines = 0;
|
|
126
|
+
#wake;
|
|
127
|
+
startedAt = Date.now();
|
|
128
|
+
idleCost = 0;
|
|
129
|
+
runnersStarted = 0;
|
|
130
|
+
taken = new Map();
|
|
131
|
+
lastItems = [];
|
|
132
|
+
failStreak = 0;
|
|
133
|
+
startsPausedUntil = 0;
|
|
134
|
+
ceiling;
|
|
135
|
+
ceilingAt = 0;
|
|
136
|
+
rateStreak = 0;
|
|
137
|
+
unservable = new Map();
|
|
138
|
+
constructor(ctx, c, opts, provider) {
|
|
139
|
+
this.ctx = ctx;
|
|
140
|
+
this.c = c;
|
|
141
|
+
this.opts = opts;
|
|
142
|
+
this.provider = provider;
|
|
143
|
+
this.ceiling = opts.max;
|
|
144
|
+
}
|
|
145
|
+
limit() {
|
|
146
|
+
return Math.min(this.opts.max, this.ceiling);
|
|
147
|
+
}
|
|
148
|
+
live() {
|
|
149
|
+
return this.runners.filter((s) => s.phase === "starting" || s.phase === "idle" || s.phase === "busy" || s.phase === "stopping");
|
|
150
|
+
}
|
|
151
|
+
get tty() {
|
|
152
|
+
return this.ctx.mode === "text" && process.stderr.isTTY === true;
|
|
153
|
+
}
|
|
154
|
+
async event(ev, text) {
|
|
155
|
+
if (this.ctx.mode === "json") {
|
|
156
|
+
await write(process.stdout, jsonLine(process.stdout, { event: ev["event"], at: new Date().toISOString(), ...ev }));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (text === undefined)
|
|
160
|
+
return;
|
|
161
|
+
this.clearTable();
|
|
162
|
+
await write(process.stdout, escapeLine(text) + "\n");
|
|
163
|
+
this.drawTable();
|
|
164
|
+
}
|
|
165
|
+
note(s) {
|
|
166
|
+
if (this.notes[this.notes.length - 1] === s)
|
|
167
|
+
return;
|
|
168
|
+
this.notes.push(s);
|
|
169
|
+
if (this.notes.length > 3)
|
|
170
|
+
this.notes.shift();
|
|
171
|
+
if (this.ctx.mode === "json")
|
|
172
|
+
void this.event({ event: "note", message: s }, undefined);
|
|
173
|
+
else if (!this.tty)
|
|
174
|
+
void write(process.stdout, `note: ${escapeLine(s)}\n`);
|
|
175
|
+
}
|
|
176
|
+
clearTable() {
|
|
177
|
+
if (!this.tty || this.#tableLines === 0)
|
|
178
|
+
return;
|
|
179
|
+
process.stderr.write(`\x1b[${this.#tableLines}A\r\x1b[J`);
|
|
180
|
+
this.#tableLines = 0;
|
|
181
|
+
}
|
|
182
|
+
drawTable() {
|
|
183
|
+
if (!this.tty)
|
|
184
|
+
return;
|
|
185
|
+
this.clearTable();
|
|
186
|
+
const cols = process.stderr.columns ?? 120;
|
|
187
|
+
const live = this.live();
|
|
188
|
+
const running = live.filter((s) => s.phase === "busy").length;
|
|
189
|
+
const done = this.jobs.length;
|
|
190
|
+
const spent = this.jobs.reduce((a, j) => a + j.cost_usd, 0) + this.idleCost;
|
|
191
|
+
const head = `${this.opts.kind} ${this.opts.scopeLabel} · queued ${this.queued} · running ${running} · done ${done} · ${usd(spent)}` +
|
|
192
|
+
(this.stopping ? (this.killing ? " · cancelling" : " · stopping (Ctrl-C again cancels running jobs)") : " · Ctrl-C stops");
|
|
193
|
+
const rows = [head];
|
|
194
|
+
if (live.length)
|
|
195
|
+
rows.push(` ${"RUNNER".padEnd(12)}${"TIER".padEnd(8)}${"STATE".padEnd(10)}${"TIME".padEnd(8)}${"COST".padEnd(10)}JOB`);
|
|
196
|
+
for (const s of live.slice(0, 20)) {
|
|
197
|
+
const since = s.jobStartedAt ?? s.startedAt;
|
|
198
|
+
const cost = this.opts.perSecond(s.tier) * Math.max(0, Date.now() - s.startedAt) / 1000;
|
|
199
|
+
const state = s.phase === "starting" ? "starting" : s.phase === "idle" ? "idle" : s.phase === "busy" ? "running" : "ending";
|
|
200
|
+
rows.push(` ${s.id.padEnd(12)}${s.tier.padEnd(8)}${state.padEnd(10)}${secs(Date.now() - since).padEnd(8)}${("~" + usd(cost)).padEnd(10)}${s.job ?? "-"}`);
|
|
201
|
+
}
|
|
202
|
+
if (live.length > 20)
|
|
203
|
+
rows.push(` … ${live.length - 20} more`);
|
|
204
|
+
for (const n of this.notes)
|
|
205
|
+
rows.push(` note: ${n}`);
|
|
206
|
+
const out = rows.map((r) => fitLine(escapeLine(r), cols));
|
|
207
|
+
process.stderr.write(out.join("\n") + "\n");
|
|
208
|
+
this.#tableLines = out.length;
|
|
209
|
+
}
|
|
210
|
+
async plainStatus() {
|
|
211
|
+
if (this.ctx.mode !== "text" || this.tty)
|
|
212
|
+
return;
|
|
213
|
+
const live = this.live();
|
|
214
|
+
const line = `status: queued ${this.queued} · starting ${live.filter((s) => s.phase === "starting").length} · idle ${live.filter((s) => s.phase === "idle").length} · running ${live.filter((s) => s.phase === "busy").length} · done ${this.jobs.length}`;
|
|
215
|
+
if (line !== this.#lastLine) {
|
|
216
|
+
this.#lastLine = line;
|
|
217
|
+
await write(process.stdout, line + "\n");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
wake() {
|
|
221
|
+
this.#wake?.();
|
|
222
|
+
}
|
|
223
|
+
async nap(ms) {
|
|
224
|
+
await new Promise((r) => {
|
|
225
|
+
const t = setTimeout(r, ms);
|
|
226
|
+
this.#wake = () => { clearTimeout(t); r(); };
|
|
227
|
+
});
|
|
228
|
+
this.#wake = undefined;
|
|
229
|
+
}
|
|
230
|
+
async run() {
|
|
231
|
+
while (true) {
|
|
232
|
+
if (this.stopping || this.fatal) {
|
|
233
|
+
this.stopping ??= "SIGTERM";
|
|
234
|
+
await this.drain();
|
|
235
|
+
if (this.live().length === 0)
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
await this.tick();
|
|
240
|
+
}
|
|
241
|
+
this.drawTable();
|
|
242
|
+
await this.plainStatus();
|
|
243
|
+
if (this.live().length === 0 && (this.stopping || this.fatal))
|
|
244
|
+
break;
|
|
245
|
+
await this.nap(this.opts.pollMs);
|
|
246
|
+
}
|
|
247
|
+
await Promise.allSettled([...this.tasks]);
|
|
248
|
+
this.clearTable();
|
|
249
|
+
}
|
|
250
|
+
async tick() {
|
|
251
|
+
const items = await this.provider.demand((s) => this.note(s)).catch((e) => {
|
|
252
|
+
this.fatal = e instanceof PromigenceError ? e : new PromigenceError({ code: "internal", message: String(e?.message ?? e), fix: "Run it again; if it repeats, tell support@promigence.ai.", exitCode: 1 });
|
|
253
|
+
return undefined;
|
|
254
|
+
});
|
|
255
|
+
if (this.fatal)
|
|
256
|
+
return;
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
for (const [id, until] of this.taken)
|
|
259
|
+
if (until < now)
|
|
260
|
+
this.taken.delete(id);
|
|
261
|
+
if (items)
|
|
262
|
+
for (const id of this.unservable.keys())
|
|
263
|
+
if (!items.some((i) => i.id === id))
|
|
264
|
+
this.unservable.delete(id);
|
|
265
|
+
const fresh = items?.filter((i) => !this.taken.has(i.id) && !((this.unservable.get(i.id)?.until ?? 0) > now));
|
|
266
|
+
if (items)
|
|
267
|
+
this.lastItems = items;
|
|
268
|
+
const live = this.live();
|
|
269
|
+
const idle = live.filter((s) => (s.phase === "starting" || s.phase === "idle") && !s.retiring);
|
|
270
|
+
const reserved = new Set();
|
|
271
|
+
const unmatched = [];
|
|
272
|
+
if (fresh) {
|
|
273
|
+
this.queued = fresh.length;
|
|
274
|
+
for (const item of fresh) {
|
|
275
|
+
const r = idle.find((s) => !reserved.has(s) && this.provider.covers(s, item));
|
|
276
|
+
if (r) {
|
|
277
|
+
reserved.add(r);
|
|
278
|
+
r.lastDemandAt = now;
|
|
279
|
+
if (r.reservedFor !== item.id) {
|
|
280
|
+
r.reservedFor = item.id;
|
|
281
|
+
r.reservedSince = now;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
unmatched.push(item);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const s of idle)
|
|
289
|
+
if (!reserved.has(s)) {
|
|
290
|
+
delete s.reservedFor;
|
|
291
|
+
delete s.reservedSince;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (this.ceiling < this.opts.max && now - this.ceilingAt >= CEILING_STEP_TICKS * this.opts.tickMs) {
|
|
295
|
+
this.ceiling += 1;
|
|
296
|
+
this.ceilingAt = now;
|
|
297
|
+
}
|
|
298
|
+
let room = this.limit() - live.length;
|
|
299
|
+
if (now < this.startsPausedUntil)
|
|
300
|
+
room = 0;
|
|
301
|
+
for (const item of unmatched) {
|
|
302
|
+
if (room <= 0) {
|
|
303
|
+
if (now >= this.startsPausedUntil) {
|
|
304
|
+
const at = this.ceiling < this.opts.max ? `${this.ceiling} at once (the org's sandbox limit; --max ${this.opts.max})` : `--max ${this.opts.max}`;
|
|
305
|
+
this.note(`at ${at}: ${unmatched.length} job${unmatched.length === 1 ? "" : "s"} wait for a free runner`);
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
const route = this.provider.route(item);
|
|
310
|
+
if (this.opts.timeoutFor(route.tier) < 60_000) {
|
|
311
|
+
this.note(`${item.title} asks for promigence-${route.tier}: --cap buys less than a minute of it, so it is left queued (raise --cap)`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (!this.opts.snapshots.has(route.tier)) {
|
|
315
|
+
const id = await this.opts.ensureSnapshot(route.tier);
|
|
316
|
+
if (id === undefined)
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
this.launch(route, "demand", item.id);
|
|
320
|
+
room -= 1;
|
|
321
|
+
}
|
|
322
|
+
if (this.opts.keep > 0 && now >= this.startsPausedUntil) {
|
|
323
|
+
const keepers = this.live().filter((s) => s.purpose === this.opts.keepPurpose &&
|
|
324
|
+
(this.opts.keepPurpose === "pool" ? true : s.phase === "starting" || s.phase === "idle"));
|
|
325
|
+
let want = this.opts.keep - keepers.length;
|
|
326
|
+
while (want > 0 && this.live().length < this.limit()) {
|
|
327
|
+
this.launch(this.provider.keepRoute(), this.opts.keepPurpose);
|
|
328
|
+
want -= 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const s of idle) {
|
|
332
|
+
if (s.purpose !== "warm" || reserved.has(s) || s.phase !== "idle")
|
|
333
|
+
continue;
|
|
334
|
+
if (now - s.startedAt > this.opts.timeoutFor(s.tier) / 2)
|
|
335
|
+
void this.retire(s, "recycled before its sandbox's end of life");
|
|
336
|
+
}
|
|
337
|
+
for (const s of idle) {
|
|
338
|
+
if (s.purpose !== "demand" || s.phase !== "idle" || this.provider.exitsByItself)
|
|
339
|
+
continue;
|
|
340
|
+
const listening = s.listeningAt ?? s.startedAt;
|
|
341
|
+
if (reserved.has(s)) {
|
|
342
|
+
const floor = Math.max(this.opts.idleTimeoutMs, UNHANDED_FLOOR_MS * this.opts.tickMs / TICK_MS);
|
|
343
|
+
if (s.reservedFor === undefined || s.listeningAt === undefined || now - Math.max(s.listeningAt, s.reservedSince ?? now) < floor)
|
|
344
|
+
continue;
|
|
345
|
+
const item = fresh?.find((i) => i.id === s.reservedFor);
|
|
346
|
+
this.markUnservable(s.reservedFor, item?.title);
|
|
347
|
+
void this.retire(s, "it was not handed the job it was started for");
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (now - Math.max(s.lastDemandAt, listening) < this.opts.idleTimeoutMs)
|
|
351
|
+
continue;
|
|
352
|
+
void this.retire(s, "no job for it");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
markUnservable(id, title) {
|
|
356
|
+
const prev = this.unservable.get(id);
|
|
357
|
+
const times = (prev?.times ?? 0) + 1;
|
|
358
|
+
const wait = Math.min(60 * 60_000, UNSERVABLE_MS * 2 ** (times - 1));
|
|
359
|
+
const name = title ?? prev?.title ?? `job ${id}`;
|
|
360
|
+
this.unservable.set(id, { until: Date.now() + wait, times, title: name });
|
|
361
|
+
this.note(`${name}: ${this.provider.unassignedHint}; no runner is started for it for ${secs(wait)}`);
|
|
362
|
+
}
|
|
363
|
+
async drain() {
|
|
364
|
+
for (const s of this.live()) {
|
|
365
|
+
if (this.killing && s.quitSentAt !== undefined && s.phase !== "busy")
|
|
366
|
+
s.retireNow = true;
|
|
367
|
+
if (s.phase === "starting" || s.phase === "idle")
|
|
368
|
+
void this.retire(s, "stopping");
|
|
369
|
+
if (s.phase === "busy" && (this.killing || this.opts.killOnExit) && s.cancelSentAt === undefined && s.execId && s.sandboxId) {
|
|
370
|
+
s.cancelSentAt = Date.now();
|
|
371
|
+
void this.c.sandboxes.execs.signal(s.sandboxId, s.execId, "SIGTERM").catch(() => undefined);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async retire(s, why) {
|
|
376
|
+
if (s.retiring || s.retireNow)
|
|
377
|
+
return;
|
|
378
|
+
s.retiring = true;
|
|
379
|
+
let how = "no";
|
|
380
|
+
try {
|
|
381
|
+
how = s.phase === "starting" ? "now" : await this.provider.retire(s);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
how = "no";
|
|
385
|
+
}
|
|
386
|
+
if (how === "graceful") {
|
|
387
|
+
const asked = s.sandboxId && s.execId
|
|
388
|
+
? await this.c.sandboxes.execs.signal(s.sandboxId, s.execId, "SIGQUIT").then((r) => r.delivered !== false).catch(() => false)
|
|
389
|
+
: false;
|
|
390
|
+
if (asked)
|
|
391
|
+
s.quitSentAt = Date.now();
|
|
392
|
+
else
|
|
393
|
+
how = "now";
|
|
394
|
+
}
|
|
395
|
+
if (how === "no") {
|
|
396
|
+
s.retiring = false;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (how === "now")
|
|
400
|
+
s.retireNow = true;
|
|
401
|
+
if (why !== "stopping")
|
|
402
|
+
this.note(`${s.id}: stopped (${why})`);
|
|
403
|
+
}
|
|
404
|
+
async reapOrphans() {
|
|
405
|
+
const c = this.c;
|
|
406
|
+
for (const dead of deadPools(this.opts.scopeKey, this.opts.poolId)) {
|
|
407
|
+
let rows;
|
|
408
|
+
try {
|
|
409
|
+
rows = await c.sandboxes.list({ labels: { ci: this.opts.kind, "ci-pool": dead.pool_id }, state: ["ready", "running"], limit: 200 });
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const ended = [];
|
|
415
|
+
const running = [];
|
|
416
|
+
for (const sb of rows) {
|
|
417
|
+
if (sb.state !== "ready" && sb.state !== "running")
|
|
418
|
+
continue;
|
|
419
|
+
let idle = false;
|
|
420
|
+
try {
|
|
421
|
+
const execs = await c.sandboxes.execs.list(sb.sandbox_id);
|
|
422
|
+
idle = !execs.some((e) => e.state === "running");
|
|
423
|
+
const name = sb.labels?.["ci-runner"];
|
|
424
|
+
if (!idle && name)
|
|
425
|
+
idle = await this.provider.orphanIdle(name);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
idle = false;
|
|
429
|
+
}
|
|
430
|
+
if (!idle) {
|
|
431
|
+
running.push(sb.sandbox_id);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (await this.killSure(() => c.sandboxes.kill(sb.sandbox_id)))
|
|
435
|
+
ended.push(sb.sandbox_id);
|
|
436
|
+
else
|
|
437
|
+
running.push(sb.sandbox_id);
|
|
438
|
+
}
|
|
439
|
+
if (running.length === 0)
|
|
440
|
+
unregisterPool(dead.pool_id);
|
|
441
|
+
if (ended.length === 0 && running.length === 0)
|
|
442
|
+
continue;
|
|
443
|
+
await this.event({ event: "orphans", pool_id: dead.pool_id, ended, running }, `earlier pool ${dead.pool_id} (no longer running) left ${ended.length + running.length} runner sandbox${ended.length + running.length === 1 ? "" : "es"}: ` +
|
|
444
|
+
`${ended.length} ended now` + (running.length ? `; ${running.length} running a job, each ends itself after its job (${sh `promigence sandbox list --label ci-pool=${dead.pool_id}`})` : ""));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
launch(route, purpose, forItem) {
|
|
448
|
+
const runner = {
|
|
449
|
+
id: `ci-${randomBytes(4).toString("hex")}`,
|
|
450
|
+
index: this.#nextIndex++,
|
|
451
|
+
tier: route.tier,
|
|
452
|
+
labels: route.labels,
|
|
453
|
+
key: route.key,
|
|
454
|
+
purpose,
|
|
455
|
+
phase: "starting",
|
|
456
|
+
startedAt: Date.now(),
|
|
457
|
+
offsets: {},
|
|
458
|
+
partial: {},
|
|
459
|
+
lastDemandAt: Date.now(),
|
|
460
|
+
tail: [],
|
|
461
|
+
...(forItem === undefined ? {} : { forItem, reservedFor: forItem, reservedSince: Date.now() }),
|
|
462
|
+
};
|
|
463
|
+
this.runners.push(runner);
|
|
464
|
+
this.runnersStarted += 1;
|
|
465
|
+
const t = this.lifecycle(runner).finally(() => { this.tasks.delete(t); this.wake(); });
|
|
466
|
+
this.tasks.add(t);
|
|
467
|
+
}
|
|
468
|
+
async lifecycle(runner) {
|
|
469
|
+
const c = this.c;
|
|
470
|
+
try {
|
|
471
|
+
const snapshot = this.opts.snapshots.get(runner.tier);
|
|
472
|
+
if (snapshot === undefined)
|
|
473
|
+
throw new PromigenceError({ code: "not_found", message: `no ${runner.tier} runner snapshot`, fix: "Build it first with --prepare.", exitCode: 8 });
|
|
474
|
+
const timeoutMs = this.opts.timeoutFor(runner.tier);
|
|
475
|
+
const sandbox = (async () => {
|
|
476
|
+
const started = await c.runs.start({
|
|
477
|
+
snapshot, count: 1, timeoutMs, spendCap: this.opts.capFor(runner.tier),
|
|
478
|
+
labels: { ci: this.opts.kind, "ci-pool": this.opts.poolId, "ci-runner": runner.id },
|
|
479
|
+
waitMs: 20_000,
|
|
480
|
+
}).catch((e) => {
|
|
481
|
+
const status = e instanceof PromigenceError ? e.status : undefined;
|
|
482
|
+
if (status === undefined || status >= 500)
|
|
483
|
+
runner.startUnknown = true;
|
|
484
|
+
throw e;
|
|
485
|
+
});
|
|
486
|
+
runner.runId = started.run_id;
|
|
487
|
+
this.rateStreak = 0;
|
|
488
|
+
let sb = started.sandboxes?.find((s) => s.state === "ready" || s.state === "running");
|
|
489
|
+
if (!sb) {
|
|
490
|
+
const w = await c.runs.waitReady(started.run_id, { waitTimeoutMs: SANDBOX_READY_MS });
|
|
491
|
+
sb = w.sandboxes.find((s) => s.state === "ready" || s.state === "running");
|
|
492
|
+
if (!sb) {
|
|
493
|
+
throw new PromigenceError({
|
|
494
|
+
code: w.timedOut ? "timeout" : "env_failed",
|
|
495
|
+
message: w.timedOut ? `no sandbox within ${SANDBOX_READY_MS / 1000} s` : "the sandbox did not start",
|
|
496
|
+
fix: `Read ${sh `promigence run report ${started.run_id}`}; a sandbox that did not start is not billed.`,
|
|
497
|
+
exitCode: w.timedOut ? 11 : 3,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
runner.sandboxId = sb.sandbox_id;
|
|
502
|
+
return sb.sandbox_id;
|
|
503
|
+
})();
|
|
504
|
+
const creds = this.provider.credentials(runner);
|
|
505
|
+
const [sbR, crR] = await Promise.allSettled([sandbox, creds]);
|
|
506
|
+
if (sbR.status === "rejected")
|
|
507
|
+
throw sbR.reason;
|
|
508
|
+
if (crR.status === "rejected")
|
|
509
|
+
throw crR.reason;
|
|
510
|
+
const sid = sbR.value;
|
|
511
|
+
const cred = crR.value;
|
|
512
|
+
if (runner.retireNow || this.stopping) {
|
|
513
|
+
runner.phase = "stopping";
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
await c.sandboxes.files.put(sid, cred.path, cred.content, { mode: 0o600 });
|
|
517
|
+
const rec = await c.sandboxes.execBackground(sid, {
|
|
518
|
+
argv: runnerArgv(this.provider.launcher, this.opts.selfEndGraceS),
|
|
519
|
+
timeoutMs,
|
|
520
|
+
...(cred.env ? { env: cred.env } : {}),
|
|
521
|
+
});
|
|
522
|
+
runner.execId = rec.exec_id;
|
|
523
|
+
runner.phase = "idle";
|
|
524
|
+
await this.event({ event: "runner_started", runner: runner.id, tier: runner.tier, labels: runner.labels, purpose: runner.purpose, sandbox_id: sid, run_id: runner.runId, ready_ms: Date.now() - runner.startedAt }, undefined);
|
|
525
|
+
await this.watch(runner);
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
const err = e instanceof PromigenceError ? e : e instanceof gh.GitHubError ? githubFailure(e, this.provider.secrets()) : new PromigenceError({ code: "internal", message: String(e?.message ?? e), fix: "Run it again.", exitCode: 1 });
|
|
529
|
+
runner.error = gh.redact(err.message, this.provider.secrets());
|
|
530
|
+
if (e instanceof gh.GitHubError && (e.status === 401 || e.status === 403))
|
|
531
|
+
this.fatal ??= err;
|
|
532
|
+
this.onStartFailure(runner, err);
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
await this.end(runner);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
onStartFailure(runner, err) {
|
|
539
|
+
const msg = gh.redact(err.message, this.provider.secrets());
|
|
540
|
+
if (err.code === "concurrency_limit") {
|
|
541
|
+
runner.refused = true;
|
|
542
|
+
const others = this.live().filter((s) => s !== runner).length;
|
|
543
|
+
this.ceiling = Math.max(1, Math.min(this.ceiling, others));
|
|
544
|
+
this.ceilingAt = Date.now();
|
|
545
|
+
if (others === 0)
|
|
546
|
+
this.startsPausedUntil = Math.max(this.startsPausedUntil, Date.now() + CEILING_STEP_TICKS * this.opts.tickMs);
|
|
547
|
+
this.note(`the org's limit on sandboxes at once refused a runner: up to ${this.ceiling} at once for now, rising back to --max ${this.opts.max} a step at a time`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (err.code === "rate_limited") {
|
|
551
|
+
runner.refused = true;
|
|
552
|
+
this.rateStreak += 1;
|
|
553
|
+
const wait = Math.min(60_000, Math.max(err.retryAfterMs ?? 0, 4 * this.opts.tickMs * 2 ** (this.rateStreak - 1)));
|
|
554
|
+
this.startsPausedUntil = Math.max(this.startsPausedUntil, Date.now() + wait);
|
|
555
|
+
this.note(`runner starts were rate limited: next start in ${secs(wait)}`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (err.cliExit === 5 || err.code === "cap_exceeded" || err.code === "credits_exhausted" || err.code === "auth" || err.cliExit === 7) {
|
|
559
|
+
this.fatal ??= err;
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (runner.jobStartedAt === undefined)
|
|
563
|
+
this.note(`${runner.id}: ${msg.slice(0, 160)}`);
|
|
564
|
+
}
|
|
565
|
+
async watch(runner) {
|
|
566
|
+
const c = this.c;
|
|
567
|
+
const sid = runner.sandboxId;
|
|
568
|
+
const eid = runner.execId;
|
|
569
|
+
const decoders = new Map();
|
|
570
|
+
const idleRead = Math.min(IDLE_READ_MS, this.opts.tickMs);
|
|
571
|
+
let failures = 0;
|
|
572
|
+
const gone = (e) => {
|
|
573
|
+
failures += 1;
|
|
574
|
+
if (e instanceof PromigenceError && (e.code === "not_found" || e.code === "sandbox_ended" || e.cliExit === 8))
|
|
575
|
+
failures += 2;
|
|
576
|
+
return undefined;
|
|
577
|
+
};
|
|
578
|
+
while (true) {
|
|
579
|
+
let ended = false;
|
|
580
|
+
if (runner.phase === "busy" && !runner.retireNow) {
|
|
581
|
+
const r = await c.sandboxes.execs.get(sid, eid, { waitMs: BUSY_WAIT_MS }).catch(gone);
|
|
582
|
+
if (r && r.state !== "running")
|
|
583
|
+
ended = true;
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
for (let waited = 0; waited < idleRead && !runner.retireNow; waited += Math.min(250, idleRead))
|
|
587
|
+
await sleep(Math.min(250, idleRead));
|
|
588
|
+
}
|
|
589
|
+
const readAll = async () => {
|
|
590
|
+
for (const stream of this.provider.streams) {
|
|
591
|
+
const r = await c.sandboxes.execs.read(sid, eid, stream, { offset: runner.offsets[stream] ?? 0 }).catch(gone);
|
|
592
|
+
if (!r)
|
|
593
|
+
continue;
|
|
594
|
+
failures = 0;
|
|
595
|
+
if (r.data.length) {
|
|
596
|
+
runner.offsets[stream] = (runner.offsets[stream] ?? 0) + r.data.length;
|
|
597
|
+
const dec = decoders.get(stream) ?? new TextDecoder();
|
|
598
|
+
decoders.set(stream, dec);
|
|
599
|
+
const text = (runner.partial[stream] ?? "") + dec.decode(r.data, { stream: true });
|
|
600
|
+
const lines = text.split("\n");
|
|
601
|
+
runner.partial[stream] = lines.pop() ?? "";
|
|
602
|
+
for (const line of lines)
|
|
603
|
+
this.onLine(runner, line);
|
|
604
|
+
}
|
|
605
|
+
if (r.record.state !== "running")
|
|
606
|
+
ended = true;
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
await readAll();
|
|
610
|
+
if (ended)
|
|
611
|
+
await readAll();
|
|
612
|
+
if (ended || failures >= 3) {
|
|
613
|
+
for (const stream of this.provider.streams)
|
|
614
|
+
if (runner.partial[stream])
|
|
615
|
+
this.onLine(runner, runner.partial[stream]);
|
|
616
|
+
if (!ended)
|
|
617
|
+
runner.error ??= "the sandbox stopped answering";
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (runner.retireNow)
|
|
621
|
+
return;
|
|
622
|
+
if (runner.cancelSentAt !== undefined && Date.now() - runner.cancelSentAt > CANCEL_GRACE_MS)
|
|
623
|
+
return;
|
|
624
|
+
if (runner.quitSentAt !== undefined && runner.phase !== "busy" && Date.now() - runner.quitSentAt > QUIT_GRACE_MS) {
|
|
625
|
+
runner.retireNow = true;
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (runner.phase === "busy" && (this.killing || this.opts.killOnExit) && this.stopping && runner.cancelSentAt === undefined) {
|
|
629
|
+
runner.cancelSentAt = Date.now();
|
|
630
|
+
await c.sandboxes.execs.signal(sid, eid, "SIGTERM").catch(() => undefined);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
onLine(runner, raw) {
|
|
635
|
+
const line = raw.replace(/\r$/, "");
|
|
636
|
+
if (line.trim()) {
|
|
637
|
+
runner.tail.push(sanitizeRunnerLine(line, this.provider.secrets()).slice(0, 300));
|
|
638
|
+
if (runner.tail.length > 4)
|
|
639
|
+
runner.tail.shift();
|
|
640
|
+
}
|
|
641
|
+
const was = runner.phase;
|
|
642
|
+
this.provider.onLine(runner, line);
|
|
643
|
+
if (was !== "busy" && runner.phase === "busy") {
|
|
644
|
+
const mine = this.lastItems.find((i) => i.id === runner.forItem && (runner.job === undefined || i.name === undefined || i.name === runner.job))
|
|
645
|
+
?? this.lastItems.find((i) => i.name !== undefined && i.name === runner.job && !this.taken.has(i.id));
|
|
646
|
+
if (mine)
|
|
647
|
+
this.taken.set(mine.id, Date.now() + 15_000);
|
|
648
|
+
void this.event({ event: "job_started", runner: runner.id, job: runner.job, ...(runner.jobId === undefined ? {} : { job_id: runner.jobId }), tier: runner.tier, sandbox_id: runner.sandboxId, pickup_ms: (runner.jobStartedAt ?? Date.now()) - runner.startedAt }, `started ${runner.id} ${runner.tier.padEnd(6)} ${runner.job ?? "a job"}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async end(runner) {
|
|
652
|
+
if (runner.phase === "done" || runner.phase === "failed")
|
|
653
|
+
return;
|
|
654
|
+
const wasBusy = runner.jobStartedAt !== undefined;
|
|
655
|
+
runner.phase = "stopping";
|
|
656
|
+
const c = this.c;
|
|
657
|
+
if (runner.sandboxId && runner.execId && !wasBusy && !runner.retireNow) {
|
|
658
|
+
for (const stream of ["stderr", "stdout"]) {
|
|
659
|
+
if (this.provider.streams.includes(stream))
|
|
660
|
+
continue;
|
|
661
|
+
const r = await c.sandboxes.execs.read(runner.sandboxId, runner.execId, stream, { offset: 0 }).catch(() => undefined);
|
|
662
|
+
for (const l of new TextDecoder().decode(r?.data ?? new Uint8Array()).split("\n").filter((x) => x.trim()).slice(-3)) {
|
|
663
|
+
runner.tail.push(sanitizeRunnerLine(l, this.provider.secrets()).slice(0, 300));
|
|
664
|
+
}
|
|
665
|
+
if (runner.tail.length > 6)
|
|
666
|
+
runner.tail.splice(0, runner.tail.length - 6);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const waitedFor = runner.reservedFor ?? runner.forItem;
|
|
670
|
+
const waited = Date.now() - (runner.listeningAt ?? runner.reservedSince ?? Date.now()) >= this.opts.idleTimeoutMs / 2;
|
|
671
|
+
const leftUnhanded = runner.tail.some((l) => /has not received a job in/.test(l)) ||
|
|
672
|
+
(this.provider.exitsByItself && runner.listeningAt !== undefined && waited);
|
|
673
|
+
const unhandedFor = !wasBusy && leftUnhanded && waited && runner.purpose === "demand" && waitedFor !== undefined &&
|
|
674
|
+
!runner.retireNow && !this.stopping && this.lastItems.some((i) => i.id === waitedFor) && !this.taken.has(waitedFor)
|
|
675
|
+
? waitedFor : undefined;
|
|
676
|
+
if (wasBusy)
|
|
677
|
+
this.failStreak = 0;
|
|
678
|
+
else if (unhandedFor !== undefined) {
|
|
679
|
+
this.markUnservable(unhandedFor, this.lastItems.find((i) => i.id === unhandedFor)?.title);
|
|
680
|
+
}
|
|
681
|
+
else if (!runner.retireNow && !runner.refused && runner.quitSentAt === undefined && !this.stopping && Date.now() - runner.startedAt < 120_000 &&
|
|
682
|
+
!leftUnhanded) {
|
|
683
|
+
this.failStreak += 1;
|
|
684
|
+
if (this.failStreak >= 3) {
|
|
685
|
+
const wait = Math.min(300_000, 4 * this.opts.tickMs * 2 ** (this.failStreak - 3));
|
|
686
|
+
this.startsPausedUntil = Date.now() + wait;
|
|
687
|
+
this.note(`runners keep ending without a job (${this.failStreak} in a row; last: ${runner.error ?? (runner.tail[runner.tail.length - 1] ?? "no output")}); next start in ${secs(wait)}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
await this.endSandbox(runner);
|
|
691
|
+
await this.provider.cleanup(runner).catch(() => undefined);
|
|
692
|
+
const { cost, billable, estimated } = await this.costOf(runner);
|
|
693
|
+
runner.cost = cost;
|
|
694
|
+
runner.billableS = billable;
|
|
695
|
+
runner.costEstimated = estimated;
|
|
696
|
+
runner.endedAt = Date.now();
|
|
697
|
+
runner.phase = runner.error && !wasBusy ? "failed" : "done";
|
|
698
|
+
if (wasBusy) {
|
|
699
|
+
const end = runner.jobEndedAt ?? runner.endedAt;
|
|
700
|
+
const rec = {
|
|
701
|
+
runner: runner.id,
|
|
702
|
+
job: runner.job ?? "(unnamed job)",
|
|
703
|
+
...(runner.jobId === undefined ? {} : { job_id: runner.jobId }),
|
|
704
|
+
tier: runner.tier,
|
|
705
|
+
result: runner.result ?? (runner.cancelSentAt !== undefined ? "cancelled" : "unknown"),
|
|
706
|
+
duration_s: Math.round((end - (runner.jobStartedAt ?? end)) / 100) / 10,
|
|
707
|
+
sandbox_s: Math.round((runner.endedAt - runner.startedAt) / 100) / 10,
|
|
708
|
+
cost_usd: cost,
|
|
709
|
+
...(estimated ? { cost_estimated: true } : {}),
|
|
710
|
+
...(runner.killFailed ? { sandbox_not_ended: true } : {}),
|
|
711
|
+
...(runner.sandboxId ? { sandbox_id: runner.sandboxId } : {}),
|
|
712
|
+
...(runner.runId ? { run_id: runner.runId } : {}),
|
|
713
|
+
};
|
|
714
|
+
this.jobs.push(rec);
|
|
715
|
+
await this.event({ event: "job_done", ...rec }, `${rec.result.padEnd(9)} ${runner.id} ${runner.tier.padEnd(6)} ${rec.job} ${rec.duration_s}s ${estimated ? "~" : ""}${usd(cost)}`);
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
this.idleCost += cost;
|
|
719
|
+
await this.event({
|
|
720
|
+
event: "runner_ended", runner: runner.id, tier: runner.tier, purpose: runner.purpose, cost_usd: cost,
|
|
721
|
+
...(estimated ? { cost_estimated: true } : {}),
|
|
722
|
+
...(runner.killFailed ? { sandbox_not_ended: true } : {}),
|
|
723
|
+
reason: runner.error ? "error" : runner.retireNow || runner.quitSentAt !== undefined ? "stopped" : "exited",
|
|
724
|
+
...(runner.error ? { error: runner.error } : {}),
|
|
725
|
+
...(runner.tail.length && !runner.retireNow ? { last_output: runner.tail } : {}),
|
|
726
|
+
...(runner.sandboxId ? { sandbox_id: runner.sandboxId } : {}),
|
|
727
|
+
}, runner.error ? `runner ${runner.id} failed to start: ${runner.error}` : undefined);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
async endSandbox(runner) {
|
|
731
|
+
const c = this.c;
|
|
732
|
+
if (!runner.sandboxId && !runner.runId && runner.startUnknown) {
|
|
733
|
+
let found;
|
|
734
|
+
for (let i = 0; i < 3; i++) {
|
|
735
|
+
found = await c.sandboxes.list({ labels: { "ci-runner": runner.id }, limit: 10 }).catch(() => undefined);
|
|
736
|
+
if (found !== undefined && found.length > 0)
|
|
737
|
+
break;
|
|
738
|
+
if (i < 2)
|
|
739
|
+
await sleep(2000);
|
|
740
|
+
}
|
|
741
|
+
const look = sh `promigence sandbox list --label ci-runner=${runner.id}`;
|
|
742
|
+
if (found === undefined) {
|
|
743
|
+
this.note(`${runner.id}: its start's answer was lost and the sandbox list could not be read: ${look}`);
|
|
744
|
+
}
|
|
745
|
+
else if (found.length === 0) {
|
|
746
|
+
this.note(`${runner.id}: its start's answer was lost and no sandbox of it was found; one may still start: ${look}, and kill it`);
|
|
747
|
+
}
|
|
748
|
+
for (const sb of found ?? []) {
|
|
749
|
+
if (sb.state === "ended")
|
|
750
|
+
continue;
|
|
751
|
+
if (runner.sandboxId === undefined) {
|
|
752
|
+
runner.sandboxId = sb.sandbox_id;
|
|
753
|
+
if (sb.run_id)
|
|
754
|
+
runner.runId = sb.run_id;
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
await this.killSure(() => c.sandboxes.kill(sb.sandbox_id));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const target = runner.sandboxId ?? runner.runId;
|
|
762
|
+
if (target === undefined)
|
|
763
|
+
return;
|
|
764
|
+
const ok = runner.sandboxId ? await this.killSure(() => c.sandboxes.kill(runner.sandboxId)) : await this.killSure(() => c.runs.kill(runner.runId));
|
|
765
|
+
if (!ok) {
|
|
766
|
+
runner.killFailed = true;
|
|
767
|
+
this.note(`${runner.id}: its sandbox ${target} could not be ended; it ends at its timeout (${sh `promigence sandbox kill ${target}`})`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
async killSure(kill) {
|
|
771
|
+
for (let i = 0; i < 4; i++) {
|
|
772
|
+
try {
|
|
773
|
+
await kill();
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
catch (e) {
|
|
777
|
+
if (e instanceof PromigenceError && (e.code === "not_found" || e.code === "sandbox_ended"))
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
if (i < 3)
|
|
781
|
+
await sleep(1000 * 2 ** i);
|
|
782
|
+
}
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
async costOf(runner) {
|
|
786
|
+
const est = () => {
|
|
787
|
+
const s = Math.ceil(((runner.endedAt ?? Date.now()) - (runner.startedAt)) / 1000);
|
|
788
|
+
return { cost: runner.sandboxId ? Math.round(s * this.opts.perSecond(runner.tier) * 1e6) / 1e6 : 0, billable: runner.sandboxId ? s : 0, estimated: true };
|
|
789
|
+
};
|
|
790
|
+
if (runner.killFailed) {
|
|
791
|
+
const s = Math.ceil(this.opts.timeoutFor(runner.tier) / 1000);
|
|
792
|
+
return { cost: this.opts.capFor(runner.tier), billable: s, estimated: true };
|
|
793
|
+
}
|
|
794
|
+
if (!runner.runId || !runner.sandboxId)
|
|
795
|
+
return { cost: 0, billable: 0, estimated: false };
|
|
796
|
+
for (let i = 0; i < 6; i++) {
|
|
797
|
+
const rep = await this.c.runs.report(runner.runId).catch(() => undefined);
|
|
798
|
+
const row = rep?.sandboxes?.find((s) => s.sandbox_id === runner.sandboxId);
|
|
799
|
+
if (row && typeof row.cost === "number" && row.state === "ended") {
|
|
800
|
+
return { cost: row.cost, billable: row.billable_seconds ?? 0, estimated: false };
|
|
801
|
+
}
|
|
802
|
+
await sleep(i < 2 ? 500 : 1000);
|
|
803
|
+
}
|
|
804
|
+
return est();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const GITHUB_PERMISSIONS_FIX = "The token needs, with --repo, Administration: read and write and Actions: read on the repository (fine-grained), or the classic `repo` scope; " +
|
|
808
|
+
"with --org, Self-hosted runners: read and write on the organization and Actions: read on its repositories (classic: `admin:org` and `repo`).";
|
|
809
|
+
function githubFailure(e, secrets) {
|
|
810
|
+
const msg = gh.redact(e.message, secrets);
|
|
811
|
+
if (e.status === 401) {
|
|
812
|
+
return new PromigenceError({ code: "usage", message: `GitHub refused the token (401): ${msg}`, fix: "The token is wrong, expired or revoked: make a new one (see `promigence runners github --help` for the permissions) and export it again.", exitCode: 2 });
|
|
813
|
+
}
|
|
814
|
+
if (e.status === 403) {
|
|
815
|
+
return new PromigenceError({ code: "usage", message: `GitHub refused the request (403): ${msg}`, fix: GITHUB_PERMISSIONS_FIX, exitCode: 2 });
|
|
816
|
+
}
|
|
817
|
+
if (e.status === 404) {
|
|
818
|
+
return new PromigenceError({ code: "usage", message: `GitHub answered 404: ${msg}`, fix: "Check --repo OWNER/REPO (or --org): it must exist, and the token must be able to see it.", exitCode: 2 });
|
|
819
|
+
}
|
|
820
|
+
return new PromigenceError({ code: "unreachable", message: `GitHub API: ${msg || `HTTP ${e.status}`}`, fix: "GitHub did not answer as expected; this is retried on the next poll. If it repeats, check https://www.githubstatus.com.", exitCode: 9 });
|
|
821
|
+
}
|
|
822
|
+
function tierFlag(ctx) {
|
|
823
|
+
const t = str(ctx, "--tier") ?? "small";
|
|
824
|
+
if (!isRunnerTier(t))
|
|
825
|
+
throw usageError(`--tier: '${t}' is not a runner tier`, `Pass --tier ${RUNNER_TIERS.join(" | ")} (small: ${shape("small")}).`);
|
|
826
|
+
return t;
|
|
827
|
+
}
|
|
828
|
+
function tokenFrom(ctx, flag, defaults, what, example) {
|
|
829
|
+
const named = str(ctx, flag);
|
|
830
|
+
const names = named === undefined ? defaults : [named];
|
|
831
|
+
for (const n of names) {
|
|
832
|
+
const v = process.env[n];
|
|
833
|
+
if (v !== undefined && v.trim() !== "")
|
|
834
|
+
return { value: v.trim(), name: n };
|
|
835
|
+
}
|
|
836
|
+
throw usageError(named === undefined ? `no ${what}: ${defaults.join(" and ")} ${defaults.length > 1 ? "are" : "is"} unset` : `${flag} ${named}: the variable is empty or unset`, `Export it first (${example}), or name another variable with ${flag} VAR. The value is read from the environment only: never pass it on the command line.`);
|
|
837
|
+
}
|
|
838
|
+
async function confirm(question) {
|
|
839
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
840
|
+
try {
|
|
841
|
+
const answer = await new Promise((resolve) => {
|
|
842
|
+
rl.question(question, resolve);
|
|
843
|
+
rl.on("close", () => resolve(""));
|
|
844
|
+
});
|
|
845
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
846
|
+
}
|
|
847
|
+
finally {
|
|
848
|
+
rl.close();
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
async function commonSetup(ctx) {
|
|
852
|
+
const c = client(ctx);
|
|
853
|
+
const me = await c.me();
|
|
854
|
+
const prices = await livePricing(c);
|
|
855
|
+
const perSecond = (tier) => prices.tiers.find((t) => t.name === tier)?.per_second_usd ?? listPerSecond(tier);
|
|
856
|
+
const planMaxS = me.limits?.max_session_s ?? undefined;
|
|
857
|
+
const flag = str(ctx, "--timeout");
|
|
858
|
+
const timeoutMs = flag === undefined ? (planMaxS ? planMaxS * 1000 : 3_600_000) : parseDuration(flag, "--timeout");
|
|
859
|
+
if (timeoutMs < 60_000)
|
|
860
|
+
throw usageError("--timeout: a job needs at least 1m", "Pass --timeout 30m (the longest a job may run).");
|
|
861
|
+
if (planMaxS && timeoutMs > planMaxS * 1000) {
|
|
862
|
+
throw usageError(`--timeout ${flag}: your plan's sandboxes live at most ${secs(planMaxS * 1000)}`, `Pass --timeout ${secs(planMaxS * 1000)} or less (a job longer than that needs another plan).`);
|
|
863
|
+
}
|
|
864
|
+
const capFlag = str(ctx, "--cap");
|
|
865
|
+
const cap = capFlag === undefined ? undefined : parseUsd(capFlag, "--cap");
|
|
866
|
+
let capNote;
|
|
867
|
+
const timeoutFor = (tier) => cap === undefined ? timeoutMs : Math.min(timeoutMs, Math.floor(cap / perSecond(tier)) * 1000);
|
|
868
|
+
const capFor = (tier) => {
|
|
869
|
+
const full = Math.ceil(perSecond(tier) * (timeoutFor(tier) / 1000) * 1e6) / 1e6;
|
|
870
|
+
return cap === undefined ? full : Math.min(cap, full);
|
|
871
|
+
};
|
|
872
|
+
if (cap !== undefined) {
|
|
873
|
+
const small = perSecond(tierFlag(ctx));
|
|
874
|
+
if (timeoutFor(tierFlag(ctx)) < 60_000)
|
|
875
|
+
throw usageError(`--cap ${capFlag} buys less than a minute of a ${tierFlag(ctx)} runner`, `Pass --cap ${usd(small * 600)} or more (ten minutes), or leave --cap off: each job is then capped at its timeout's cost.`);
|
|
876
|
+
const cut = RUNNER_TIERS.filter((t) => timeoutFor(t) < timeoutMs);
|
|
877
|
+
if (cut.length)
|
|
878
|
+
capNote = `--cap ${usd(cap)} limits each job by size: ${cut.map((t) => `${t} ${timeoutFor(t) < 60_000 ? "not run" : secs(timeoutFor(t))}`).join(", ")}`;
|
|
879
|
+
}
|
|
880
|
+
return { c, timeoutMs: timeoutFor(tierFlag(ctx)), timeoutFor, capFor, prices, perSecond, capNote, maxVcpus: me.limits?.max_sandbox_vcpus ?? undefined, maxConcurrency: me.limits?.max_concurrency ?? undefined };
|
|
881
|
+
}
|
|
882
|
+
function clampMax(max, keep, keepFlag, setup) {
|
|
883
|
+
const plan = setup.maxConcurrency;
|
|
884
|
+
if (!plan || plan <= 0 || max <= plan)
|
|
885
|
+
return { max, note: undefined };
|
|
886
|
+
if (keep > plan) {
|
|
887
|
+
throw usageError(`${keepFlag} ${keep} is more than your plan's ${plan} sandboxes at once`, `Pass ${keepFlag} ${plan} or less (\`promigence me\` shows the limit).`);
|
|
888
|
+
}
|
|
889
|
+
return { max: plan, note: `--max ${max} is over your plan's ${plan} sandboxes at once: up to ${plan} run at once` };
|
|
890
|
+
}
|
|
891
|
+
async function ensureSnapshot(ctx, c, kind, tier, mid) {
|
|
892
|
+
const s = runnerImageSpec(kind, tier);
|
|
893
|
+
try {
|
|
894
|
+
const rec = await c.snapshots.get(s.alias);
|
|
895
|
+
if (rec?.snapshot_id && !rec.unavailable)
|
|
896
|
+
return { id: rec.snapshot_id, built: false };
|
|
897
|
+
}
|
|
898
|
+
catch (e) {
|
|
899
|
+
if (!(e instanceof PromigenceError) || e.code !== "not_found")
|
|
900
|
+
throw e;
|
|
901
|
+
}
|
|
902
|
+
const prices = await livePricing(c);
|
|
903
|
+
const most = buildPriceIn(prices, tier);
|
|
904
|
+
const price = `up to ${usd(most.most_usd)}, billed per second while it builds`;
|
|
905
|
+
const listed = prices.source === "compiled" ? ` (${COMPILED_NOTE})` : "";
|
|
906
|
+
const yes = bool(ctx, "--yes");
|
|
907
|
+
const what = `the ${kind === "github" ? "GitHub Actions" : "GitLab"} runner snapshot for ${tier} (${shape(tier)})`;
|
|
908
|
+
if (!yes) {
|
|
909
|
+
const interactive = process.stdin.isTTY === true && process.stderr.isTTY === true && ctx.mode === "text" && !mid;
|
|
910
|
+
if (!interactive) {
|
|
911
|
+
if (mid)
|
|
912
|
+
return undefined;
|
|
913
|
+
throw usageError(`${what} is not built yet: building it once costs ${price} (about two minutes)${listed}`, `Pass --yes to build it, or build it first: ${sh `promigence runners ${kind} --prepare --tier ${tier} --yes`}`);
|
|
914
|
+
}
|
|
915
|
+
const ok = await confirm(`${what} is not built yet. Build it now? One time, ${price}${listed}, about two minutes. [y/N] `);
|
|
916
|
+
if (!ok)
|
|
917
|
+
return undefined;
|
|
918
|
+
}
|
|
919
|
+
const b = await c.snapshots.create({ image: s.image, setup: runnerBuildSteps(s), verify: { cmd: s.verify }, tier, alias: s.alias });
|
|
920
|
+
const charged = b.deduped || b.joined ? 0 : typeof b.build_price === "number" ? b.build_price : most.most_usd;
|
|
921
|
+
const said = buildPriceOf(b);
|
|
922
|
+
await write(process.stderr, ctx.mode === "json"
|
|
923
|
+
? jsonLine(process.stderr, {
|
|
924
|
+
hint: "build_price", build_price: charged, unit: "USD", tier, alias: s.alias, build_id: b.build_id,
|
|
925
|
+
...(charged > 0 && said ? { per_second_usd: said.per_second_usd, max_seconds: said.max_s } : {}),
|
|
926
|
+
})
|
|
927
|
+
: `building ${s.alias}: ${charged === 0 ? `${usd(0)} (an identical build exists)` : said ? buildPriceWords(said) : usd(charged)}, build ${b.build_id}; jobs start once it is verified\n`);
|
|
928
|
+
let build = b;
|
|
929
|
+
if (!(b.deduped || b.state === "verified")) {
|
|
930
|
+
const w = await c.snapshots.waitBuild(b.build_id, { waitTimeoutMs: BUILD_WAIT_MS });
|
|
931
|
+
build = w.build;
|
|
932
|
+
if (w.timedOut) {
|
|
933
|
+
throw new PromigenceError({ code: "timeout", message: `the runner snapshot build ${b.build_id} is still running after ${secs(BUILD_WAIT_MS)}`, fix: `Wait for it (${sh `promigence snapshot status ${b.build_id} --wait`}), then start again.`, exitCode: 11 });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (build.state !== "verified" || !build.snapshot_id) {
|
|
937
|
+
const log = await c.snapshots.buildLog(b.build_id, 15).catch(() => []);
|
|
938
|
+
throw new PromigenceError({
|
|
939
|
+
code: "env_failed",
|
|
940
|
+
message: `the runner snapshot build ${b.build_id} ended ${build.state}${build.error?.message ? `: ${build.error.message}` : ""}${log.length ? ` (last log line: ${log[log.length - 1]})` : ""}`,
|
|
941
|
+
fix: `${typeof build.cost_usd === "number" && build.cost_usd > 0 ? `Nothing was stored; the time it ran was billed (${usd(build.cost_usd)}).` : "Nothing was stored or charged."} Read ${sh `promigence snapshot status ${b.build_id} --log 80`}, and tell support@promigence.ai if it is not your network.`,
|
|
942
|
+
exitCode: 3,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
if (build.alias !== s.alias)
|
|
946
|
+
await c.snapshots.alias(build.snapshot_id, s.alias).catch(() => undefined);
|
|
947
|
+
return { id: build.snapshot_id, built: true, buildId: b.build_id, price: charged };
|
|
948
|
+
}
|
|
949
|
+
async function prepare(ctx, kind) {
|
|
950
|
+
const c = client(ctx);
|
|
951
|
+
const tier = tierFlag(ctx);
|
|
952
|
+
const r = await ensureSnapshot(ctx, c, kind, tier, false);
|
|
953
|
+
if (!r)
|
|
954
|
+
throw usageError("not built: the build was declined", `Run it again and answer y, or pass --yes.`);
|
|
955
|
+
const alias = runnerAlias(kind, tier);
|
|
956
|
+
const next = nexts([kind === "github" ? "promigence runners github --repo OWNER/REPO" : "promigence runners gitlab --url https://gitlab.com", "start taking jobs"]);
|
|
957
|
+
return emit(ctx.mode, okEnvelope(ctx.cmd.name, { kind, tier, alias, snapshot_id: r.id, built: r.built, ...(r.buildId ? { build_id: r.buildId, build_price: r.price } : {}) }, next), [
|
|
958
|
+
`${alias}: ${r.built ? "built" : "already built"} (${r.id})`,
|
|
959
|
+
` next: ${next[0].cmd}`,
|
|
960
|
+
], 0, ctx.pointer);
|
|
961
|
+
}
|
|
962
|
+
async function runPool(ctx, pool, intro, introFields) {
|
|
963
|
+
const onSignal = (sig) => {
|
|
964
|
+
if (!pool.stopping) {
|
|
965
|
+
pool.stopping = sig;
|
|
966
|
+
pool.note(pool.opts.killOnExit ? "stopping: cancelling running jobs" : "stopping: running jobs finish first (Ctrl-C again cancels them)");
|
|
967
|
+
}
|
|
968
|
+
else if (!pool.killing) {
|
|
969
|
+
pool.killing = true;
|
|
970
|
+
pool.note("cancelling running jobs");
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
void Promise.allSettled(pool.live().filter((s) => s.sandboxId).map((s) => pool.c.sandboxes.kill(s.sandboxId)))
|
|
974
|
+
.then(() => process.exit(130));
|
|
975
|
+
}
|
|
976
|
+
pool.wake();
|
|
977
|
+
};
|
|
978
|
+
const handlers = ["SIGINT", "SIGTERM", "SIGHUP"].map((sig) => [sig, () => onSignal(sig)]);
|
|
979
|
+
for (const [sig, h] of handlers)
|
|
980
|
+
process.on(sig, h);
|
|
981
|
+
await pool.event({ event: "pool_started", ...introFields }, undefined);
|
|
982
|
+
if (ctx.mode === "text")
|
|
983
|
+
await write(process.stdout, intro.map((l) => escapeLine(l)).join("\n") + "\n");
|
|
984
|
+
const registered = registerPool({ pool_id: pool.opts.poolId, pid: process.pid, scope: pool.opts.scopeKey, started_at: new Date().toISOString() });
|
|
985
|
+
await pool.reapOrphans();
|
|
986
|
+
await pool.run();
|
|
987
|
+
if (registered && !pool.runners.some((r) => r.killFailed))
|
|
988
|
+
unregisterPool(pool.opts.poolId);
|
|
989
|
+
for (const [sig, h] of handlers)
|
|
990
|
+
process.off(sig, h);
|
|
991
|
+
const jobs = pool.jobs;
|
|
992
|
+
const by = (r) => jobs.filter((j) => j.result.toLowerCase() === r).length;
|
|
993
|
+
const jobCost = jobs.reduce((a, j) => a + j.cost_usd, 0);
|
|
994
|
+
const total = Math.round((jobCost + pool.idleCost) * 1e6) / 1e6;
|
|
995
|
+
const summary = {
|
|
996
|
+
stopped_by: pool.fatal ? "error" : pool.stopping ?? "signal",
|
|
997
|
+
jobs: { total: jobs.length, succeeded: by("succeeded") + by("success"), failed: by("failed"), cancelled: by("cancelled") + by("canceled"), other: 0 },
|
|
998
|
+
job_seconds: Math.round(jobs.reduce((a, j) => a + j.duration_s, 0) * 10) / 10,
|
|
999
|
+
cost_usd: total,
|
|
1000
|
+
jobs_cost_usd: Math.round(jobCost * 1e6) / 1e6,
|
|
1001
|
+
idle_cost_usd: Math.round(pool.idleCost * 1e6) / 1e6,
|
|
1002
|
+
runners_started: pool.runnersStarted,
|
|
1003
|
+
job_list: jobs,
|
|
1004
|
+
};
|
|
1005
|
+
summary.jobs.other = jobs.length - summary.jobs.succeeded - summary.jobs.failed - summary.jobs.cancelled;
|
|
1006
|
+
const lines = [
|
|
1007
|
+
`runners ${pool.opts.kind}: stopped${pool.fatal ? " on an error" : ""}. ${jobs.length} job${jobs.length === 1 ? "" : "s"}: ${summary.jobs.succeeded} succeeded, ${summary.jobs.failed} failed, ${summary.jobs.cancelled} cancelled` +
|
|
1008
|
+
(summary.jobs.other ? `, ${summary.jobs.other} other` : ""),
|
|
1009
|
+
` ${secs(summary.job_seconds * 1000)} of jobs · ${usd(total)} (jobs ${usd(jobCost)}, idle runners ${usd(pool.idleCost)}) · ${pool.runnersStarted} sandbox${pool.runnersStarted === 1 ? "" : "es"}`,
|
|
1010
|
+
];
|
|
1011
|
+
if (pool.fatal) {
|
|
1012
|
+
const err = pool.fatal;
|
|
1013
|
+
const next = nexts(["promigence sandbox list --label ci-pool=" + pool.opts.poolId + " --state ready,running", "anything this pool left running"]);
|
|
1014
|
+
const env = { ...errorEnvelope(ctx.cmd.name, err, next), summary };
|
|
1015
|
+
return emit(ctx.mode, env, [...errorTextLines(ctx.cmd.name, err, err.cliExit, next), ...lines], err.cliExit, ctx.pointer);
|
|
1016
|
+
}
|
|
1017
|
+
const next = nexts([sh `promigence sandbox list --label ci-pool=${pool.opts.poolId}`, "the sandboxes this pool used"]);
|
|
1018
|
+
return emit(ctx.mode, okEnvelope(ctx.cmd.name, summary, next), lines, 130, ctx.pointer);
|
|
1019
|
+
}
|
|
1020
|
+
class GithubProvider {
|
|
1021
|
+
api;
|
|
1022
|
+
scope;
|
|
1023
|
+
policy;
|
|
1024
|
+
groupId;
|
|
1025
|
+
kind = "github";
|
|
1026
|
+
launcher = GITHUB_LAUNCHER;
|
|
1027
|
+
streams = ["stdout"];
|
|
1028
|
+
exitsByItself = false;
|
|
1029
|
+
unassignedHint = "GitHub did not hand it to the runner started for it (may the repository use this runner group? with --org, the Default group does not take public repositories)";
|
|
1030
|
+
#repos = [];
|
|
1031
|
+
#reposAt = 0;
|
|
1032
|
+
constructor(api, scope, policy, groupId) {
|
|
1033
|
+
this.api = api;
|
|
1034
|
+
this.scope = scope;
|
|
1035
|
+
this.policy = policy;
|
|
1036
|
+
this.groupId = groupId;
|
|
1037
|
+
if (scope.kind === "repo")
|
|
1038
|
+
this.#repos = [scope.name];
|
|
1039
|
+
}
|
|
1040
|
+
secrets() {
|
|
1041
|
+
return [this.api.secret];
|
|
1042
|
+
}
|
|
1043
|
+
async demand(note) {
|
|
1044
|
+
try {
|
|
1045
|
+
if (this.scope.kind === "org" && Date.now() - this.#reposAt > 5 * 60_000) {
|
|
1046
|
+
const list = await gh.orgRepos(this.api, this.scope.name);
|
|
1047
|
+
this.#repos = list.repos;
|
|
1048
|
+
this.#reposAt = Date.now();
|
|
1049
|
+
if (list.more)
|
|
1050
|
+
note(`${this.scope.name} has more than 100 repositories: only the 100 most recently pushed are watched`);
|
|
1051
|
+
}
|
|
1052
|
+
const jobs = await gh.queuedJobs(this.api, this.#repos, this.scope.kind === "org"
|
|
1053
|
+
? (repo) => {
|
|
1054
|
+
this.#repos = this.#repos.filter((r) => r !== repo);
|
|
1055
|
+
note(`${repo}: GitHub answered 404 (deleted, moved, or not visible to the token): not watched until the repository list is read again`);
|
|
1056
|
+
}
|
|
1057
|
+
: undefined);
|
|
1058
|
+
const items = [];
|
|
1059
|
+
for (const j of jobs) {
|
|
1060
|
+
const route = gh.routeJob(j.labels, this.policy);
|
|
1061
|
+
if (!route)
|
|
1062
|
+
continue;
|
|
1063
|
+
items.push({
|
|
1064
|
+
id: j.id, tier: route.tier, labels: route.labels, key: route.key,
|
|
1065
|
+
title: `${j.repo}: ${j.workflow_name ? `${j.workflow_name} / ` : ""}${j.name}`, name: j.name,
|
|
1066
|
+
...(j.created_at ? { createdAt: j.created_at } : {}),
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
items.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
1070
|
+
return items;
|
|
1071
|
+
}
|
|
1072
|
+
catch (e) {
|
|
1073
|
+
if (e instanceof gh.GitHubError && (e.status === 429 || e.status === 0 || e.status >= 500)) {
|
|
1074
|
+
note(gh.redact(e.message, [this.api.secret]));
|
|
1075
|
+
return undefined;
|
|
1076
|
+
}
|
|
1077
|
+
if (e instanceof gh.GitHubError)
|
|
1078
|
+
throw githubFailure(e, [this.api.secret]);
|
|
1079
|
+
throw e;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
async credentials(runner) {
|
|
1083
|
+
const jit = await gh.mintJitConfig(this.api, this.scope, { name: runner.id, labels: runner.labels, groupId: this.groupId });
|
|
1084
|
+
runner.ghRunnerId = jit.runnerId;
|
|
1085
|
+
return { path: GITHUB_CONFIG_PATH, content: jit.encoded };
|
|
1086
|
+
}
|
|
1087
|
+
onLine(runner, line) {
|
|
1088
|
+
const at = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\dZ):/.exec(line);
|
|
1089
|
+
const when = at ? Date.parse(at[1].replace(" ", "T")) : Date.now();
|
|
1090
|
+
if (/Listening for Jobs/.test(line))
|
|
1091
|
+
runner.listeningAt ??= Date.now();
|
|
1092
|
+
const run = /Running job: (.+)$/.exec(line);
|
|
1093
|
+
if (run) {
|
|
1094
|
+
runner.phase = "busy";
|
|
1095
|
+
runner.job = run[1].trim();
|
|
1096
|
+
runner.jobStartedAt = Number.isFinite(when) ? when : Date.now();
|
|
1097
|
+
}
|
|
1098
|
+
const done = /Job (.+) completed with result: (\w+)/.exec(line);
|
|
1099
|
+
if (done) {
|
|
1100
|
+
runner.result = done[2];
|
|
1101
|
+
runner.jobEndedAt = Number.isFinite(when) ? when : Date.now();
|
|
1102
|
+
runner.job ??= done[1].trim();
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
covers(runner, item) {
|
|
1106
|
+
return gh.covers(runner.labels, item.labels);
|
|
1107
|
+
}
|
|
1108
|
+
async retire(runner) {
|
|
1109
|
+
if (runner.jobStartedAt !== undefined)
|
|
1110
|
+
return "no";
|
|
1111
|
+
if (runner.ghRunnerId === undefined)
|
|
1112
|
+
return "now";
|
|
1113
|
+
const r = await gh.getRunner(this.api, this.scope, runner.ghRunnerId);
|
|
1114
|
+
if (r?.busy)
|
|
1115
|
+
return "no";
|
|
1116
|
+
await gh.deleteRunner(this.api, this.scope, runner.ghRunnerId);
|
|
1117
|
+
delete runner.ghRunnerId;
|
|
1118
|
+
return "now";
|
|
1119
|
+
}
|
|
1120
|
+
async orphanIdle(runnerName) {
|
|
1121
|
+
const r = await gh.findRunnerByName(this.api, this.scope, runnerName);
|
|
1122
|
+
if (!r || r.busy)
|
|
1123
|
+
return false;
|
|
1124
|
+
await gh.deleteRunner(this.api, this.scope, r.id);
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
async cleanup(runner) {
|
|
1128
|
+
if (runner.ghRunnerId !== undefined && runner.jobStartedAt === undefined) {
|
|
1129
|
+
await gh.deleteRunner(this.api, this.scope, runner.ghRunnerId).catch(() => undefined);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
route(item) {
|
|
1133
|
+
return { tier: item.tier, labels: item.labels, key: item.key };
|
|
1134
|
+
}
|
|
1135
|
+
keepRoute() {
|
|
1136
|
+
const labels = gh.warmLabels(this.policy);
|
|
1137
|
+
return { tier: this.policy.defaultTier, labels, key: labels.join(",") };
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const runnersGithub = async (ctx) => {
|
|
1141
|
+
const tier = tierFlag(ctx);
|
|
1142
|
+
if (bool(ctx, "--prepare"))
|
|
1143
|
+
return prepare(ctx, "github");
|
|
1144
|
+
const repo = str(ctx, "--repo");
|
|
1145
|
+
const org = str(ctx, "--org");
|
|
1146
|
+
if ((repo === undefined) === (org === undefined)) {
|
|
1147
|
+
throw usageError("pass --repo OWNER/REPO or --org ORG (one of them)", "e.g. promigence runners github --repo acme/app: runners for one repository (--org: for every repository of an organization).");
|
|
1148
|
+
}
|
|
1149
|
+
if (repo !== undefined && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
1150
|
+
throw usageError(`--repo: '${repo}' is not OWNER/REPO`, "Pass --repo acme/app (the part of the URL after github.com/).");
|
|
1151
|
+
}
|
|
1152
|
+
if (org !== undefined && !/^[A-Za-z0-9-]+$/.test(org))
|
|
1153
|
+
throw usageError(`--org: '${org}' is not an organization name`, "Pass --org acme.");
|
|
1154
|
+
const group = str(ctx, "--runner-group");
|
|
1155
|
+
if (group !== undefined && org === undefined)
|
|
1156
|
+
throw usageError("--runner-group needs --org", "Runner groups belong to an organization: pass --org ORG with it.");
|
|
1157
|
+
const pool = gh.parsePoolLabels(str(ctx, "--labels"));
|
|
1158
|
+
const bad = pool.find((l) => !gh.validLabel(l) || /^promigence-(small|medium|large)$/.test(l) || gh.DEFAULT_RUNNER_LABELS.includes(l));
|
|
1159
|
+
if (pool.length === 0 || bad !== undefined) {
|
|
1160
|
+
throw usageError(`--labels: '${bad ?? ""}' cannot be a pool label`, "Pass --labels with your own names (letters, digits, . _ -), e.g. --labels promigence,ci; the tier labels promigence-small|medium|large and self-hosted/linux/x64 are always understood.");
|
|
1161
|
+
}
|
|
1162
|
+
const warm = str(ctx, "--warm") === undefined ? 0 : parseCountOrZero(str(ctx, "--warm"), "--warm");
|
|
1163
|
+
let max = str(ctx, "--max") === undefined ? DEFAULT_MAX : parseCount(str(ctx, "--max"), "--max");
|
|
1164
|
+
if (warm > max)
|
|
1165
|
+
throw usageError(`--warm ${warm} is more than --max ${max}`, "Keep --warm at or below --max.");
|
|
1166
|
+
const idleTimeoutMs = str(ctx, "--idle-timeout") === undefined ? IDLE_TIMEOUT_DEFAULT_MS : parseDuration(str(ctx, "--idle-timeout"), "--idle-timeout");
|
|
1167
|
+
const pollFlag = str(ctx, "--poll");
|
|
1168
|
+
const tickMs = pollFlag === undefined ? TICK_MS : Math.max(100, parseDuration(pollFlag, "--poll"));
|
|
1169
|
+
const pollMs = tickMs;
|
|
1170
|
+
const token = tokenFrom(ctx, "--token-env", ["GH_TOKEN", "GITHUB_TOKEN"], "GitHub token", "export GH_TOKEN=…");
|
|
1171
|
+
const apiUrl = str(ctx, "--github-api") ?? gh.GITHUB_API_DEFAULT;
|
|
1172
|
+
if (!/^https?:\/\//.test(apiUrl))
|
|
1173
|
+
throw usageError(`--github-api: '${apiUrl}' is not a URL`, "Pass the API's URL, e.g. https://github.example.com/api/v3 (GitHub Enterprise Server).");
|
|
1174
|
+
const setup = await commonSetup(ctx);
|
|
1175
|
+
const c = setup.c;
|
|
1176
|
+
const clamped = clampMax(max, warm, "--warm", setup);
|
|
1177
|
+
max = clamped.max;
|
|
1178
|
+
const api = new gh.GitHubApi({ apiUrl, token: token.value });
|
|
1179
|
+
const scope = repo !== undefined ? { kind: "repo", name: repo } : { kind: "org", name: org };
|
|
1180
|
+
let groupId = 1;
|
|
1181
|
+
try {
|
|
1182
|
+
await gh.checkAccess(api, scope);
|
|
1183
|
+
if (scope.kind === "org")
|
|
1184
|
+
groupId = await gh.runnerGroupId(api, scope.name, group);
|
|
1185
|
+
}
|
|
1186
|
+
catch (e) {
|
|
1187
|
+
if (e instanceof gh.GitHubError)
|
|
1188
|
+
throw githubFailure(e, [token.value]);
|
|
1189
|
+
throw e;
|
|
1190
|
+
}
|
|
1191
|
+
const snapshots = new Map();
|
|
1192
|
+
const first = await ensureSnapshot(ctx, c, "github", tier, false);
|
|
1193
|
+
if (!first)
|
|
1194
|
+
throw usageError("not started: the runner snapshot was not built", "Run it again and answer y, or pass --yes.");
|
|
1195
|
+
snapshots.set(tier, first.id);
|
|
1196
|
+
for (const t of RUNNER_TIERS) {
|
|
1197
|
+
if (t === tier)
|
|
1198
|
+
continue;
|
|
1199
|
+
const rec = await c.snapshots.get(runnerAlias("github", t)).catch(() => undefined);
|
|
1200
|
+
if (rec?.snapshot_id)
|
|
1201
|
+
snapshots.set(t, rec.snapshot_id);
|
|
1202
|
+
}
|
|
1203
|
+
const policy = { pool, defaultTier: tier };
|
|
1204
|
+
const provider = new GithubProvider(api, scope, policy, groupId);
|
|
1205
|
+
const poolId = randomBytes(4).toString("hex");
|
|
1206
|
+
const building = new Set();
|
|
1207
|
+
const opts = {
|
|
1208
|
+
kind: "github", scopeLabel: scope.name, snapshots, max, keep: warm, keepPurpose: "warm",
|
|
1209
|
+
timeoutFor: setup.timeoutFor, capFor: setup.capFor, perSecond: setup.perSecond, tickMs, pollMs, idleTimeoutMs,
|
|
1210
|
+
killOnExit: bool(ctx, "--kill-on-exit"), poolId, selfEndGraceS: graceFor(tickMs, TICK_MS),
|
|
1211
|
+
scopeKey: `github ${c.apiUrl} ${apiUrl} ${scope.kind}:${scope.name}`,
|
|
1212
|
+
ensureSnapshot: async (t) => {
|
|
1213
|
+
if (snapshots.has(t))
|
|
1214
|
+
return snapshots.get(t);
|
|
1215
|
+
if (!bool(ctx, "--yes")) {
|
|
1216
|
+
poolRef?.note(`a job asks for promigence-${t}: its runner snapshot is not built (${sh `promigence runners github --prepare --tier ${t} --yes`}, once: up to ${usd(buildPriceIn(setup.prices, t).most_usd)}, billed per second)`);
|
|
1217
|
+
return undefined;
|
|
1218
|
+
}
|
|
1219
|
+
if (building.has(t))
|
|
1220
|
+
return undefined;
|
|
1221
|
+
building.add(t);
|
|
1222
|
+
void ensureSnapshot(ctx, c, "github", t, true).then((r) => { if (r)
|
|
1223
|
+
snapshots.set(t, r.id); })
|
|
1224
|
+
.catch((e) => poolRef?.note(`building the ${t} runner snapshot failed: ${String(e?.message ?? e).slice(0, 160)}`))
|
|
1225
|
+
.finally(() => building.delete(t));
|
|
1226
|
+
return undefined;
|
|
1227
|
+
},
|
|
1228
|
+
};
|
|
1229
|
+
const poolObj = new Pool(ctx, c, opts, provider);
|
|
1230
|
+
poolRef = poolObj;
|
|
1231
|
+
const rate = setup.perSecond(tier);
|
|
1232
|
+
const intro = [
|
|
1233
|
+
`runners github: ${scope.kind === "repo" ? "repository" : "organization"} ${scope.name}${scope.kind === "org" && group ? ` (runner group ${group})` : ""}`,
|
|
1234
|
+
` in your workflow: runs-on: ${pool[0]} (a size: runs-on: promigence-medium | promigence-large)`,
|
|
1235
|
+
` default tier ${tier}: ${shape(tier)}, ${usd(rate * 3600)}/h billed per second; runner snapshots: ${[...snapshots.keys()].join(", ")}`,
|
|
1236
|
+
` up to ${max} job${max === 1 ? "" : "s"} at once, each at most ${secs(setup.timeoutMs)} and ${usd(setup.capFor(tier))} (${tier})` +
|
|
1237
|
+
(warm ? `; ${warm} warm runner${warm === 1 ? "" : "s"} kept waiting (they bill while idle: ${usd(rate * 3600 * warm)}/h)` : "; runners start on demand (nothing bills while no job is queued)"),
|
|
1238
|
+
...(setup.capNote ? [` note: ${setup.capNote}`] : []),
|
|
1239
|
+
...(setup.prices.source === "compiled" ? [` note: prices: ${COMPILED_NOTE}`] : []),
|
|
1240
|
+
...(clamped.note ? [` note: ${clamped.note}`] : []),
|
|
1241
|
+
" Ctrl-C stops taking jobs and waits for running ones; a second Ctrl-C cancels them.",
|
|
1242
|
+
];
|
|
1243
|
+
return runPool(ctx, poolObj, intro, {
|
|
1244
|
+
kind: "github", scope: scope.kind, name: scope.name, labels: pool, tier, tiers_ready: [...snapshots.keys()],
|
|
1245
|
+
max, warm, timeout_s: setup.timeoutMs / 1000, cap_usd: setup.capFor(tier), price_source: setup.prices.source, pool_id: poolId, token_env: token.name,
|
|
1246
|
+
});
|
|
1247
|
+
};
|
|
1248
|
+
let poolRef;
|
|
1249
|
+
function graceFor(tickMs, defaultTick) {
|
|
1250
|
+
return Math.max(1, Math.round(SELF_END_GRACE_S * Math.min(1, tickMs / defaultTick)));
|
|
1251
|
+
}
|
|
1252
|
+
function parseCountOrZero(v, flag) {
|
|
1253
|
+
if (v.trim() === "0")
|
|
1254
|
+
return 0;
|
|
1255
|
+
return parseCount(v, flag);
|
|
1256
|
+
}
|
|
1257
|
+
class GitlabProvider {
|
|
1258
|
+
cfg;
|
|
1259
|
+
tier;
|
|
1260
|
+
seed;
|
|
1261
|
+
api;
|
|
1262
|
+
waitTimeoutS;
|
|
1263
|
+
kind = "gitlab";
|
|
1264
|
+
launcher = GITLAB_LAUNCHER;
|
|
1265
|
+
streams = ["stderr", "stdout"];
|
|
1266
|
+
exitsByItself = true;
|
|
1267
|
+
unassignedHint = "GitLab did not hand it to the runner started for it (a protected branch the runner may not run, or a paused runner?)";
|
|
1268
|
+
#runner;
|
|
1269
|
+
#projects = [];
|
|
1270
|
+
#projectsAt = 0;
|
|
1271
|
+
constructor(cfg, tier, seed, api, waitTimeoutS) {
|
|
1272
|
+
this.cfg = cfg;
|
|
1273
|
+
this.tier = tier;
|
|
1274
|
+
this.seed = seed;
|
|
1275
|
+
this.api = api;
|
|
1276
|
+
this.waitTimeoutS = waitTimeoutS;
|
|
1277
|
+
}
|
|
1278
|
+
secrets() {
|
|
1279
|
+
return [this.cfg.token, this.api?.secret];
|
|
1280
|
+
}
|
|
1281
|
+
async init() {
|
|
1282
|
+
if (!this.api)
|
|
1283
|
+
return undefined;
|
|
1284
|
+
this.#runner = await this.api.runner(this.cfg.token);
|
|
1285
|
+
return this.#runner;
|
|
1286
|
+
}
|
|
1287
|
+
async demand(note) {
|
|
1288
|
+
if (!this.api || !this.#runner)
|
|
1289
|
+
return [];
|
|
1290
|
+
try {
|
|
1291
|
+
if (Date.now() - this.#projectsAt > 5 * 60_000) {
|
|
1292
|
+
const ps = new Set(this.#runner.projects);
|
|
1293
|
+
for (const g of this.#runner.groups)
|
|
1294
|
+
for (const p of await this.api.groupProjects(g))
|
|
1295
|
+
ps.add(p);
|
|
1296
|
+
this.#projects = [...ps];
|
|
1297
|
+
this.#projectsAt = Date.now();
|
|
1298
|
+
}
|
|
1299
|
+
const items = [];
|
|
1300
|
+
for (const p of this.#projects) {
|
|
1301
|
+
for (const j of await this.api.pendingJobs(p)) {
|
|
1302
|
+
if (!gl.runnerTakes(this.#runner, j))
|
|
1303
|
+
continue;
|
|
1304
|
+
items.push({ id: j.id, tier: this.tier, labels: [], key: this.tier, title: `${j.name} (#${j.id})` });
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return items;
|
|
1308
|
+
}
|
|
1309
|
+
catch (e) {
|
|
1310
|
+
if (e instanceof gh.GitHubError && (e.status === 429 || e.status === 0 || e.status >= 500)) {
|
|
1311
|
+
note(gh.redact(e.message, this.secrets()));
|
|
1312
|
+
return undefined;
|
|
1313
|
+
}
|
|
1314
|
+
if (e instanceof gh.GitHubError) {
|
|
1315
|
+
throw new PromigenceError({ code: "usage", message: `GitLab API ${e.status}: ${gh.redact(e.message, this.secrets())}`, fix: "The API token needs read_api on the runner's projects (a personal, group or project access token).", exitCode: 2 });
|
|
1316
|
+
}
|
|
1317
|
+
throw e;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
async credentials(runner) {
|
|
1321
|
+
return {
|
|
1322
|
+
path: GITLAB_CONFIG_PATH,
|
|
1323
|
+
content: gl.gitlabConfigToml(this.cfg),
|
|
1324
|
+
env: {
|
|
1325
|
+
CI_SYSTEM_ID: gl.systemId(this.seed, runner.index % 64),
|
|
1326
|
+
...(this.waitTimeoutS === undefined ? {} : { CI_WAIT_TIMEOUT: String(this.waitTimeoutS) }),
|
|
1327
|
+
},
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
onLine(runner, line) {
|
|
1331
|
+
if (/Starting runner for/.test(line) || /Checking for jobs/.test(line))
|
|
1332
|
+
runner.listeningAt ??= Date.now();
|
|
1333
|
+
if (/Checking for jobs\.\.\.\s+received/.test(line) || (/received/.test(line) && /\bjob=\d+/.test(line))) {
|
|
1334
|
+
runner.phase = "busy";
|
|
1335
|
+
runner.jobStartedAt ??= Date.now();
|
|
1336
|
+
const id = gl.receivedJobId(line);
|
|
1337
|
+
if (id !== undefined)
|
|
1338
|
+
runner.jobId = id;
|
|
1339
|
+
const name = /job_name=("(?:[^"\\]|\\.)*"|\S+)/.exec(line);
|
|
1340
|
+
const n = name ? (name[1].startsWith('"') ? safeJson(name[1]) : name[1]) : undefined;
|
|
1341
|
+
runner.job = `${n ?? "job"}${id !== undefined ? ` (#${id})` : ""}`;
|
|
1342
|
+
}
|
|
1343
|
+
if (/Job succeeded/.test(line)) {
|
|
1344
|
+
runner.result = "success";
|
|
1345
|
+
runner.jobEndedAt ??= Date.now();
|
|
1346
|
+
}
|
|
1347
|
+
else if (/Job failed/.test(line)) {
|
|
1348
|
+
runner.result = /canceled|cancelled/.test(line) ? "cancelled" : "failed";
|
|
1349
|
+
runner.jobEndedAt ??= Date.now();
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
covers() {
|
|
1353
|
+
return true;
|
|
1354
|
+
}
|
|
1355
|
+
async retire(runner) {
|
|
1356
|
+
return runner.jobStartedAt === undefined ? "graceful" : "no";
|
|
1357
|
+
}
|
|
1358
|
+
async cleanup() { }
|
|
1359
|
+
async orphanIdle() {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
route() {
|
|
1363
|
+
return { tier: this.tier, labels: [], key: this.tier };
|
|
1364
|
+
}
|
|
1365
|
+
keepRoute() {
|
|
1366
|
+
return this.route();
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
function safeJson(s) {
|
|
1370
|
+
try {
|
|
1371
|
+
return JSON.parse(s);
|
|
1372
|
+
}
|
|
1373
|
+
catch {
|
|
1374
|
+
return undefined;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
export const GITLAB_TOKEN_NOTICE = "jobs on this runner can read its token (glrt-…): run it for projects whose CI code you trust";
|
|
1378
|
+
const runnersGitlab = async (ctx) => {
|
|
1379
|
+
const tier = tierFlag(ctx);
|
|
1380
|
+
if (bool(ctx, "--prepare"))
|
|
1381
|
+
return prepare(ctx, "gitlab");
|
|
1382
|
+
const url = (str(ctx, "--url") ?? gl.GITLAB_URL_DEFAULT).replace(/\/+$/, "");
|
|
1383
|
+
if (!/^https?:\/\/[^\s/]+/.test(url))
|
|
1384
|
+
throw usageError(`--url: '${url}' is not a GitLab URL`, "Pass --url https://gitlab.com or your instance's address.");
|
|
1385
|
+
const executor = str(ctx, "--executor") ?? "docker";
|
|
1386
|
+
if (!gl.GITLAB_EXECUTORS.includes(executor)) {
|
|
1387
|
+
throw usageError(`--executor: '${executor}' is not one this runs`, "Pass --executor docker (jobs in containers, the default) or shell (jobs directly in the sandbox).");
|
|
1388
|
+
}
|
|
1389
|
+
const defaultImage = str(ctx, "--default-image") ?? gl.GITLAB_DEFAULT_IMAGE;
|
|
1390
|
+
const token = tokenFrom(ctx, "--token-env", ["GITLAB_RUNNER_TOKEN"], "runner authentication token", "export GITLAB_RUNNER_TOKEN=glrt-…, from the runner you created in GitLab with its tags");
|
|
1391
|
+
const apiTokenName = str(ctx, "--api-token-env");
|
|
1392
|
+
const apiToken = apiTokenName === undefined ? undefined : tokenFrom(ctx, "--api-token-env", [apiTokenName], "GitLab API token", `export ${apiTokenName}=… (read_api)`);
|
|
1393
|
+
const poolSize = str(ctx, "--pool") === undefined ? (apiToken ? 0 : 1) : parseCountOrZero(str(ctx, "--pool"), "--pool");
|
|
1394
|
+
let max = str(ctx, "--max") === undefined ? Math.max(DEFAULT_MAX, poolSize) : parseCount(str(ctx, "--max"), "--max");
|
|
1395
|
+
if (poolSize > max)
|
|
1396
|
+
throw usageError(`--pool ${poolSize} is more than --max ${max}`, "Keep --pool at or below --max.");
|
|
1397
|
+
if (poolSize === 0 && !apiToken)
|
|
1398
|
+
throw usageError("--pool 0 without --api-token-env would never take a job", "Pass --pool N (N runners always up), or --api-token-env VAR to start runners only when jobs are pending.");
|
|
1399
|
+
const idleTimeoutMs = str(ctx, "--idle-timeout") === undefined ? IDLE_TIMEOUT_DEFAULT_MS : parseDuration(str(ctx, "--idle-timeout"), "--idle-timeout");
|
|
1400
|
+
const tickMs = str(ctx, "--poll") === undefined ? TICK_MS * 2 : Math.max(100, parseDuration(str(ctx, "--poll"), "--poll"));
|
|
1401
|
+
const pollMs = tickMs;
|
|
1402
|
+
const setup = await commonSetup(ctx);
|
|
1403
|
+
const c = setup.c;
|
|
1404
|
+
const clamped = clampMax(max, poolSize, "--pool", setup);
|
|
1405
|
+
max = clamped.max;
|
|
1406
|
+
const api = apiToken ? new gl.GitlabApi(url, apiToken.value) : undefined;
|
|
1407
|
+
const cfg = { url, token: token.value, executor: executor, defaultImage };
|
|
1408
|
+
const seed = randomBytes(6).toString("hex");
|
|
1409
|
+
const provider = new GitlabProvider(cfg, tier, seed, api, api ? Math.max(30, Math.round(idleTimeoutMs / 1000)) : Math.max(60, Math.floor(setup.timeoutMs / 2000)));
|
|
1410
|
+
let info;
|
|
1411
|
+
try {
|
|
1412
|
+
info = await provider.init();
|
|
1413
|
+
}
|
|
1414
|
+
catch (e) {
|
|
1415
|
+
if (e instanceof gh.GitHubError) {
|
|
1416
|
+
throw new PromigenceError({ code: "usage", message: `GitLab refused the runner check (${e.status}): ${gh.redact(e.message, provider.secrets())}`, fix: "Check --url, the runner authentication token (glrt-…) and that the API token can read the runner (read_api).", exitCode: 2 });
|
|
1417
|
+
}
|
|
1418
|
+
throw e;
|
|
1419
|
+
}
|
|
1420
|
+
if (api && info && info.type === "instance_type") {
|
|
1421
|
+
throw usageError("on-demand runners need a project or group runner", "An instance runner's pending jobs cannot be listed: drop --api-token-env and keep a pool (--pool N).");
|
|
1422
|
+
}
|
|
1423
|
+
const first = await ensureSnapshot(ctx, c, "gitlab", tier, false);
|
|
1424
|
+
if (!first)
|
|
1425
|
+
throw usageError("not started: the runner snapshot was not built", "Run it again and answer y, or pass --yes.");
|
|
1426
|
+
const snapshots = new Map([[tier, first.id]]);
|
|
1427
|
+
const poolId = randomBytes(4).toString("hex");
|
|
1428
|
+
const opts = {
|
|
1429
|
+
kind: "gitlab", scopeLabel: url.replace(/^https?:\/\//, ""), snapshots, max, keep: poolSize, keepPurpose: "pool",
|
|
1430
|
+
timeoutFor: setup.timeoutFor, capFor: setup.capFor, perSecond: setup.perSecond, tickMs, pollMs, idleTimeoutMs,
|
|
1431
|
+
killOnExit: bool(ctx, "--kill-on-exit"), poolId, ensureSnapshot: async (t) => snapshots.get(t),
|
|
1432
|
+
selfEndGraceS: graceFor(tickMs, TICK_MS * 2), scopeKey: `gitlab ${c.apiUrl} ${url}`,
|
|
1433
|
+
};
|
|
1434
|
+
const pool = new Pool(ctx, c, opts, provider);
|
|
1435
|
+
poolRef = pool;
|
|
1436
|
+
const rate = setup.perSecond(tier);
|
|
1437
|
+
const intro = [
|
|
1438
|
+
`runners gitlab: ${url} · executor ${executor}${executor === "docker" ? ` (default image ${defaultImage})` : ""}`,
|
|
1439
|
+
` note: ${GITLAB_TOKEN_NOTICE}`,
|
|
1440
|
+
` in your .gitlab-ci.yml: tags: [the tags you gave this runner in GitLab]${info ? ` (it has: ${info.tags.join(", ") || "none"}${info.runUntagged ? ", takes untagged jobs" : ""})` : ""}`,
|
|
1441
|
+
` tier ${tier}: ${shape(tier)}, ${usd(rate * 3600)}/h billed per second; each job at most ${secs(setup.timeoutMs)} and ${usd(setup.capFor(tier))}`,
|
|
1442
|
+
poolSize > 0
|
|
1443
|
+
? ` ${poolSize} runner${poolSize === 1 ? "" : "s"} kept up (each takes one job, then a fresh one replaces it; they bill while idle: ${usd(rate * 3600 * poolSize)}/h)`
|
|
1444
|
+
: ` runners start when GitLab has a pending job for this runner (up to ${max} at once; nothing bills while none is pending)`,
|
|
1445
|
+
...(setup.capNote ? [` note: ${setup.capNote}`] : []),
|
|
1446
|
+
...(setup.prices.source === "compiled" ? [` note: prices: ${COMPILED_NOTE}`] : []),
|
|
1447
|
+
...(clamped.note ? [` note: ${clamped.note}`] : []),
|
|
1448
|
+
" Ctrl-C stops taking jobs and waits for running ones; a second Ctrl-C cancels them.",
|
|
1449
|
+
];
|
|
1450
|
+
return runPool(ctx, pool, intro, {
|
|
1451
|
+
kind: "gitlab", url, executor, tier, pool: poolSize, max, on_demand: Boolean(api), timeout_s: setup.timeoutMs / 1000,
|
|
1452
|
+
cap_usd: setup.capFor(tier), price_source: setup.prices.source, pool_id: poolId, token_env: token.name, notice: GITLAB_TOKEN_NOTICE, ...(info ? { runner_tags: info.tags } : {}),
|
|
1453
|
+
});
|
|
1454
|
+
};
|
|
1455
|
+
export const RUNNERS_HANDLERS = {
|
|
1456
|
+
"runners github": runnersGithub,
|
|
1457
|
+
"runners gitlab": runnersGitlab,
|
|
1458
|
+
};
|