@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
package/dist/ci/web.js
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
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.readLog = readLog;
|
|
37
|
+
exports.registerCiWeb = registerCiWeb;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const readline = __importStar(require("readline"));
|
|
41
|
+
const ansi_1 = require("../ansi");
|
|
42
|
+
const perms_1 = require("../perms");
|
|
43
|
+
const scan_1 = require("../scan");
|
|
44
|
+
const session_1 = require("../session");
|
|
45
|
+
const web_1 = require("../web");
|
|
46
|
+
const artifacts_1 = require("./artifacts");
|
|
47
|
+
const engine_1 = require("./engine");
|
|
48
|
+
const runs_1 = require("./runs");
|
|
49
|
+
const runners_1 = require("./runners");
|
|
50
|
+
const manual_1 = require("./manual");
|
|
51
|
+
const wake_1 = require("./wake");
|
|
52
|
+
const dispatch_1 = require("./dispatch");
|
|
53
|
+
const version_1 = require("../version");
|
|
54
|
+
const ciViews = __importStar(require("./views"));
|
|
55
|
+
// The Actions pages and their operations. Reading follows the repository's
|
|
56
|
+
// visibility, as everywhere else in a vault; cancelling, re-running, and
|
|
57
|
+
// dispatching require the write role, and runner management is the site
|
|
58
|
+
// admin's, on the web.
|
|
59
|
+
const RUNS_PER_PAGE = 50;
|
|
60
|
+
const MAX_LOG_RENDER_BYTES = 4 * 1024 * 1024;
|
|
61
|
+
// Nothing posted here is long: a workflow dispatch names a ref and its inputs,
|
|
62
|
+
// and a runner registration names a runner.
|
|
63
|
+
const form = (0, web_1.urlencodedForm)('1mb');
|
|
64
|
+
// Read a job log as ndjson from a byte offset. Returning the new offset lets
|
|
65
|
+
// the tailer poll without re-reading, and lets a huge log render its tail
|
|
66
|
+
// rather than exhausting memory.
|
|
67
|
+
function readLog(file, offset) {
|
|
68
|
+
let size;
|
|
69
|
+
try {
|
|
70
|
+
size = fs.statSync(file).size;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { lines: [], offset: 0 };
|
|
74
|
+
}
|
|
75
|
+
let start = Math.max(0, Math.min(offset, size));
|
|
76
|
+
let truncated = false;
|
|
77
|
+
if (size - start > MAX_LOG_RENDER_BYTES) {
|
|
78
|
+
start = size - MAX_LOG_RENDER_BYTES;
|
|
79
|
+
truncated = true;
|
|
80
|
+
}
|
|
81
|
+
let text;
|
|
82
|
+
try {
|
|
83
|
+
const fd = fs.openSync(file, 'r');
|
|
84
|
+
try {
|
|
85
|
+
const buf = Buffer.alloc(size - start);
|
|
86
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
87
|
+
text = buf.toString('utf8');
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
fs.closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { lines: [], offset };
|
|
95
|
+
}
|
|
96
|
+
// A trailing partial line means the writer is mid-append; leave it for the
|
|
97
|
+
// next poll by reporting an offset before it.
|
|
98
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
99
|
+
let consumed = size;
|
|
100
|
+
if (lastNewline === -1) {
|
|
101
|
+
return { lines: [], offset: start };
|
|
102
|
+
}
|
|
103
|
+
if (lastNewline !== text.length - 1) {
|
|
104
|
+
consumed = start + Buffer.byteLength(text.slice(0, lastNewline + 1), 'utf8');
|
|
105
|
+
text = text.slice(0, lastNewline + 1);
|
|
106
|
+
}
|
|
107
|
+
const lines = [];
|
|
108
|
+
if (truncated)
|
|
109
|
+
lines.push({ s: 0, l: '[earlier output omitted]' });
|
|
110
|
+
for (const raw of text.split('\n')) {
|
|
111
|
+
if (raw === '')
|
|
112
|
+
continue;
|
|
113
|
+
try {
|
|
114
|
+
const p = JSON.parse(raw);
|
|
115
|
+
if (typeof p.l === 'string')
|
|
116
|
+
lines.push({ s: typeof p.s === 'number' ? p.s : 0, l: p.l });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// skip a damaged line rather than failing the page
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { lines, offset: consumed };
|
|
123
|
+
}
|
|
124
|
+
function registerCiWeb(app, root, engine) {
|
|
125
|
+
// ---- the runs list ----
|
|
126
|
+
app.get('/:collection/:repo/actions', (0, web_1.ah)(async (req, res) => {
|
|
127
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
128
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
129
|
+
if (!loaded)
|
|
130
|
+
return;
|
|
131
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
|
|
132
|
+
const filter = typeof req.query.workflow === 'string' ? req.query.workflow : null;
|
|
133
|
+
let runs = (0, runs_1.listRuns)(root, loaded.repo.collection, loaded.repo.name);
|
|
134
|
+
if (filter)
|
|
135
|
+
runs = runs.filter((r) => r.workflowPath === filter);
|
|
136
|
+
runs = runs.slice(0, RUNS_PER_PAGE);
|
|
137
|
+
// Live runs are held in memory; prefer those records so a listing
|
|
138
|
+
// refreshed mid-run shows the current status rather than the last write.
|
|
139
|
+
runs = runs.map((r) => engine.activeRun(loaded.repo.collection, loaded.repo.name, r.number) ?? r);
|
|
140
|
+
let workflows = [];
|
|
141
|
+
const tip = loaded.branches.find((b) => b.name === loaded.defaultBranch);
|
|
142
|
+
if (tip) {
|
|
143
|
+
try {
|
|
144
|
+
workflows = await (0, engine_1.listWorkflowsAt)(loaded.repo, tip.sha);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
workflows = [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const flash = typeof req.query.flash === 'string' ? req.query.flash : undefined;
|
|
151
|
+
res.type('html').send(ciViews.runsPage(ctx, runs, workflows, filter, flash));
|
|
152
|
+
}));
|
|
153
|
+
// ---- one run ----
|
|
154
|
+
async function loadRunPage(req, res) {
|
|
155
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
156
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
157
|
+
if (!loaded)
|
|
158
|
+
return null;
|
|
159
|
+
const n = parseInt(req.params.run, 10);
|
|
160
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
161
|
+
(0, web_1.send404)(res, 'No such run', viewer);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const run = engine.runOf(loaded.repo.collection, loaded.repo.name, n);
|
|
165
|
+
if (!run) {
|
|
166
|
+
(0, web_1.send404)(res, `Run #${n} not found`, viewer);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return { viewer, loaded, run };
|
|
170
|
+
}
|
|
171
|
+
app.get('/:collection/:repo/actions/runs/:run', (0, web_1.ah)(async (req, res) => {
|
|
172
|
+
const page = await loadRunPage(req, res);
|
|
173
|
+
if (!page)
|
|
174
|
+
return;
|
|
175
|
+
const { viewer, loaded, run } = page;
|
|
176
|
+
const repo = loaded.repo;
|
|
177
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, run.refName || (loaded.defaultBranch ?? ''), viewer);
|
|
178
|
+
const jobs = [];
|
|
179
|
+
for (const id of run.jobs) {
|
|
180
|
+
const j = engine.jobOf(repo.collection, repo.name, run.number, id);
|
|
181
|
+
if (j)
|
|
182
|
+
jobs.push(j);
|
|
183
|
+
}
|
|
184
|
+
const wanted = typeof req.query.job === 'string' ? req.query.job : null;
|
|
185
|
+
const selected = (wanted ? jobs.find((j) => j.id === wanted) : undefined) ??
|
|
186
|
+
jobs.find((j) => j.status === 'running') ??
|
|
187
|
+
jobs.find((j) => j.conclusion === 'failure') ??
|
|
188
|
+
jobs[0] ??
|
|
189
|
+
null;
|
|
190
|
+
let lines = [];
|
|
191
|
+
let offset = 0;
|
|
192
|
+
if (selected) {
|
|
193
|
+
const r = readLog((0, runs_1.jobLogPath)(root, repo.collection, repo.name, run.number, selected.id), 0);
|
|
194
|
+
lines = r.lines;
|
|
195
|
+
offset = r.offset;
|
|
196
|
+
}
|
|
197
|
+
const artifacts = (0, artifacts_1.listArtifacts)(root, repo.collection, repo.name, run.number);
|
|
198
|
+
res.type('html').send(ciViews.runPage(ctx, run, jobs, selected, lines, offset, artifacts));
|
|
199
|
+
}));
|
|
200
|
+
// Artifact download. A repository's build output is exactly as visible as
|
|
201
|
+
// its source: anonymous on a public repository, and gone with the rest of a
|
|
202
|
+
// private one for anyone without the read role. These three routes resolve
|
|
203
|
+
// the repository themselves rather than through loadRepo, so each makes the
|
|
204
|
+
// same check.
|
|
205
|
+
app.get('/:collection/:repo/actions/runs/:run/artifacts/:name', (0, web_1.ah)(async (req, res) => {
|
|
206
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
207
|
+
const repo = (0, scan_1.findRepo)(root, req.params.collection, req.params.repo);
|
|
208
|
+
if (!repo || !(0, perms_1.canReadRepo)(root, viewer?.auth ?? null, repo)) {
|
|
209
|
+
(0, web_1.send404)(res, 'Repository not found', viewer);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const n = parseInt(req.params.run, 10);
|
|
213
|
+
if (!Number.isInteger(n) || n <= 0 || !(0, artifacts_1.isValidArtifactName)(req.params.name)) {
|
|
214
|
+
(0, web_1.send404)(res, 'No such artifact', viewer);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const file = (0, artifacts_1.artifactPath)(root, repo.collection, repo.name, n, req.params.name);
|
|
218
|
+
if (!file || !fs.existsSync(file)) {
|
|
219
|
+
(0, web_1.send404)(res, `No artifact named ${req.params.name} in run #${n}`, viewer);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
res
|
|
223
|
+
.type('application/x-tar')
|
|
224
|
+
.set('Content-Disposition', `attachment; filename="${req.params.name}.tar"`)
|
|
225
|
+
.sendFile(path.resolve(file));
|
|
226
|
+
}));
|
|
227
|
+
// The whole log as plain text, exactly as the runner wrote it: escape
|
|
228
|
+
// sequences and all, since "raw" that has been cleaned up is not raw. The
|
|
229
|
+
// step view renders; this preserves. Streamed, because a log can be larger
|
|
230
|
+
// than anything worth buffering.
|
|
231
|
+
app.get('/:collection/:repo/actions/runs/:run/log/:job/raw', (0, web_1.ah)(async (req, res) => {
|
|
232
|
+
const repo = (0, scan_1.findRepo)(root, req.params.collection, req.params.repo);
|
|
233
|
+
const readable = repo !== null && (0, perms_1.canReadRepo)(root, (0, session_1.getViewer)(req, root)?.auth ?? null, repo);
|
|
234
|
+
const n = parseInt(req.params.run, 10);
|
|
235
|
+
const job = repo && readable && Number.isInteger(n) && n > 0 ? engine.jobOf(repo.collection, repo.name, n, req.params.job) : null;
|
|
236
|
+
if (!repo || !readable || !job) {
|
|
237
|
+
res.status(404).type('text').send('not found\n');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const file = (0, runs_1.jobLogPath)(root, repo.collection, repo.name, n, job.id);
|
|
241
|
+
if (!fs.existsSync(file)) {
|
|
242
|
+
res.status(404).type('text').send('no log\n');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
res
|
|
246
|
+
.type('text/plain; charset=utf-8')
|
|
247
|
+
.set('X-Content-Type-Options', 'nosniff')
|
|
248
|
+
.set('Content-Disposition', `attachment; filename="${job.id}.log"`);
|
|
249
|
+
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
250
|
+
for await (const raw of rl) {
|
|
251
|
+
if (raw === '')
|
|
252
|
+
continue;
|
|
253
|
+
try {
|
|
254
|
+
const p = JSON.parse(raw);
|
|
255
|
+
if (typeof p.l === 'string') {
|
|
256
|
+
if (!res.write(p.l + '\n'))
|
|
257
|
+
await new Promise((resolve) => res.once('drain', resolve));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// skip a damaged line rather than failing the download
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
res.end();
|
|
265
|
+
}));
|
|
266
|
+
// The tailer's endpoint: new log lines since an offset, plus whether the
|
|
267
|
+
// job has finished (at which point the page reloads into the step view).
|
|
268
|
+
app.get('/:collection/:repo/actions/runs/:run/log/:job', (0, web_1.ah)(async (req, res) => {
|
|
269
|
+
const collection = req.params.collection;
|
|
270
|
+
const repoName = req.params.repo;
|
|
271
|
+
const repo = (0, scan_1.findRepo)(root, collection, repoName);
|
|
272
|
+
if (!repo || !(0, perms_1.canReadRepo)(root, (0, session_1.getViewer)(req, root)?.auth ?? null, repo)) {
|
|
273
|
+
res.status(404).json({ error: 'not found' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const n = parseInt(req.params.run, 10);
|
|
277
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
278
|
+
res.status(404).json({ error: 'not found' });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const job = engine.jobOf(repo.collection, repo.name, n, req.params.job);
|
|
282
|
+
if (!job) {
|
|
283
|
+
res.status(404).json({ error: 'not found' });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const offset = Math.max(0, parseInt(String(req.query.offset ?? '0'), 10) || 0);
|
|
287
|
+
const r = readLog((0, runs_1.jobLogPath)(root, repo.collection, repo.name, n, job.id), offset);
|
|
288
|
+
res.set('Cache-Control', 'no-store').json({
|
|
289
|
+
done: job.status === 'completed',
|
|
290
|
+
offset: r.offset,
|
|
291
|
+
// Stripped of terminal escapes, since the tailer appends these as
|
|
292
|
+
// plain text; the log file itself keeps the bytes as written.
|
|
293
|
+
lines: r.lines.map((l) => (0, ansi_1.stripAnsi)(l.l)),
|
|
294
|
+
});
|
|
295
|
+
}));
|
|
296
|
+
// ---- operations ----
|
|
297
|
+
function repoActor(req, res) {
|
|
298
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
299
|
+
if (!viewer)
|
|
300
|
+
return null;
|
|
301
|
+
const collection = req.params.collection;
|
|
302
|
+
const repoName = req.params.repo;
|
|
303
|
+
if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(repoName)) {
|
|
304
|
+
(0, web_1.fail)(res, 404, 'No such repository', viewer);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
return { viewer, collection, repo: repoName };
|
|
308
|
+
}
|
|
309
|
+
app.post('/:collection/:repo/actions/runs/:run/cancel', form, (0, web_1.ah)(async (req, res) => {
|
|
310
|
+
const actor = repoActor(req, res);
|
|
311
|
+
if (!actor)
|
|
312
|
+
return;
|
|
313
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, actor.viewer);
|
|
314
|
+
if (!loaded)
|
|
315
|
+
return;
|
|
316
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', actor.viewer);
|
|
317
|
+
if (!ctx.canPush) {
|
|
318
|
+
(0, web_1.fail)(res, 403, 'You do not have permission to cancel runs in this repository.', actor.viewer);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const n = parseInt(req.params.run, 10);
|
|
322
|
+
engine.cancelRun(loaded.repo.collection, loaded.repo.name, n);
|
|
323
|
+
res.redirect(`/${encodeURIComponent(loaded.repo.collection)}/${encodeURIComponent(loaded.repo.name)}/actions/runs/${n}`);
|
|
324
|
+
}));
|
|
325
|
+
app.post('/:collection/:repo/actions/runs/:run/rerun', form, (0, web_1.ah)(async (req, res) => {
|
|
326
|
+
const actor = repoActor(req, res);
|
|
327
|
+
if (!actor)
|
|
328
|
+
return;
|
|
329
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, actor.viewer);
|
|
330
|
+
if (!loaded)
|
|
331
|
+
return;
|
|
332
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', actor.viewer);
|
|
333
|
+
if (!ctx.canPush) {
|
|
334
|
+
(0, web_1.fail)(res, 403, 'You do not have permission to run workflows in this repository.', actor.viewer);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const n = parseInt(req.params.run, 10);
|
|
338
|
+
const base = `/${encodeURIComponent(loaded.repo.collection)}/${encodeURIComponent(loaded.repo.name)}/actions`;
|
|
339
|
+
try {
|
|
340
|
+
const fresh = await engine.rerun(loaded.repo.collection, loaded.repo.name, n);
|
|
341
|
+
if (!fresh) {
|
|
342
|
+
(0, web_1.fail)(res, 404, `Run #${n} not found`, actor.viewer, base);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
res.redirect(`${base}/runs/${fresh.number}`);
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
(0, web_1.fail)(res, 400, e instanceof Error ? e.message : String(e), actor.viewer, base);
|
|
349
|
+
}
|
|
350
|
+
}));
|
|
351
|
+
// Mint the pasted command for a run's manual jobs. Write role, like every
|
|
352
|
+
// other way of causing a workflow to execute; the page the token lands on
|
|
353
|
+
// is the only place it ever appears, as with runner tokens.
|
|
354
|
+
app.post('/:collection/:repo/actions/runs/:run/exec-command', form, (0, web_1.ah)(async (req, res) => {
|
|
355
|
+
const actor = repoActor(req, res);
|
|
356
|
+
if (!actor)
|
|
357
|
+
return;
|
|
358
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, actor.viewer);
|
|
359
|
+
if (!loaded)
|
|
360
|
+
return;
|
|
361
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', actor.viewer);
|
|
362
|
+
if (!ctx.canPush) {
|
|
363
|
+
(0, web_1.fail)(res, 403, 'You do not have permission to run workflows in this repository.', actor.viewer);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const n = parseInt(req.params.run, 10);
|
|
367
|
+
const back = `/${encodeURIComponent(loaded.repo.collection)}/${encodeURIComponent(loaded.repo.name)}/actions/runs/${n}`;
|
|
368
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
369
|
+
(0, web_1.send404)(res, 'No such run', actor.viewer);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const minted = engine.mintManual(loaded.repo.collection, loaded.repo.name, n, actor.viewer.auth.username);
|
|
373
|
+
if ('error' in minted) {
|
|
374
|
+
const why = minted.error === 'finished'
|
|
375
|
+
? `Run #${n} has finished; there is nothing left to run.`
|
|
376
|
+
: `Run #${n} has no manual jobs.`;
|
|
377
|
+
(0, web_1.fail)(res, minted.error === 'finished' ? 409 : 400, why, actor.viewer, back);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const command = `npx @magland/mochi@${(0, version_1.packageVersion)()} job run ${(0, web_1.baseUrlOf)(req)} ${minted.token}`;
|
|
381
|
+
res
|
|
382
|
+
.type('html')
|
|
383
|
+
.send(ciViews.execCommandPage(ctx, n, command, Math.round(manual_1.MINT_TTL_MS / 60000), back));
|
|
384
|
+
}));
|
|
385
|
+
app.post('/:collection/:repo/actions/dispatch', form, (0, web_1.ah)(async (req, res) => {
|
|
386
|
+
const actor = repoActor(req, res);
|
|
387
|
+
if (!actor)
|
|
388
|
+
return;
|
|
389
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, actor.viewer);
|
|
390
|
+
if (!loaded)
|
|
391
|
+
return;
|
|
392
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', actor.viewer);
|
|
393
|
+
const base = `/${encodeURIComponent(loaded.repo.collection)}/${encodeURIComponent(loaded.repo.name)}/actions`;
|
|
394
|
+
if (!ctx.canPush) {
|
|
395
|
+
(0, web_1.fail)(res, 403, 'You do not have permission to run workflows in this repository.', actor.viewer);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// The form's own business is reading the form: the input.* fields become
|
|
399
|
+
// the inputs, and everything after that is dispatchWorkflow's, which the
|
|
400
|
+
// API dispatch route calls too.
|
|
401
|
+
const inputs = {};
|
|
402
|
+
for (const [k, v] of Object.entries((req.body ?? {}))) {
|
|
403
|
+
if (k.startsWith('input.') && typeof v === 'string')
|
|
404
|
+
inputs[k.slice(6)] = v;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const run = await (0, dispatch_1.dispatchWorkflow)(engine, loaded.repo, loaded.branches, loaded.defaultBranch, actor.viewer.auth.username, { workflow: (0, web_1.field)(req, 'workflow'), ref: (0, web_1.field)(req, 'ref'), inputs });
|
|
408
|
+
res.redirect(`${base}/runs/${run.number}`);
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
(0, web_1.fail)(res, 400, e instanceof Error ? e.message : String(e), actor.viewer, base);
|
|
412
|
+
}
|
|
413
|
+
}));
|
|
414
|
+
// ---- runners, under Admin ----
|
|
415
|
+
// Registration says what a runner may do; the engine and the last-seen map
|
|
416
|
+
// say whether it is there and what it is doing, which is what an operator
|
|
417
|
+
// looking at a runner actually wants to know.
|
|
418
|
+
function runnerViews() {
|
|
419
|
+
const load = engine.runnerLoad();
|
|
420
|
+
return Object.entries((0, runners_1.loadRunners)(root).runners).map(([name, r]) => ({
|
|
421
|
+
name,
|
|
422
|
+
labels: r.labels,
|
|
423
|
+
allow: r.allow,
|
|
424
|
+
createdBy: r.createdBy,
|
|
425
|
+
createdAt: r.createdAt,
|
|
426
|
+
tokenUpdatedAt: r.tokenUpdatedAt,
|
|
427
|
+
lastSeen: (0, runners_1.runnerLastSeen)(name),
|
|
428
|
+
running: load.running[name] ?? null,
|
|
429
|
+
wakeUrl: r.wakeUrl ?? null,
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
app.get('/admin/runners', (req, res) => {
|
|
433
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
434
|
+
if (!viewer) {
|
|
435
|
+
res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (!(0, session_1.viewerIsAdmin)(viewer)) {
|
|
439
|
+
(0, web_1.fail)(res, 403, 'Admin access is required to manage runners.', viewer);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const flash = typeof req.query.flash === 'string' ? req.query.flash : undefined;
|
|
443
|
+
res.type('html').send(ciViews.runnersPage(viewer, runnerViews(), flash));
|
|
444
|
+
});
|
|
445
|
+
app.get('/admin/runners/:name', (req, res) => {
|
|
446
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
447
|
+
if (!viewer) {
|
|
448
|
+
res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!(0, session_1.viewerIsAdmin)(viewer)) {
|
|
452
|
+
(0, web_1.fail)(res, 403, 'Admin access is required to manage runners.', viewer);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const view = runnerViews().find((r) => r.name === req.params.name);
|
|
456
|
+
if (!view) {
|
|
457
|
+
(0, web_1.send404)(res, `No runner named ${req.params.name} is registered.`, viewer);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const flash = typeof req.query.flash === 'string' ? req.query.flash : undefined;
|
|
461
|
+
res.type('html').send(ciViews.runnerPage(viewer, view, (0, web_1.baseUrlOf)(req), flash));
|
|
462
|
+
});
|
|
463
|
+
app.post('/admin/runners', form, (req, res) => {
|
|
464
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
465
|
+
if (!viewer)
|
|
466
|
+
return;
|
|
467
|
+
if (!(0, session_1.viewerIsAdmin)(viewer)) {
|
|
468
|
+
(0, web_1.fail)(res, 403, 'Admin access is required to register runners.', viewer);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const name = (0, web_1.field)(req, 'name');
|
|
472
|
+
const labels = (0, web_1.field)(req, 'labels')
|
|
473
|
+
.split(/[\s,]+/)
|
|
474
|
+
.filter((s) => s.length > 0 && s.length < 200);
|
|
475
|
+
const allow = (0, web_1.field)(req, 'allow')
|
|
476
|
+
.split(/[\s,]+/)
|
|
477
|
+
.filter((s) => s.length > 0 && s.length < 200);
|
|
478
|
+
const registry = (0, runners_1.loadRunners)(root);
|
|
479
|
+
const showError = (msg) => {
|
|
480
|
+
res.status(400).type('html').send(ciViews.runnersPage(viewer, runnerViews(), undefined, msg));
|
|
481
|
+
};
|
|
482
|
+
if (!(0, scan_1.isValidName)(name)) {
|
|
483
|
+
showError('A runner needs a simple name (letters, digits, dot, dash, underscore).');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (registry.runners[name]) {
|
|
487
|
+
showError(`A runner named ${name} is already registered; remove it first.`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (allow.length === 0) {
|
|
491
|
+
showError('Say which repositories this runner may take jobs for, as globs over collection/repo.');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (labels.includes(manual_1.MANUAL_LABEL)) {
|
|
495
|
+
showError(`'${manual_1.MANUAL_LABEL}' is a reserved label: a job that names it runs only through a command pasted from its run page, never on a registered runner.`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const { token } = (0, runners_1.registerRunner)(root, name, {
|
|
499
|
+
labels: labels.length ? labels : ['ubuntu-latest'],
|
|
500
|
+
allow,
|
|
501
|
+
createdBy: viewer.auth.username,
|
|
502
|
+
});
|
|
503
|
+
res.type('html').send(ciViews.runnerTokenPage(viewer, name, token, (0, web_1.baseUrlOf)(req)));
|
|
504
|
+
});
|
|
505
|
+
app.post('/admin/runners/:name/token', form, (req, res) => {
|
|
506
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
507
|
+
if (!viewer)
|
|
508
|
+
return;
|
|
509
|
+
if (!(0, session_1.viewerIsAdmin)(viewer)) {
|
|
510
|
+
(0, web_1.fail)(res, 403, 'Admin access is required to manage runners.', viewer);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const name = req.params.name;
|
|
514
|
+
const existing = (0, runners_1.loadRunners)(root).runners[name];
|
|
515
|
+
if (!existing) {
|
|
516
|
+
(0, web_1.send404)(res, `No runner named ${name} is registered.`, viewer);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const issued = (0, runners_1.regenerateRunnerToken)(root, name);
|
|
520
|
+
if (!issued) {
|
|
521
|
+
(0, web_1.send404)(res, `No runner named ${name} is registered.`, viewer);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
res.type('html').send(ciViews.runnerTokenPage(viewer, name, issued.token, (0, web_1.baseUrlOf)(req), true));
|
|
525
|
+
});
|
|
526
|
+
// Setting the address and sending a request to it are separate posts
|
|
527
|
+
// because they are separate acts: one changes what is stored, the other
|
|
528
|
+
// starts a machine and waits for it. A single form doing both would make
|
|
529
|
+
// the slow one unavoidable.
|
|
530
|
+
app.post('/admin/runners/:name/wake', form, (req, res) => {
|
|
531
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
532
|
+
if (!viewer)
|
|
533
|
+
return;
|
|
534
|
+
if (!(0, session_1.viewerIsAdmin)(viewer)) {
|
|
535
|
+
(0, web_1.fail)(res, 403, 'Admin access is required to manage runners.', viewer);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const name = req.params.name;
|
|
539
|
+
const existing = (0, runners_1.loadRunners)(root).runners[name];
|
|
540
|
+
if (!existing) {
|
|
541
|
+
(0, web_1.send404)(res, `No runner named ${name} is registered.`, viewer);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const url = (0, web_1.field)(req, 'wakeUrl').trim();
|
|
545
|
+
const back = `/admin/runners/${encodeURIComponent(name)}`;
|
|
546
|
+
if (!url) {
|
|
547
|
+
(0, runners_1.setRunnerWake)(root, name, null);
|
|
548
|
+
res.redirect(`${back}?flash=${encodeURIComponent('Wake address removed; nothing will start this runner.')}`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
let parsed;
|
|
552
|
+
try {
|
|
553
|
+
parsed = new URL(url);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
(0, web_1.fail)(res, 400, `That is not a URL: ${url}`, viewer, back);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
560
|
+
(0, web_1.fail)(res, 400, 'A wake address must be an http or https URL.', viewer, back);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// The secret is generated rather than asked for, and shown once here, for
|
|
564
|
+
// the same reason a runner token is: it is the runner's copy that has to
|
|
565
|
+
// match, and there is nothing to be gained by having an operator invent
|
|
566
|
+
// one. Keeping the old secret when only the URL changed would be worse,
|
|
567
|
+
// since the address that was trusted with it is no longer the address
|
|
568
|
+
// being written.
|
|
569
|
+
const secret = (0, wake_1.newWakeSecret)();
|
|
570
|
+
(0, runners_1.setRunnerWake)(root, name, { url, secret });
|
|
571
|
+
res.type('html').send(ciViews.runnerWakePage(viewer, name, url, secret));
|
|
572
|
+
});
|
|
573
|
+
app.post('/admin/runners/:name/wake/send', form, async (req, res) => {
|
|
574
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
575
|
+
if (!viewer)
|
|
576
|
+
return;
|
|
577
|
+
if (!(0, session_1.viewerIsAdmin)(viewer)) {
|
|
578
|
+
(0, web_1.fail)(res, 403, 'Admin access is required to manage runners.', viewer);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const name = req.params.name;
|
|
582
|
+
const existing = (0, runners_1.loadRunners)(root).runners[name];
|
|
583
|
+
if (!existing) {
|
|
584
|
+
(0, web_1.send404)(res, `No runner named ${name} is registered.`, viewer);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const back = `/admin/runners/${encodeURIComponent(name)}`;
|
|
588
|
+
const wake = (0, wake_1.wakeOf)(existing);
|
|
589
|
+
if (!wake) {
|
|
590
|
+
(0, web_1.fail)(res, 400, `${name} has no wake address, so there is nothing to start it.`, viewer, back);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const started = Date.now();
|
|
594
|
+
try {
|
|
595
|
+
await (0, wake_1.sendWake)(wake);
|
|
596
|
+
}
|
|
597
|
+
catch (e) {
|
|
598
|
+
(0, web_1.fail)(res, 502, `${wake.url} did not answer: ${e instanceof Error ? e.message : String(e)}`, viewer, back);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
602
|
+
res.redirect(`${back}?flash=${encodeURIComponent(`${name} answered the wake request in ${secs}s.`)}`);
|
|
603
|
+
});
|
|
604
|
+
app.post('/admin/runners/:name/remove', form, (req, res) => {
|
|
605
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
606
|
+
if (!viewer)
|
|
607
|
+
return;
|
|
608
|
+
if (!(0, session_1.viewerIsAdmin)(viewer)) {
|
|
609
|
+
(0, web_1.fail)(res, 403, 'Admin access is required to manage runners.', viewer);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
// viewerIsAdmin above is the whole check: on the web, runners are the
|
|
613
|
+
// site admin's to manage.
|
|
614
|
+
(0, runners_1.removeRunner)(root, req.params.name);
|
|
615
|
+
res.redirect('/admin/runners?flash=' + encodeURIComponent(`Runner ${req.params.name} removed.`));
|
|
616
|
+
});
|
|
617
|
+
}
|