@magland/mochi 0.3.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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.Runner = exports.JobClient = exports.DEFAULT_IMAGES = void 0;
|
|
37
|
+
exports.configPath = configPath;
|
|
38
|
+
exports.loadRunnerConfig = loadRunnerConfig;
|
|
39
|
+
exports.saveRunnerConfig = saveRunnerConfig;
|
|
40
|
+
exports.executeSpec = executeSpec;
|
|
41
|
+
exports.imageForLabels = imageForLabels;
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const actions_1 = require("./actions");
|
|
46
|
+
const docker_1 = require("./docker");
|
|
47
|
+
const externals_1 = require("./externals");
|
|
48
|
+
const job_1 = require("./job");
|
|
49
|
+
const wake_1 = require("./wake");
|
|
50
|
+
exports.DEFAULT_IMAGES = {
|
|
51
|
+
'ubuntu-latest': 'catthehacker/ubuntu:act-latest',
|
|
52
|
+
'ubuntu-24.04': 'catthehacker/ubuntu:act-24.04',
|
|
53
|
+
'ubuntu-22.04': 'catthehacker/ubuntu:act-22.04',
|
|
54
|
+
'self-hosted': 'catthehacker/ubuntu:act-latest',
|
|
55
|
+
};
|
|
56
|
+
function configPath() {
|
|
57
|
+
const base = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config');
|
|
58
|
+
return path.join(base, 'mochi', 'runner.json');
|
|
59
|
+
}
|
|
60
|
+
function loadRunnerConfig(file = configPath()) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
63
|
+
if (typeof parsed.host !== 'string' || typeof parsed.token !== 'string')
|
|
64
|
+
return null;
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function saveRunnerConfig(config, file = configPath()) {
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
const tmp = `${file}.tmp`;
|
|
74
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
75
|
+
fs.renameSync(tmp, file);
|
|
76
|
+
}
|
|
77
|
+
// The per-job wire client: logs, heartbeats, progress, and the final report.
|
|
78
|
+
// It needs a host and a bearer token and nothing else about its caller, which
|
|
79
|
+
// is what lets a manual session (`mochi job run`) drive it with a session
|
|
80
|
+
// token exactly as the long-lived runner drives it with a runner token.
|
|
81
|
+
class JobClient {
|
|
82
|
+
runner;
|
|
83
|
+
spec;
|
|
84
|
+
buffer = [];
|
|
85
|
+
flushing = false;
|
|
86
|
+
cancelSeen = false;
|
|
87
|
+
timer = null;
|
|
88
|
+
constructor(runner, spec) {
|
|
89
|
+
this.runner = runner;
|
|
90
|
+
this.spec = spec;
|
|
91
|
+
}
|
|
92
|
+
base() {
|
|
93
|
+
const a = this.spec.address;
|
|
94
|
+
return `${this.runner.host}/api/runner/jobs/${encodeURIComponent(a.collection)}/${encodeURIComponent(a.repo)}/${a.run}/${encodeURIComponent(a.job)}`;
|
|
95
|
+
}
|
|
96
|
+
headers(extra = {}) {
|
|
97
|
+
return {
|
|
98
|
+
authorization: `Bearer ${this.runner.token}`,
|
|
99
|
+
'x-mochi-lease': this.spec.lease,
|
|
100
|
+
...extra,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
log(stepIndex, line) {
|
|
104
|
+
this.buffer.push({ s: stepIndex, t: new Date().toISOString(), l: line });
|
|
105
|
+
if (this.buffer.length >= 200)
|
|
106
|
+
void this.flush();
|
|
107
|
+
}
|
|
108
|
+
async flush() {
|
|
109
|
+
if (this.flushing || this.buffer.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
this.flushing = true;
|
|
112
|
+
const batch = this.buffer.splice(0, this.buffer.length);
|
|
113
|
+
try {
|
|
114
|
+
await fetch(`${this.base()}/logs`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: this.headers({ 'content-type': 'application/x-ndjson' }),
|
|
117
|
+
body: batch.map((l) => JSON.stringify(l)).join('\n') + '\n',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Losing a log batch must never fail the job; the work matters more
|
|
122
|
+
// than its transcript.
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
this.flushing = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
start() {
|
|
129
|
+
this.timer = setInterval(() => {
|
|
130
|
+
void this.flush();
|
|
131
|
+
void this.heartbeat();
|
|
132
|
+
}, 3000);
|
|
133
|
+
}
|
|
134
|
+
stop() {
|
|
135
|
+
if (this.timer)
|
|
136
|
+
clearInterval(this.timer);
|
|
137
|
+
this.timer = null;
|
|
138
|
+
}
|
|
139
|
+
cancelled() {
|
|
140
|
+
return this.cancelSeen;
|
|
141
|
+
}
|
|
142
|
+
async heartbeat() {
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`${this.base()}/heartbeat`, { method: 'POST', headers: this.headers() });
|
|
145
|
+
if (res.status === 409) {
|
|
146
|
+
// The server took the job back (lease expired, run cancelled). Stop.
|
|
147
|
+
this.cancelSeen = true;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const body = (await res.json());
|
|
151
|
+
if (body.cancel)
|
|
152
|
+
this.cancelSeen = true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// A transient failure is not a cancellation; keep working.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async progress(steps) {
|
|
159
|
+
await this.flush();
|
|
160
|
+
try {
|
|
161
|
+
await fetch(`${this.base()}/status`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
164
|
+
body: JSON.stringify({ lease: this.spec.lease, status: 'running', stepStates: steps }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// reported again on the next step
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async complete(result) {
|
|
172
|
+
await this.flush();
|
|
173
|
+
try {
|
|
174
|
+
await fetch(`${this.base()}/status`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
lease: this.spec.lease,
|
|
179
|
+
status: 'completed',
|
|
180
|
+
conclusion: result.conclusion,
|
|
181
|
+
stepStates: result.steps,
|
|
182
|
+
outputs: result.outputs,
|
|
183
|
+
summaries: result.summaries,
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
// The lease will expire and the server will requeue the job.
|
|
189
|
+
console.error(` could not report the result: ${e instanceof Error ? e.message : e}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
exports.JobClient = JobClient;
|
|
194
|
+
class Runner {
|
|
195
|
+
host;
|
|
196
|
+
token;
|
|
197
|
+
labels;
|
|
198
|
+
images;
|
|
199
|
+
workDir;
|
|
200
|
+
network;
|
|
201
|
+
idleSeconds;
|
|
202
|
+
wake;
|
|
203
|
+
// When this runner last had something to do. A wake request counts, so that
|
|
204
|
+
// a runner started for a job that is a moment behind the request does not
|
|
205
|
+
// time out before the job arrives.
|
|
206
|
+
idleSince = Date.now();
|
|
207
|
+
stopping = false;
|
|
208
|
+
// The acquire poll the loop is sitting in, so that stopping can abort it. A
|
|
209
|
+
// runner between jobs is inside a request the server holds open for twenty-five
|
|
210
|
+
// seconds, and a stop that only set the flag above was not acted on until that
|
|
211
|
+
// request answered: Ctrl-C printed "stopping" and then appeared to hang.
|
|
212
|
+
polling = null;
|
|
213
|
+
// The registered name, learned from whoami at startup. Only used in
|
|
214
|
+
// messages, but a message from a runner that does not say which runner
|
|
215
|
+
// sent it is of little use on a vault serving more than one.
|
|
216
|
+
name = '';
|
|
217
|
+
actions;
|
|
218
|
+
externals;
|
|
219
|
+
constructor(config) {
|
|
220
|
+
this.host = config.host.replace(/\/+$/, '');
|
|
221
|
+
this.token = config.token;
|
|
222
|
+
this.labels = config.labels ?? [];
|
|
223
|
+
this.images = { ...exports.DEFAULT_IMAGES, ...(config.images ?? {}) };
|
|
224
|
+
this.workDir = config.workDir ?? (0, job_1.defaultWorkDir)();
|
|
225
|
+
this.network = config.network;
|
|
226
|
+
this.idleSeconds = config.idleSeconds && config.idleSeconds > 0 ? config.idleSeconds : null;
|
|
227
|
+
this.wake = config.wakePort && config.wakeSecret ? { port: config.wakePort, secret: config.wakeSecret } : null;
|
|
228
|
+
const actionCache = config.cacheDir ? path.join(config.cacheDir, 'actions') : (0, actions_1.defaultActionCacheDir)();
|
|
229
|
+
const externalsCache = config.cacheDir ? path.join(config.cacheDir, 'externals') : (0, externals_1.defaultExternalsDir)();
|
|
230
|
+
this.actions = new actions_1.ActionStore(actionCache, config.actionsUrl, config.actionCache);
|
|
231
|
+
this.externals = new externals_1.Externals(externalsCache);
|
|
232
|
+
}
|
|
233
|
+
imageFor(labels) {
|
|
234
|
+
return imageForLabels(this.images, labels);
|
|
235
|
+
}
|
|
236
|
+
cloneUrl(collection, repo) {
|
|
237
|
+
return `${this.host}/${encodeURIComponent(collection)}/${encodeURIComponent(repo)}`;
|
|
238
|
+
}
|
|
239
|
+
async whoami() {
|
|
240
|
+
const res = await fetch(`${this.host}/api/runner/whoami`, {
|
|
241
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
242
|
+
});
|
|
243
|
+
if (!res.ok) {
|
|
244
|
+
const body = (await res.json().catch(() => ({})));
|
|
245
|
+
throw new Error(body.error ?? `the server answered ${res.status}`);
|
|
246
|
+
}
|
|
247
|
+
return (await res.json());
|
|
248
|
+
}
|
|
249
|
+
async acquire(labels) {
|
|
250
|
+
const poll = new AbortController();
|
|
251
|
+
this.polling = poll;
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch(`${this.host}/api/runner/acquire`, {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' },
|
|
256
|
+
body: JSON.stringify({ labels }),
|
|
257
|
+
signal: poll.signal,
|
|
258
|
+
});
|
|
259
|
+
if (res.status === 204)
|
|
260
|
+
return null;
|
|
261
|
+
if (!res.ok) {
|
|
262
|
+
const body = (await res.json().catch(() => ({})));
|
|
263
|
+
throw new Error(body.error ?? `acquire failed with ${res.status}`);
|
|
264
|
+
}
|
|
265
|
+
return (await res.json());
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
// An aborted poll is a stop, not a failure: answer it the way an empty
|
|
269
|
+
// poll is answered and let the loop see the flag and end.
|
|
270
|
+
if (this.stopping)
|
|
271
|
+
return null;
|
|
272
|
+
throw e;
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
this.polling = null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
stop() {
|
|
279
|
+
this.stopping = true;
|
|
280
|
+
// Aborting the poll hangs up on the server, which releases any job it was
|
|
281
|
+
// in the middle of leasing to this runner rather than leaving it leased to
|
|
282
|
+
// nobody. Nothing is executing at this point: a runner inside a poll is a
|
|
283
|
+
// runner between jobs.
|
|
284
|
+
this.polling?.abort();
|
|
285
|
+
}
|
|
286
|
+
async loop() {
|
|
287
|
+
const docker = await (0, docker_1.dockerAvailable)();
|
|
288
|
+
if (!docker) {
|
|
289
|
+
throw new Error(`${(0, docker_1.containerEngine)()} is not available. The runner executes every job in a container, so it needs a working \`${(0, docker_1.containerEngine)()}\` command.`);
|
|
290
|
+
}
|
|
291
|
+
const identity = await this.whoami();
|
|
292
|
+
const labels = this.labels.length ? this.labels.filter((l) => identity.labels.includes(l)) : identity.labels;
|
|
293
|
+
if (labels.length === 0) {
|
|
294
|
+
throw new Error(`none of the requested labels (${this.labels.join(', ')}) are registered for this runner (${identity.labels.join(', ')})`);
|
|
295
|
+
}
|
|
296
|
+
this.name = identity.name;
|
|
297
|
+
// Listening before the banner, because a runner that cannot be woken
|
|
298
|
+
// should say so at the top rather than after claiming to be ready. A
|
|
299
|
+
// failure to bind throws out of here: a runner meant to stop when idle
|
|
300
|
+
// and unable to be started again is worse than one that never started.
|
|
301
|
+
const listener = this.wake
|
|
302
|
+
? await (0, wake_1.startWakeListener)({
|
|
303
|
+
port: this.wake.port,
|
|
304
|
+
secret: this.wake.secret,
|
|
305
|
+
onWake: () => {
|
|
306
|
+
this.idleSince = Date.now();
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
: null;
|
|
310
|
+
console.log(`mochi runner ${identity.name} ready`);
|
|
311
|
+
console.log(` server: ${this.host}`);
|
|
312
|
+
console.log(` engine: ${(0, docker_1.containerEngine)()} ${docker}`);
|
|
313
|
+
console.log(` workdir: ${this.workDir}`);
|
|
314
|
+
console.log(` labels: ${labels.join(', ')}`);
|
|
315
|
+
console.log(` serving: ${identity.allow.join(', ')}`);
|
|
316
|
+
if (this.idleSeconds !== null)
|
|
317
|
+
console.log(` idle: stopping after ${this.idleSeconds}s with no job`);
|
|
318
|
+
if (listener)
|
|
319
|
+
console.log(` wake: listening on port ${listener.port}`);
|
|
320
|
+
console.log('Waiting for jobs.');
|
|
321
|
+
let backoff = 1000;
|
|
322
|
+
// Idle is measured from the end of the last job, not from the last poll:
|
|
323
|
+
// a runner between jobs polls every twenty-five seconds forever, so a
|
|
324
|
+
// timer reset by polling would never fire.
|
|
325
|
+
this.idleSince = Date.now();
|
|
326
|
+
try {
|
|
327
|
+
while (!this.stopping) {
|
|
328
|
+
let spec = null;
|
|
329
|
+
try {
|
|
330
|
+
spec = await this.acquire(labels);
|
|
331
|
+
backoff = 1000;
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
console.error(` ${e instanceof Error ? e.message : e}; retrying in ${Math.round(backoff / 1000)}s`);
|
|
335
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
336
|
+
backoff = Math.min(backoff * 2, 60000);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (!spec) {
|
|
340
|
+
if (this.idleSeconds !== null && Date.now() - this.idleSince >= this.idleSeconds * 1000) {
|
|
341
|
+
console.log(`No job for ${this.idleSeconds}s; stopping.`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
await this.execute(spec);
|
|
347
|
+
this.idleSince = Date.now();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
// Closed before this returns, and so before the process exits, which
|
|
352
|
+
// settles the race between stopping and being woken: a wake request
|
|
353
|
+
// that arrives after the decision to stop finds nothing listening and
|
|
354
|
+
// fails, and a failed wake is retried against a runner that by then has
|
|
355
|
+
// stopped and can be started again. Answering it and exiting anyway
|
|
356
|
+
// would leave the sender believing the runner was up.
|
|
357
|
+
await listener?.close();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async execute(spec) {
|
|
361
|
+
await executeSpec(spec, {
|
|
362
|
+
host: this.host,
|
|
363
|
+
token: this.token,
|
|
364
|
+
runnerName: this.name || 'this runner',
|
|
365
|
+
imageFor: (labels) => this.imageFor(labels),
|
|
366
|
+
workDir: this.workDir,
|
|
367
|
+
network: this.network,
|
|
368
|
+
actions: this.actions,
|
|
369
|
+
externals: this.externals,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
exports.Runner = Runner;
|
|
374
|
+
/**
|
|
375
|
+
* Take one leased job through execution and reporting. Shared between the
|
|
376
|
+
* long-lived runner and a manual session, which differ in how they came by
|
|
377
|
+
* the lease and in nothing that happens after.
|
|
378
|
+
*/
|
|
379
|
+
async function executeSpec(spec, deps) {
|
|
380
|
+
const label = `${spec.address.collection}/${spec.address.repo} #${spec.runNumber} ${spec.name}`;
|
|
381
|
+
console.log(`> ${label}`);
|
|
382
|
+
const client = new JobClient({ host: deps.host, token: deps.token }, spec);
|
|
383
|
+
client.start();
|
|
384
|
+
deps.actions.beginJob();
|
|
385
|
+
const started = Date.now();
|
|
386
|
+
let result;
|
|
387
|
+
try {
|
|
388
|
+
result = await (0, job_1.runJob)(spec, {
|
|
389
|
+
imageFor: deps.imageFor,
|
|
390
|
+
cloneUrl: (c, r) => `${deps.host}/${encodeURIComponent(c)}/${encodeURIComponent(r)}`,
|
|
391
|
+
workDir: deps.workDir,
|
|
392
|
+
network: deps.network,
|
|
393
|
+
actions: deps.actions,
|
|
394
|
+
externals: deps.externals,
|
|
395
|
+
serverUrl: deps.host,
|
|
396
|
+
runnerToken: deps.token,
|
|
397
|
+
}, {
|
|
398
|
+
log: (i, line) => client.log(i, line),
|
|
399
|
+
progress: (steps) => void client.progress(steps),
|
|
400
|
+
cancelled: () => client.cancelled(),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
// A failure here is the runner's own, not the workflow's, and the log
|
|
405
|
+
// is read on the vault by someone who cannot see this machine: say
|
|
406
|
+
// which runner it was and where it was working, so that the reader
|
|
407
|
+
// knows whose filesystem the message is about.
|
|
408
|
+
const where = `${deps.runnerName} on ${os.hostname()}, work dir ${deps.workDir}`;
|
|
409
|
+
client.log(-1, `runner error (${where}): ${e instanceof Error ? e.message : String(e)}`);
|
|
410
|
+
result = { conclusion: 'failure', steps: [], outputs: {}, summaries: [] };
|
|
411
|
+
}
|
|
412
|
+
await client.complete(result);
|
|
413
|
+
client.stop();
|
|
414
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
415
|
+
console.log(`< ${label}: ${result.conclusion} in ${secs}s`);
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
/** The image a set of runs-on labels selects, given the label→image map. */
|
|
419
|
+
function imageForLabels(images, labels) {
|
|
420
|
+
for (const l of labels) {
|
|
421
|
+
if (images[l])
|
|
422
|
+
return images[l];
|
|
423
|
+
}
|
|
424
|
+
// An unmapped label is taken as an image name when it looks like one,
|
|
425
|
+
// which makes `runs-on: node:24` work without configuration.
|
|
426
|
+
const looksLikeImage = labels.find((l) => l.includes('/') || l.includes(':'));
|
|
427
|
+
return looksLikeImage ?? images['ubuntu-latest'];
|
|
428
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Masker = exports.MAX_ACTION_DEPTH = exports.ACTIONS_MOUNT = exports.FILE_COMMAND_DIR = exports.RUNNER_TOOL_CACHE = exports.RUNNER_TEMP = exports.WORKSPACE = void 0;
|
|
4
|
+
exports.newScope = newScope;
|
|
5
|
+
exports.baseEnv = baseEnv;
|
|
6
|
+
exports.contextsFor = contextsFor;
|
|
7
|
+
exports.statusFunctions = statusFunctions;
|
|
8
|
+
exports.shellCommand = shellCommand;
|
|
9
|
+
exports.stepLabel = stepLabel;
|
|
10
|
+
exports.parseWorkflowCommand = parseWorkflowCommand;
|
|
11
|
+
exports.parseKeyValueFile = parseKeyValueFile;
|
|
12
|
+
// The state and helpers shared by everything that executes inside a job:
|
|
13
|
+
// the container paths, the environment, the expression contexts, and the
|
|
14
|
+
// parsers for the two ways a step talks back to the runner (workflow commands
|
|
15
|
+
// on stdout, and the file commands).
|
|
16
|
+
//
|
|
17
|
+
// This module holds no I/O. It exists so that job.ts (orchestration) and
|
|
18
|
+
// steps.ts (execution, including recursion into composite actions) can share
|
|
19
|
+
// definitions without importing each other.
|
|
20
|
+
exports.WORKSPACE = '/mochi/workspace';
|
|
21
|
+
exports.RUNNER_TEMP = '/mochi/temp';
|
|
22
|
+
exports.RUNNER_TOOL_CACHE = '/mochi/tools';
|
|
23
|
+
exports.FILE_COMMAND_DIR = '/mochi/files';
|
|
24
|
+
exports.ACTIONS_MOUNT = '/mochi/actions';
|
|
25
|
+
// How deep composite actions may nest before we call it a loop.
|
|
26
|
+
exports.MAX_ACTION_DEPTH = 10;
|
|
27
|
+
// Values a step asks to be redacted (::add-mask::) are removed from every
|
|
28
|
+
// later log line. Masking happens on the runner, before a line is shipped, so
|
|
29
|
+
// an unmasked value never reaches the vault.
|
|
30
|
+
class Masker {
|
|
31
|
+
values = [];
|
|
32
|
+
add(value) {
|
|
33
|
+
const v = value.trim();
|
|
34
|
+
if (v.length >= 4 && !this.values.includes(v))
|
|
35
|
+
this.values.push(v);
|
|
36
|
+
}
|
|
37
|
+
apply(line) {
|
|
38
|
+
let out = line;
|
|
39
|
+
for (const v of this.values)
|
|
40
|
+
out = out.split(v).join('***');
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.Masker = Masker;
|
|
45
|
+
function newScope(depth = 0) {
|
|
46
|
+
return { outputs: {}, conclusions: {}, inputs: {}, actionEnv: {}, depth };
|
|
47
|
+
}
|
|
48
|
+
function baseEnv(spec) {
|
|
49
|
+
const gh = spec.github;
|
|
50
|
+
const str = (k) => {
|
|
51
|
+
const v = gh[k];
|
|
52
|
+
return v === undefined || v === null ? '' : String(v);
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
CI: 'true',
|
|
56
|
+
GITHUB_ACTIONS: 'true',
|
|
57
|
+
MOCHI_ACTIONS: 'true',
|
|
58
|
+
GITHUB_WORKFLOW: spec.workflowName,
|
|
59
|
+
GITHUB_WORKFLOW_REF: str('workflow_ref'),
|
|
60
|
+
GITHUB_RUN_ID: String(spec.runNumber),
|
|
61
|
+
GITHUB_RUN_NUMBER: String(spec.runNumber),
|
|
62
|
+
GITHUB_RUN_ATTEMPT: '1',
|
|
63
|
+
GITHUB_JOB: spec.address.job,
|
|
64
|
+
GITHUB_ACTION: '',
|
|
65
|
+
GITHUB_ACTOR: str('actor'),
|
|
66
|
+
GITHUB_TRIGGERING_ACTOR: str('triggering_actor'),
|
|
67
|
+
GITHUB_REPOSITORY: str('repository'),
|
|
68
|
+
GITHUB_REPOSITORY_OWNER: str('repository_owner'),
|
|
69
|
+
GITHUB_EVENT_NAME: str('event_name'),
|
|
70
|
+
GITHUB_EVENT_PATH: `${exports.RUNNER_TEMP}/event.json`,
|
|
71
|
+
GITHUB_REF: spec.ref,
|
|
72
|
+
GITHUB_REF_NAME: spec.refName,
|
|
73
|
+
GITHUB_REF_TYPE: str('ref_type'),
|
|
74
|
+
GITHUB_SHA: spec.sha,
|
|
75
|
+
GITHUB_WORKSPACE: exports.WORKSPACE,
|
|
76
|
+
GITHUB_SERVER_URL: str('server_url'),
|
|
77
|
+
GITHUB_API_URL: str('api_url'),
|
|
78
|
+
GITHUB_GRAPHQL_URL: '',
|
|
79
|
+
GITHUB_RETENTION_DAYS: '90',
|
|
80
|
+
RUNNER_OS: 'Linux',
|
|
81
|
+
RUNNER_ARCH: process.arch === 'arm64' ? 'ARM64' : 'X64',
|
|
82
|
+
RUNNER_NAME: 'mochi',
|
|
83
|
+
RUNNER_TEMP: exports.RUNNER_TEMP,
|
|
84
|
+
RUNNER_TOOL_CACHE: exports.RUNNER_TOOL_CACHE,
|
|
85
|
+
RUNNER_DEBUG: '0',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function contextsFor(args) {
|
|
89
|
+
const { spec, scope, stepEnv } = args;
|
|
90
|
+
const steps = {};
|
|
91
|
+
const ids = new Set([...Object.keys(scope.outputs), ...Object.keys(scope.conclusions)]);
|
|
92
|
+
for (const id of ids) {
|
|
93
|
+
steps[id] = {
|
|
94
|
+
outputs: (scope.outputs[id] ?? {}),
|
|
95
|
+
outcome: scope.conclusions[id]?.outcome ?? 'success',
|
|
96
|
+
conclusion: scope.conclusions[id]?.conclusion ?? 'success',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const github = { ...spec.github };
|
|
100
|
+
github.workspace = exports.WORKSPACE;
|
|
101
|
+
if (scope.actionPath !== undefined)
|
|
102
|
+
github.action_path = scope.actionPath;
|
|
103
|
+
return {
|
|
104
|
+
github: github,
|
|
105
|
+
env: stepEnv,
|
|
106
|
+
job: { status: args.jobFailed ? 'failure' : 'success', container: {}, services: {} },
|
|
107
|
+
runner: {
|
|
108
|
+
os: 'Linux',
|
|
109
|
+
arch: process.arch === 'arm64' ? 'ARM64' : 'X64',
|
|
110
|
+
name: 'mochi',
|
|
111
|
+
temp: exports.RUNNER_TEMP,
|
|
112
|
+
tool_cache: exports.RUNNER_TOOL_CACHE,
|
|
113
|
+
debug: '0',
|
|
114
|
+
environment: 'self-hosted',
|
|
115
|
+
},
|
|
116
|
+
strategy: spec.strategy
|
|
117
|
+
? {
|
|
118
|
+
'fail-fast': spec.strategy.failFast,
|
|
119
|
+
'job-index': spec.strategy.jobIndex,
|
|
120
|
+
'job-total': spec.strategy.jobTotal,
|
|
121
|
+
}
|
|
122
|
+
: {},
|
|
123
|
+
matrix: (spec.matrix ?? {}),
|
|
124
|
+
needs: spec.needs,
|
|
125
|
+
// Inside a composite action `inputs` is the action's inputs; at job level
|
|
126
|
+
// it is the workflow_dispatch inputs.
|
|
127
|
+
inputs: (scope.depth > 0 ? scope.inputs : (spec.inputs ?? {})),
|
|
128
|
+
vars: {},
|
|
129
|
+
secrets: {},
|
|
130
|
+
steps: steps,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function statusFunctions(jobFailed, jobCancelled) {
|
|
134
|
+
return {
|
|
135
|
+
success: () => !jobFailed && !jobCancelled,
|
|
136
|
+
failure: () => jobFailed && !jobCancelled,
|
|
137
|
+
cancelled: () => jobCancelled,
|
|
138
|
+
always: () => true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// The shell a `run:` step uses. GitHub's default on Linux is bash with -e,
|
|
142
|
+
// and an explicit `bash` adds -o pipefail; the distinction is preserved
|
|
143
|
+
// because workflows depend on it.
|
|
144
|
+
function shellCommand(shell, scriptPath) {
|
|
145
|
+
const s = (shell ?? 'default').toLowerCase();
|
|
146
|
+
if (s === 'bash')
|
|
147
|
+
return ['bash', '--noprofile', '--norc', '-eo', 'pipefail', scriptPath];
|
|
148
|
+
if (s === 'sh')
|
|
149
|
+
return ['sh', '-e', scriptPath];
|
|
150
|
+
if (s === 'default')
|
|
151
|
+
return ['bash', '-e', scriptPath];
|
|
152
|
+
if (s === 'python')
|
|
153
|
+
return ['python3', scriptPath];
|
|
154
|
+
if (s.includes('{0}')) {
|
|
155
|
+
const parts = s.split(/\s+/).map((p) => (p === '{0}' ? scriptPath : p));
|
|
156
|
+
return parts;
|
|
157
|
+
}
|
|
158
|
+
return [s, scriptPath];
|
|
159
|
+
}
|
|
160
|
+
// A step's display name. Expressions are resolved before the text is cut to
|
|
161
|
+
// length, since truncating first would leave a torn `${{` that then fails to
|
|
162
|
+
// render and shows the reader the raw expression instead of its value.
|
|
163
|
+
function stepLabel(step, index, resolve) {
|
|
164
|
+
const r = (s) => {
|
|
165
|
+
if (!resolve)
|
|
166
|
+
return s;
|
|
167
|
+
try {
|
|
168
|
+
return resolve(s);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return s;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
if (step.name)
|
|
175
|
+
return r(step.name);
|
|
176
|
+
if (step.uses)
|
|
177
|
+
return step.uses;
|
|
178
|
+
if (step.run) {
|
|
179
|
+
const firstLine = r(step.run).split('\n').find((l) => l.trim() !== '') ?? '';
|
|
180
|
+
const trimmed = firstLine.trim();
|
|
181
|
+
return `Run ${trimmed.length > 70 ? trimmed.slice(0, 70) + '…' : trimmed}`;
|
|
182
|
+
}
|
|
183
|
+
return `Step ${index + 1}`;
|
|
184
|
+
}
|
|
185
|
+
// ::name key=value,key=value::message, with the documented escapes. The older
|
|
186
|
+
// ##[name]message spelling is accepted too, since actions still emit it (the
|
|
187
|
+
// problem-matcher commands, for instance).
|
|
188
|
+
function parseWorkflowCommand(line) {
|
|
189
|
+
const hash = line.match(/^\s*##\[([A-Za-z-]+)\]([\s\S]*)$/);
|
|
190
|
+
if (hash)
|
|
191
|
+
return { name: hash[1].toLowerCase(), props: {}, message: hash[2] };
|
|
192
|
+
const m = line.match(/^\s*::([A-Za-z-]+)(\s+(.*?))?::([\s\S]*)$/);
|
|
193
|
+
if (!m) {
|
|
194
|
+
const bare = line.match(/^\s*::([A-Za-z-]+)::$/);
|
|
195
|
+
if (bare)
|
|
196
|
+
return { name: bare[1].toLowerCase(), props: {}, message: '' };
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const props = {};
|
|
200
|
+
if (m[3]) {
|
|
201
|
+
for (const pair of m[3].split(',')) {
|
|
202
|
+
const i = pair.indexOf('=');
|
|
203
|
+
if (i === -1)
|
|
204
|
+
continue;
|
|
205
|
+
props[pair.slice(0, i).trim()] = unescapeProperty(pair.slice(i + 1));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { name: m[1].toLowerCase(), props, message: unescapeData(m[4]) };
|
|
209
|
+
}
|
|
210
|
+
function unescapeData(s) {
|
|
211
|
+
return s.replace(/%25/g, '%').replace(/%0D/g, '\r').replace(/%0A/g, '\n');
|
|
212
|
+
}
|
|
213
|
+
function unescapeProperty(s) {
|
|
214
|
+
return unescapeData(s).replace(/%3A/g, ':').replace(/%2C/g, ',');
|
|
215
|
+
}
|
|
216
|
+
// The GITHUB_OUTPUT / GITHUB_ENV format: `key=value` lines, plus the heredoc
|
|
217
|
+
// form for values containing newlines:
|
|
218
|
+
//
|
|
219
|
+
// key<<EOF
|
|
220
|
+
// line one
|
|
221
|
+
// EOF
|
|
222
|
+
function parseKeyValueFile(text) {
|
|
223
|
+
const out = {};
|
|
224
|
+
const lines = text.split('\n');
|
|
225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
226
|
+
const line = lines[i];
|
|
227
|
+
if (line.trim() === '')
|
|
228
|
+
continue;
|
|
229
|
+
const heredoc = line.match(/^([^=<]+)<<(\S+)\s*$/);
|
|
230
|
+
if (heredoc) {
|
|
231
|
+
const key = heredoc[1].trim();
|
|
232
|
+
const delim = heredoc[2];
|
|
233
|
+
const body = [];
|
|
234
|
+
i++;
|
|
235
|
+
while (i < lines.length && lines[i] !== delim) {
|
|
236
|
+
body.push(lines[i]);
|
|
237
|
+
i++;
|
|
238
|
+
}
|
|
239
|
+
out[key] = body.join('\n');
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const eq = line.indexOf('=');
|
|
243
|
+
if (eq > 0)
|
|
244
|
+
out[line.slice(0, eq).trim()] = line.slice(eq + 1);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|