@magland/mochi 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +166 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +193 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/runner/actions.js
CHANGED
|
@@ -45,12 +45,17 @@ const os = __importStar(require("os"));
|
|
|
45
45
|
const path = __importStar(require("path"));
|
|
46
46
|
const YAML = __importStar(require("yaml"));
|
|
47
47
|
const actionref_1 = require("../ci/actionref");
|
|
48
|
+
const download_1 = require("./download");
|
|
48
49
|
// Fetching actions and reading their definitions. Actions come from a forge
|
|
49
50
|
// over HTTPS (github.com by default) as a source tarball, and are cached on
|
|
50
51
|
// the runner's disk so a repeated job does not re-download them.
|
|
51
52
|
//
|
|
52
53
|
// Nothing here executes anything: the store hands back a directory and a
|
|
53
54
|
// parsed action.yml, and steps.ts decides what to do with them.
|
|
55
|
+
// The most an action's source archive may be. The largest actions in common
|
|
56
|
+
// use are a few megabytes of bundled JavaScript; this is a ceiling on what a
|
|
57
|
+
// `uses:` line can make the runner hold, not a budget anyone should approach.
|
|
58
|
+
const MAX_ACTION_BYTES = 256 * 1024 * 1024;
|
|
54
59
|
class ActionError extends Error {
|
|
55
60
|
}
|
|
56
61
|
exports.ActionError = ActionError;
|
|
@@ -400,7 +405,13 @@ class ActionStore {
|
|
|
400
405
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
401
406
|
fs.mkdirSync(tmp, { recursive: true });
|
|
402
407
|
const tarball = path.join(tmp, 'source.tar.gz');
|
|
403
|
-
|
|
408
|
+
try {
|
|
409
|
+
await (0, download_1.downloadTo)(res, tarball, MAX_ACTION_BYTES);
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
413
|
+
throw new ActionError(`could not fetch ${ref.raw}: ${e instanceof Error ? e.message : e}`);
|
|
414
|
+
}
|
|
404
415
|
const extracted = path.join(tmp, 'src');
|
|
405
416
|
fs.mkdirSync(extracted);
|
|
406
417
|
try {
|
package/dist/runner/client.js
CHANGED
|
@@ -84,6 +84,7 @@ class JobClient {
|
|
|
84
84
|
buffer = [];
|
|
85
85
|
flushing = false;
|
|
86
86
|
cancelSeen = false;
|
|
87
|
+
cancelListeners = [];
|
|
87
88
|
timer = null;
|
|
88
89
|
constructor(runner, spec) {
|
|
89
90
|
this.runner = runner;
|
|
@@ -139,17 +140,34 @@ class JobClient {
|
|
|
139
140
|
cancelled() {
|
|
140
141
|
return this.cancelSeen;
|
|
141
142
|
}
|
|
143
|
+
// Called once, the first time a heartbeat learns the job should stop. The
|
|
144
|
+
// flag above is polled between steps; this is for the step that is running
|
|
145
|
+
// now, which nothing polls and which only removing the container can end.
|
|
146
|
+
onCancel(listener) {
|
|
147
|
+
if (this.cancelSeen)
|
|
148
|
+
listener();
|
|
149
|
+
else
|
|
150
|
+
this.cancelListeners.push(listener);
|
|
151
|
+
}
|
|
152
|
+
markCancelled() {
|
|
153
|
+
if (this.cancelSeen)
|
|
154
|
+
return;
|
|
155
|
+
this.cancelSeen = true;
|
|
156
|
+
const listeners = this.cancelListeners.splice(0, this.cancelListeners.length);
|
|
157
|
+
for (const l of listeners)
|
|
158
|
+
l();
|
|
159
|
+
}
|
|
142
160
|
async heartbeat() {
|
|
143
161
|
try {
|
|
144
162
|
const res = await fetch(`${this.base()}/heartbeat`, { method: 'POST', headers: this.headers() });
|
|
145
163
|
if (res.status === 409) {
|
|
146
164
|
// The server took the job back (lease expired, run cancelled). Stop.
|
|
147
|
-
this.
|
|
165
|
+
this.markCancelled();
|
|
148
166
|
return;
|
|
149
167
|
}
|
|
150
168
|
const body = (await res.json());
|
|
151
169
|
if (body.cancel)
|
|
152
|
-
this.
|
|
170
|
+
this.markCancelled();
|
|
153
171
|
}
|
|
154
172
|
catch {
|
|
155
173
|
// A transient failure is not a cancellation; keep working.
|
|
@@ -198,6 +216,7 @@ class Runner {
|
|
|
198
216
|
images;
|
|
199
217
|
workDir;
|
|
200
218
|
network;
|
|
219
|
+
limits;
|
|
201
220
|
idleSeconds;
|
|
202
221
|
wake;
|
|
203
222
|
// When this runner last had something to do. A wake request counts, so that
|
|
@@ -223,6 +242,7 @@ class Runner {
|
|
|
223
242
|
this.images = { ...exports.DEFAULT_IMAGES, ...(config.images ?? {}) };
|
|
224
243
|
this.workDir = config.workDir ?? (0, job_1.defaultWorkDir)();
|
|
225
244
|
this.network = config.network;
|
|
245
|
+
this.limits = config.limits;
|
|
226
246
|
this.idleSeconds = config.idleSeconds && config.idleSeconds > 0 ? config.idleSeconds : null;
|
|
227
247
|
this.wake = config.wakePort && config.wakeSecret ? { port: config.wakePort, secret: config.wakeSecret } : null;
|
|
228
248
|
const actionCache = config.cacheDir ? path.join(config.cacheDir, 'actions') : (0, actions_1.defaultActionCacheDir)();
|
|
@@ -294,6 +314,17 @@ class Runner {
|
|
|
294
314
|
throw new Error(`none of the requested labels (${this.labels.join(', ')}) are registered for this runner (${identity.labels.join(', ')})`);
|
|
295
315
|
}
|
|
296
316
|
this.name = identity.name;
|
|
317
|
+
// Whatever a previous life of this runner left running. A container is
|
|
318
|
+
// removed when its job ends, so any found here belong to a job that was
|
|
319
|
+
// under way when the runner last died, and nothing else will ever remove them.
|
|
320
|
+
try {
|
|
321
|
+
const stale = await (0, docker_1.removeStaleJobContainers)();
|
|
322
|
+
if (stale > 0)
|
|
323
|
+
console.log(`Removed ${stale} job container(s) left behind by an earlier run.`);
|
|
324
|
+
}
|
|
325
|
+
catch (e) {
|
|
326
|
+
console.log(`Could not look for leftover job containers: ${e instanceof Error ? e.message : String(e)}`);
|
|
327
|
+
}
|
|
297
328
|
// Listening before the banner, because a runner that cannot be woken
|
|
298
329
|
// should say so at the top rather than after claiming to be ready. A
|
|
299
330
|
// failure to bind throws out of here: a runner meant to stop when idle
|
|
@@ -313,6 +344,12 @@ class Runner {
|
|
|
313
344
|
console.log(` workdir: ${this.workDir}`);
|
|
314
345
|
console.log(` labels: ${labels.join(', ')}`);
|
|
315
346
|
console.log(` serving: ${identity.allow.join(', ')}`);
|
|
347
|
+
const limits = [
|
|
348
|
+
`pids ${this.limits?.pids ?? docker_1.DEFAULT_PIDS_LIMIT}`,
|
|
349
|
+
this.limits?.memory ? `memory ${this.limits.memory}` : null,
|
|
350
|
+
this.limits?.cpus ? `cpus ${this.limits.cpus}` : null,
|
|
351
|
+
].filter((l) => l !== null);
|
|
352
|
+
console.log(` limits: ${limits.join(', ')} per job`);
|
|
316
353
|
if (this.idleSeconds !== null)
|
|
317
354
|
console.log(` idle: stopping after ${this.idleSeconds}s with no job`);
|
|
318
355
|
if (listener)
|
|
@@ -398,6 +435,7 @@ async function executeSpec(spec, deps) {
|
|
|
398
435
|
log: (i, line) => client.log(i, line),
|
|
399
436
|
progress: (steps) => void client.progress(steps),
|
|
400
437
|
cancelled: () => client.cancelled(),
|
|
438
|
+
onCancel: (listener) => client.onCancel(listener),
|
|
401
439
|
});
|
|
402
440
|
}
|
|
403
441
|
catch (e) {
|
package/dist/runner/docker.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DockerError = void 0;
|
|
3
|
+
exports.JOB_CONTAINER_LABEL = exports.DEFAULT_PIDS_LIMIT = exports.DockerError = void 0;
|
|
4
4
|
exports.setContainerEngine = setContainerEngine;
|
|
5
5
|
exports.containerEngine = containerEngine;
|
|
6
6
|
exports.dockerAvailable = dockerAvailable;
|
|
@@ -8,10 +8,9 @@ exports.probeEngine = probeEngine;
|
|
|
8
8
|
exports.imagePresent = imagePresent;
|
|
9
9
|
exports.pullImage = pullImage;
|
|
10
10
|
exports.startContainer = startContainer;
|
|
11
|
+
exports.removeStaleJobContainers = removeStaleJobContainers;
|
|
11
12
|
exports.removeContainer = removeContainer;
|
|
12
13
|
exports.execInContainer = execInContainer;
|
|
13
|
-
exports.writeFileInContainer = writeFileInContainer;
|
|
14
|
-
exports.readFileInContainer = readFileInContainer;
|
|
15
14
|
const child_process_1 = require("child_process");
|
|
16
15
|
// The Docker surface the runner needs, as thin wrappers over the CLI rather
|
|
17
16
|
// than the Engine API socket. Shelling out keeps the runner dependency-free
|
|
@@ -94,12 +93,24 @@ function pullImage(image, onLine) {
|
|
|
94
93
|
child.on('close', (code) => code === 0 ? resolve() : reject(new DockerError(`docker pull ${image} exited ${code}`)));
|
|
95
94
|
});
|
|
96
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Processes without a bound are the cheapest way to take a machine down and
|
|
98
|
+
* the one no job legitimately needs, so the ceiling is on unless the operator
|
|
99
|
+
* says otherwise; a build that wants more than this many at once is rare
|
|
100
|
+
* enough to be asked for by name. Memory and CPU have no default: what a job
|
|
101
|
+
* may reasonably want depends on the machine, and a cap that is wrong for it
|
|
102
|
+
* fails honest builds rather than protecting anything.
|
|
103
|
+
*/
|
|
104
|
+
exports.DEFAULT_PIDS_LIMIT = 4096;
|
|
105
|
+
/** The label every job container carries, so a runner can find the ones a crash left behind. */
|
|
106
|
+
exports.JOB_CONTAINER_LABEL = 'mochi.job';
|
|
97
107
|
// One container per job, kept alive by a sleep so each step can exec into
|
|
98
108
|
// it. This is how a container job behaves on GitHub: steps share a
|
|
99
109
|
// filesystem and a process namespace, and anything a step installs is there
|
|
100
110
|
// for the next one.
|
|
101
111
|
async function startContainer(opts) {
|
|
102
112
|
const args = ['run', '--detach', '--name', opts.name, '--workdir', opts.workdir, '--entrypoint', ''];
|
|
113
|
+
args.push('--label', `${exports.JOB_CONTAINER_LABEL}=1`);
|
|
103
114
|
for (const b of opts.binds) {
|
|
104
115
|
args.push('--volume', `${b.host}:${b.container}${b.readonly ? ':ro' : ''}`);
|
|
105
116
|
}
|
|
@@ -108,10 +119,36 @@ async function startContainer(opts) {
|
|
|
108
119
|
}
|
|
109
120
|
if (opts.network)
|
|
110
121
|
args.push('--network', opts.network);
|
|
122
|
+
const limits = opts.limits ?? {};
|
|
123
|
+
const pids = limits.pids ?? exports.DEFAULT_PIDS_LIMIT;
|
|
124
|
+
if (pids > 0)
|
|
125
|
+
args.push('--pids-limit', String(pids));
|
|
126
|
+
if (limits.memory)
|
|
127
|
+
args.push('--memory', limits.memory);
|
|
128
|
+
if (limits.cpus)
|
|
129
|
+
args.push('--cpus', limits.cpus);
|
|
111
130
|
args.push(opts.image, 'sh', '-c', 'while true; do sleep 3600; done');
|
|
112
131
|
const { stdout } = await run(args);
|
|
113
132
|
return stdout.trim();
|
|
114
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Remove every job container on this daemon, and say how many there were.
|
|
136
|
+
*
|
|
137
|
+
* A job's container is removed when the job finishes or times out, and by
|
|
138
|
+
* nothing else: a runner killed mid-job, or a machine that lost power, leaves
|
|
139
|
+
* a container running its idle sleep for good, and on a machine whose docker
|
|
140
|
+
* directory is a persistent volume, they accumulate across restarts. This
|
|
141
|
+
* runs when a runner starts, when by construction no job of its own is in
|
|
142
|
+
* flight. A daemon shared with a second runner would lose that runner's
|
|
143
|
+
* running job here, which is one more reason not to share one.
|
|
144
|
+
*/
|
|
145
|
+
async function removeStaleJobContainers() {
|
|
146
|
+
const { stdout } = await run(['ps', '--all', '--quiet', '--filter', `label=${exports.JOB_CONTAINER_LABEL}`]);
|
|
147
|
+
const ids = stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
148
|
+
for (const id of ids)
|
|
149
|
+
await removeContainer(id);
|
|
150
|
+
return ids.length;
|
|
151
|
+
}
|
|
115
152
|
async function removeContainer(id) {
|
|
116
153
|
try {
|
|
117
154
|
await run(['rm', '--force', '--volumes', id]);
|
|
@@ -170,28 +207,3 @@ function execInContainer(id, argv, onLine, opts = {}) {
|
|
|
170
207
|
});
|
|
171
208
|
return { child, done };
|
|
172
209
|
}
|
|
173
|
-
// Copy a file into a container. `docker cp` from stdin needs a tar stream, so
|
|
174
|
-
// the simpler route for the small files the runner writes (step scripts, the
|
|
175
|
-
// environment files) is to pipe the bytes through a shell redirect.
|
|
176
|
-
function writeFileInContainer(id, containerPath, content) {
|
|
177
|
-
return new Promise((resolve, reject) => {
|
|
178
|
-
const child = (0, child_process_1.spawn)(engineBin, ['exec', '--interactive', id, 'sh', '-c', `cat > "$0"`, containerPath], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
179
|
-
let stderr = '';
|
|
180
|
-
child.stderr?.on('data', (b) => {
|
|
181
|
-
stderr += b.toString('utf8');
|
|
182
|
-
});
|
|
183
|
-
child.on('error', (e) => reject(new DockerError(e.message)));
|
|
184
|
-
child.on('close', (code) => code === 0 ? resolve() : reject(new DockerError(`writing ${containerPath} failed: ${stderr.trim()}`)));
|
|
185
|
-
child.stdin?.write(content);
|
|
186
|
-
child.stdin?.end();
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
async function readFileInContainer(id, containerPath) {
|
|
190
|
-
try {
|
|
191
|
-
const { stdout } = await run(['exec', id, 'sh', '-c', `cat "$0" 2>/dev/null || true`, containerPath]);
|
|
192
|
-
return stdout;
|
|
193
|
-
}
|
|
194
|
-
catch {
|
|
195
|
-
return '';
|
|
196
|
-
}
|
|
197
|
-
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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.DownloadTooLarge = void 0;
|
|
37
|
+
exports.downloadTo = downloadTo;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const stream_1 = require("stream");
|
|
40
|
+
const promises_1 = require("stream/promises");
|
|
41
|
+
// A response body written to a file, with a ceiling.
|
|
42
|
+
//
|
|
43
|
+
// The two callers fetch archives -- an action's source from a forge, a node
|
|
44
|
+
// build from nodejs.org -- and used to read each whole into one Buffer before
|
|
45
|
+
// writing it out. The size of that Buffer was whatever the far end chose to
|
|
46
|
+
// serve, and a `uses:` line names the far end, so a workflow could point the
|
|
47
|
+
// runner at a tarball the size of its memory. Streaming through a counter
|
|
48
|
+
// bounds the transfer at the cap and costs one chunk of memory at a time.
|
|
49
|
+
class DownloadTooLarge extends Error {
|
|
50
|
+
}
|
|
51
|
+
exports.DownloadTooLarge = DownloadTooLarge;
|
|
52
|
+
async function downloadTo(res, file, maxBytes) {
|
|
53
|
+
if (!res.body)
|
|
54
|
+
throw new Error('the response had no body');
|
|
55
|
+
// A declared length over the cap is refused before a byte is read; a body
|
|
56
|
+
// that declares less than it sends, or nothing, is caught as it arrives.
|
|
57
|
+
const declared = Number(res.headers.get('content-length') ?? '0');
|
|
58
|
+
if (declared > maxBytes)
|
|
59
|
+
throw new DownloadTooLarge(`${declared} bytes, over the limit of ${maxBytes}`);
|
|
60
|
+
let seen = 0;
|
|
61
|
+
const cap = new stream_1.Transform({
|
|
62
|
+
transform(chunk, _encoding, callback) {
|
|
63
|
+
seen += chunk.length;
|
|
64
|
+
if (seen > maxBytes)
|
|
65
|
+
callback(new DownloadTooLarge(`more than ${maxBytes} bytes`));
|
|
66
|
+
else
|
|
67
|
+
callback(null, chunk);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
await (0, promises_1.pipeline)(stream_1.Readable.fromWeb(res.body), cap, fs.createWriteStream(file));
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
fs.rmSync(file, { force: true });
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/runner/externals.js
CHANGED
|
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs"));
|
|
|
40
40
|
const os = __importStar(require("os"));
|
|
41
41
|
const path = __importStar(require("path"));
|
|
42
42
|
const docker_1 = require("./docker");
|
|
43
|
+
const download_1 = require("./download");
|
|
43
44
|
// JavaScript actions need a node interpreter inside the job's container.
|
|
44
45
|
// GitHub's runner ships its own and mounts it in, which is why an image
|
|
45
46
|
// without node still runs `actions/checkout`. We do the same, but only when
|
|
@@ -49,6 +50,10 @@ const docker_1 = require("./docker");
|
|
|
49
50
|
// The downloaded builds are the official linux-x64/arm64 ones, which are
|
|
50
51
|
// linked against glibc. An Alpine or other musl image therefore cannot use
|
|
51
52
|
// them, and gets a clear message rather than a confusing loader error.
|
|
53
|
+
// An official node build is around fifty megabytes compressed; a download
|
|
54
|
+
// several times that is not one, whatever nodejs.org or the proxy in front
|
|
55
|
+
// of it is serving.
|
|
56
|
+
const MAX_NODE_BYTES = 256 * 1024 * 1024;
|
|
52
57
|
class ExternalsError extends Error {
|
|
53
58
|
}
|
|
54
59
|
exports.ExternalsError = ExternalsError;
|
|
@@ -160,7 +165,13 @@ class Externals {
|
|
|
160
165
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
161
166
|
fs.mkdirSync(tmp);
|
|
162
167
|
const tarball = path.join(tmp, 'node.tar.gz');
|
|
163
|
-
|
|
168
|
+
try {
|
|
169
|
+
await (0, download_1.downloadTo)(res, tarball, MAX_NODE_BYTES);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
173
|
+
throw new ExternalsError(`could not download node ${version}: ${e instanceof Error ? e.message : e}`);
|
|
174
|
+
}
|
|
164
175
|
const extracted = path.join(tmp, 'out');
|
|
165
176
|
fs.mkdirSync(extracted);
|
|
166
177
|
await run('tar', ['-xzf', tarball, '-C', extracted, '--strip-components=1']);
|
package/dist/runner/job.js
CHANGED
|
@@ -89,13 +89,23 @@ async function runJob(spec, ctx, hooks) {
|
|
|
89
89
|
let containerId = null;
|
|
90
90
|
// `timeout-minutes` is enforced here rather than by the server: the lease
|
|
91
91
|
// sweep only notices a runner that has stopped heartbeating, and a job stuck
|
|
92
|
-
// inside a step heartbeats perfectly well.
|
|
93
|
-
//
|
|
94
|
-
// interrupt the step that is actually hanging; removing the container is
|
|
95
|
-
// makes the in-flight exec fail and the loop unwind.
|
|
92
|
+
// inside a step heartbeats perfectly well. The cancelled flag is checked
|
|
93
|
+
// between steps, so neither a deadline nor a cancel that only set it would
|
|
94
|
+
// ever interrupt the step that is actually hanging; removing the container is
|
|
95
|
+
// what makes the in-flight exec fail and the loop unwind. Both do that, and
|
|
96
|
+
// `stopped` records that they did, so nothing tries to use the container
|
|
97
|
+
// afterwards.
|
|
96
98
|
let timedOut = false;
|
|
99
|
+
let stopped = false;
|
|
97
100
|
let deadline = null;
|
|
98
101
|
const cancelled = () => hooks.cancelled() || timedOut;
|
|
102
|
+
const stopContainer = (why) => {
|
|
103
|
+
if (stopped || !containerId)
|
|
104
|
+
return;
|
|
105
|
+
stopped = true;
|
|
106
|
+
log(why);
|
|
107
|
+
void (0, docker_1.removeContainer)(containerId);
|
|
108
|
+
};
|
|
99
109
|
const finish = async (conclusion) => {
|
|
100
110
|
currentStep = RUNNER_STEP;
|
|
101
111
|
if (deadline)
|
|
@@ -158,10 +168,10 @@ async function runJob(spec, ctx, hooks) {
|
|
|
158
168
|
env,
|
|
159
169
|
workdir: context_1.WORKSPACE,
|
|
160
170
|
network: ctx.network,
|
|
171
|
+
limits: ctx.limits,
|
|
161
172
|
});
|
|
162
173
|
await (0, docker_1.execInContainer)(containerId, ['mkdir', '-p', context_1.RUNNER_TOOL_CACHE], () => { }).done;
|
|
163
174
|
log(`Container started from ${image}`);
|
|
164
|
-
const id = containerId;
|
|
165
175
|
// A runner and its server are separate long-running processes and need not
|
|
166
176
|
// be the same version, so a spec that carries no usable timeout falls back
|
|
167
177
|
// to GitHub's six hours rather than to NaN, which setTimeout would treat as
|
|
@@ -174,9 +184,13 @@ async function runJob(spec, ctx, hooks) {
|
|
|
174
184
|
const delay = Math.min(limit * 60_000, 2 ** 31 - 1);
|
|
175
185
|
deadline = setTimeout(() => {
|
|
176
186
|
timedOut = true;
|
|
177
|
-
|
|
178
|
-
void (0, docker_1.removeContainer)(id);
|
|
187
|
+
stopContainer(`Job exceeded its timeout of ${limit} minute${limit === 1 ? '' : 's'}`);
|
|
179
188
|
}, delay);
|
|
189
|
+
// A cancel that arrives while a step is running is served the same way.
|
|
190
|
+
// One that arrived earlier is caught by the step loop before it runs
|
|
191
|
+
// anything, and the listener fires at once, so the container goes either
|
|
192
|
+
// way.
|
|
193
|
+
hooks.onCancel?.(() => stopContainer('The run was cancelled; stopping the job container'));
|
|
180
194
|
const exec = {
|
|
181
195
|
containerId,
|
|
182
196
|
spec,
|
|
@@ -231,10 +245,11 @@ async function runJob(spec, ctx, hooks) {
|
|
|
231
245
|
});
|
|
232
246
|
failed = failed || result.failed;
|
|
233
247
|
// Post steps run even when the job failed or was cancelled, which is what
|
|
234
|
-
// makes them useful for cleanup. A
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
248
|
+
// makes them useful for cleanup. A job stopped mid-step, by its timeout or
|
|
249
|
+
// by a cancel, is the exception: the container they would run in is the
|
|
250
|
+
// thing that was torn down to stop the job, so attempting them would only
|
|
251
|
+
// add a failure for each one to the log.
|
|
252
|
+
if (rt.postHooks.length && !stopped) {
|
|
238
253
|
currentStep = RUNNER_STEP;
|
|
239
254
|
await (0, steps_1.runPostHooks)(exec, failed);
|
|
240
255
|
}
|
package/dist/runner/steps.js
CHANGED
|
@@ -77,9 +77,11 @@ async function runSteps(steps, scope, ctx, hooks = {}) {
|
|
|
77
77
|
}),
|
|
78
78
|
functions: (0, context_1.statusFunctions)(failed || ctx.status().failed, ctx.status().cancelled),
|
|
79
79
|
});
|
|
80
|
+
// A step that failed under continue-on-error never set `failed`, so it
|
|
81
|
+
// does not close the gate for the steps after it.
|
|
80
82
|
let shouldRun;
|
|
81
83
|
try {
|
|
82
|
-
shouldRun =
|
|
84
|
+
shouldRun = (0, expr_1.evalGatedCondition)(step.if, !failed, evalEnv());
|
|
83
85
|
}
|
|
84
86
|
catch (e) {
|
|
85
87
|
ctx.log(`Error evaluating if: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -213,9 +215,42 @@ async function makeFileCommands(ctx, tag) {
|
|
|
213
215
|
state: `${context_1.FILE_COMMAND_DIR}/state-${tag}`,
|
|
214
216
|
};
|
|
215
217
|
for (const f of Object.values(files))
|
|
216
|
-
|
|
218
|
+
writeJobFile(ctx, f, '');
|
|
217
219
|
return files;
|
|
218
220
|
}
|
|
221
|
+
// The file-command directory is a bind mount of a directory the runner owns,
|
|
222
|
+
// so the files a step appends to are created and read back on the host. This
|
|
223
|
+
// used to go through `docker exec --interactive ... cat`, one exec per file,
|
|
224
|
+
// which has a race: when stdin is closed before the exec has attached, the
|
|
225
|
+
// EOF is lost and cat waits forever, and so did the job, until its timeout.
|
|
226
|
+
// An empty file is the widest case of that window, since there is no data
|
|
227
|
+
// write to sequence against. The files are world-writable because the step
|
|
228
|
+
// appends to them as whatever user the image runs, which need not be the
|
|
229
|
+
// runner's.
|
|
230
|
+
function writeJobFile(ctx, containerPath, content) {
|
|
231
|
+
const host = ctx.hostPath(containerPath);
|
|
232
|
+
if (host === null)
|
|
233
|
+
throw new Error(`${containerPath} is not on a mount the runner can write`);
|
|
234
|
+
fs.writeFileSync(host, content);
|
|
235
|
+
try {
|
|
236
|
+
fs.chmodSync(host, 0o666);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// the runner and the container being the same user, which is the common
|
|
240
|
+
// case, needs no wider mode
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function readJobFile(ctx, containerPath) {
|
|
244
|
+
const host = ctx.hostPath(containerPath);
|
|
245
|
+
if (host === null)
|
|
246
|
+
return '';
|
|
247
|
+
try {
|
|
248
|
+
return fs.readFileSync(host, 'utf8');
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return '';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
219
254
|
function fileCommandEnv(files) {
|
|
220
255
|
return {
|
|
221
256
|
GITHUB_OUTPUT: files.output,
|
|
@@ -228,20 +263,20 @@ function fileCommandEnv(files) {
|
|
|
228
263
|
// Read back everything a step wrote through the file commands, and fold it
|
|
229
264
|
// into the scope and the job runtime.
|
|
230
265
|
async function collectFileCommands(ctx, scope, files, stepId) {
|
|
231
|
-
const outputText =
|
|
266
|
+
const outputText = readJobFile(ctx, files.output);
|
|
232
267
|
const parsed = (0, context_1.parseKeyValueFile)(outputText);
|
|
233
268
|
if (stepId && Object.keys(parsed).length) {
|
|
234
269
|
scope.outputs[stepId] = { ...(scope.outputs[stepId] ?? {}), ...parsed };
|
|
235
270
|
}
|
|
236
|
-
const envText =
|
|
271
|
+
const envText = readJobFile(ctx, files.envFile);
|
|
237
272
|
Object.assign(ctx.rt.env, (0, context_1.parseKeyValueFile)(envText));
|
|
238
|
-
const pathText =
|
|
273
|
+
const pathText = readJobFile(ctx, files.pathFile);
|
|
239
274
|
for (const line of pathText.split('\n')) {
|
|
240
275
|
const p = line.trim();
|
|
241
276
|
if (p !== '' && !ctx.rt.extraPath.includes(p))
|
|
242
277
|
ctx.rt.extraPath.unshift(p);
|
|
243
278
|
}
|
|
244
|
-
const summaryText =
|
|
279
|
+
const summaryText = readJobFile(ctx, files.summary);
|
|
245
280
|
if (summaryText.trim() !== '')
|
|
246
281
|
ctx.rt.summaries.push(summaryText);
|
|
247
282
|
}
|
|
@@ -332,7 +367,7 @@ async function runShellStep(step, scope, ctx, stepEnv, evalEnv, index) {
|
|
|
332
367
|
const tag = envFileTag(index);
|
|
333
368
|
const files = await makeFileCommands(ctx, tag);
|
|
334
369
|
const scriptPath = `${context_1.FILE_COMMAND_DIR}/step-${tag}`;
|
|
335
|
-
|
|
370
|
+
writeJobFile(ctx, scriptPath, script.endsWith('\n') ? script : script + '\n');
|
|
336
371
|
const shell = step.shell ?? (scope.depth === 0 ? ctx.spec.defaults?.shell : undefined);
|
|
337
372
|
const env = {
|
|
338
373
|
...stepEnv,
|