@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,199 @@
|
|
|
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.MINT_TTL_MS = exports.MANUAL_LABEL = void 0;
|
|
37
|
+
exports.isManualJob = isManualJob;
|
|
38
|
+
exports.sessionRunnerName = sessionRunnerName;
|
|
39
|
+
exports.isManualRunnerName = isManualRunnerName;
|
|
40
|
+
exports.loadGrants = loadGrants;
|
|
41
|
+
exports.mintGrant = mintGrant;
|
|
42
|
+
exports.findMintable = findMintable;
|
|
43
|
+
exports.redeemGrant = redeemGrant;
|
|
44
|
+
exports.findSession = findSession;
|
|
45
|
+
exports.looksLikeSessionToken = looksLikeSessionToken;
|
|
46
|
+
exports.looksLikeMintToken = looksLikeMintToken;
|
|
47
|
+
const crypto = __importStar(require("crypto"));
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const atomic_1 = require("../atomic");
|
|
51
|
+
const runs_1 = require("./runs");
|
|
52
|
+
// Manual jobs: a job whose runs-on names the reserved label `manual` is never
|
|
53
|
+
// handed to a registered runner. It runs when someone with the write role
|
|
54
|
+
// mints a command from the run page and pastes it on a machine of their
|
|
55
|
+
// choosing, which is the arrangement for a machine that should execute
|
|
56
|
+
// repository code only while a person is watching it do so.
|
|
57
|
+
//
|
|
58
|
+
// The pasted command carries a *mint token*: single-use, scoped to one run,
|
|
59
|
+
// and dead fifteen minutes after minting if nobody redeems it. Redeeming it
|
|
60
|
+
// returns a *session token* that lives only in the redeeming process and
|
|
61
|
+
// works until the run completes. The split is the point: a command lands in
|
|
62
|
+
// shell history and terminal scrollback, and a copy found there later must
|
|
63
|
+
// buy nothing. Only hashes of either token are stored, as with every other
|
|
64
|
+
// credential in a vault.
|
|
65
|
+
//
|
|
66
|
+
// Grants live in <run dir>/manual.json, beside run.json rather than inside
|
|
67
|
+
// it, so that the run records served by the API and rendered by the UI never
|
|
68
|
+
// carry them. They are pruned with the run.
|
|
69
|
+
exports.MANUAL_LABEL = 'manual';
|
|
70
|
+
/** Whether a job (by its resolved runs-on labels) runs only by pasted command. */
|
|
71
|
+
function isManualJob(runsOn) {
|
|
72
|
+
return runsOn.includes(exports.MANUAL_LABEL);
|
|
73
|
+
}
|
|
74
|
+
/** How long a minted command waits to be pasted before it dies. */
|
|
75
|
+
exports.MINT_TTL_MS = 15 * 60 * 1000;
|
|
76
|
+
const MINT_PREFIX = 'mochi_run_';
|
|
77
|
+
const SESSION_PREFIX = 'mochi_manual_';
|
|
78
|
+
/** The runner name a session's leases are held under. */
|
|
79
|
+
function sessionRunnerName(grant) {
|
|
80
|
+
return `manual:${grant.id}`;
|
|
81
|
+
}
|
|
82
|
+
function isManualRunnerName(name) {
|
|
83
|
+
return name.startsWith('manual:');
|
|
84
|
+
}
|
|
85
|
+
function grantsPath(root, collection, repo, n) {
|
|
86
|
+
const base = (0, runs_1.runsDir)(root, collection, repo);
|
|
87
|
+
return base ? path.join(base, String(n), 'manual.json') : null;
|
|
88
|
+
}
|
|
89
|
+
function loadGrants(root, collection, repo, n) {
|
|
90
|
+
const file = grantsPath(root, collection, repo, n);
|
|
91
|
+
if (!file)
|
|
92
|
+
return [];
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
if (!Array.isArray(parsed))
|
|
101
|
+
return [];
|
|
102
|
+
return parsed.filter((g) => typeof g === 'object' &&
|
|
103
|
+
g !== null &&
|
|
104
|
+
typeof g.id === 'string' &&
|
|
105
|
+
typeof g.hash === 'string');
|
|
106
|
+
}
|
|
107
|
+
function saveGrants(root, collection, repo, n, grants) {
|
|
108
|
+
const file = grantsPath(root, collection, repo, n);
|
|
109
|
+
if (!file)
|
|
110
|
+
throw new Error('invalid repository');
|
|
111
|
+
(0, atomic_1.writeFileAtomic)(file, JSON.stringify(grants, null, 1) + '\n', { mode: 0o600 });
|
|
112
|
+
}
|
|
113
|
+
function hashToken(token) {
|
|
114
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
115
|
+
}
|
|
116
|
+
function sameHash(a, b) {
|
|
117
|
+
let x;
|
|
118
|
+
let y;
|
|
119
|
+
try {
|
|
120
|
+
x = Buffer.from(a, 'hex');
|
|
121
|
+
y = Buffer.from(b, 'hex');
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Mint a grant for a run and return the token, which is shown once and only
|
|
130
|
+
* its hash kept. Expired grants that were never redeemed are dropped on the
|
|
131
|
+
* way through: they are dead weight, and pruning them here means the file
|
|
132
|
+
* never accumulates the residue of commands nobody pasted.
|
|
133
|
+
*/
|
|
134
|
+
function mintGrant(root, collection, repo, n, mintedBy) {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const grants = loadGrants(root, collection, repo, n).filter((g) => g.redeemedAt !== undefined || Date.parse(g.expiresAt) > now);
|
|
137
|
+
const token = MINT_PREFIX + crypto.randomBytes(24).toString('hex');
|
|
138
|
+
const grant = {
|
|
139
|
+
id: crypto.randomBytes(4).toString('hex'),
|
|
140
|
+
hash: hashToken(token),
|
|
141
|
+
mintedBy,
|
|
142
|
+
mintedAt: new Date(now).toISOString(),
|
|
143
|
+
expiresAt: new Date(now + exports.MINT_TTL_MS).toISOString(),
|
|
144
|
+
};
|
|
145
|
+
grants.push(grant);
|
|
146
|
+
saveGrants(root, collection, repo, n, grants);
|
|
147
|
+
return { token, grant };
|
|
148
|
+
}
|
|
149
|
+
/** The grant a presented mint token names, if it is unexpired and unredeemed. */
|
|
150
|
+
function findMintable(grants, token) {
|
|
151
|
+
if (!token.startsWith(MINT_PREFIX))
|
|
152
|
+
return null;
|
|
153
|
+
const presented = hashToken(token);
|
|
154
|
+
for (const g of grants) {
|
|
155
|
+
if (g.redeemedAt !== undefined)
|
|
156
|
+
continue;
|
|
157
|
+
if (Date.parse(g.expiresAt) <= Date.now())
|
|
158
|
+
continue;
|
|
159
|
+
if (sameHash(g.hash, presented))
|
|
160
|
+
return g;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Redeem a grant: mark it redeemed, record the reported host, and mint the
|
|
166
|
+
* session token whose hash it will answer to from now on. The mint token is
|
|
167
|
+
* spent by this; presenting it again finds nothing.
|
|
168
|
+
*/
|
|
169
|
+
function redeemGrant(root, collection, repo, n, grantId, host) {
|
|
170
|
+
const grants = loadGrants(root, collection, repo, n);
|
|
171
|
+
const grant = grants.find((g) => g.id === grantId);
|
|
172
|
+
if (!grant || grant.redeemedAt !== undefined)
|
|
173
|
+
return null;
|
|
174
|
+
const session = SESSION_PREFIX + crypto.randomBytes(32).toString('hex');
|
|
175
|
+
grant.sessionHash = hashToken(session);
|
|
176
|
+
grant.redeemedAt = new Date().toISOString();
|
|
177
|
+
grant.host = host.slice(0, 200);
|
|
178
|
+
saveGrants(root, collection, repo, n, grants);
|
|
179
|
+
return session;
|
|
180
|
+
}
|
|
181
|
+
/** The redeemed grant a presented session token belongs to, or null. */
|
|
182
|
+
function findSession(grants, token) {
|
|
183
|
+
if (!token.startsWith(SESSION_PREFIX))
|
|
184
|
+
return null;
|
|
185
|
+
const presented = hashToken(token);
|
|
186
|
+
for (const g of grants) {
|
|
187
|
+
if (g.sessionHash === undefined)
|
|
188
|
+
continue;
|
|
189
|
+
if (sameHash(g.sessionHash, presented))
|
|
190
|
+
return g;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function looksLikeSessionToken(token) {
|
|
195
|
+
return token.startsWith(SESSION_PREFIX);
|
|
196
|
+
}
|
|
197
|
+
function looksLikeMintToken(token) {
|
|
198
|
+
return token.startsWith(MINT_PREFIX);
|
|
199
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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.hasCiState = hasCiState;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const runs_1 = require("./runs");
|
|
39
|
+
// Whether to show a repository's Actions tab. Two cheap questions: does the
|
|
40
|
+
// repository have any run history on disk, and does its default branch carry
|
|
41
|
+
// a workflow file? The second needs git, so the answer is memoized against
|
|
42
|
+
// the branch tip: one ls-tree per repository per new commit, not one per page
|
|
43
|
+
// view. A vault with no CI in it never grows the tab.
|
|
44
|
+
const memo = new Map();
|
|
45
|
+
async function hasWorkflowsAt(repo, sha) {
|
|
46
|
+
const cached = memo.get(repo.dir);
|
|
47
|
+
if (cached && cached.sha === sha)
|
|
48
|
+
return cached.has;
|
|
49
|
+
let has = false;
|
|
50
|
+
for (const dir of ['.github/workflows', '.mochi/workflows']) {
|
|
51
|
+
try {
|
|
52
|
+
const entries = await repo.listTree(sha, dir);
|
|
53
|
+
if (entries.some((e) => e.type === 'blob' && /\.(yml|yaml)$/i.test(e.name))) {
|
|
54
|
+
has = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// no such directory at this commit
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (memo.size > 2000)
|
|
63
|
+
memo.clear();
|
|
64
|
+
memo.set(repo.dir, { sha, has });
|
|
65
|
+
return has;
|
|
66
|
+
}
|
|
67
|
+
async function hasCiState(root, repo, defaultBranch, branches) {
|
|
68
|
+
const dir = (0, runs_1.runsDir)(root, repo.collection, repo.name);
|
|
69
|
+
if (dir) {
|
|
70
|
+
try {
|
|
71
|
+
if (fs.statSync(dir).isDirectory())
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// no runs yet
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const tip = branches.find((b) => b.name === defaultBranch);
|
|
79
|
+
if (!tip)
|
|
80
|
+
return false;
|
|
81
|
+
return hasWorkflowsAt(repo, tip.sha);
|
|
82
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The wire types of the runner protocol: what the server hands a runner when
|
|
3
|
+
// it acquires a job, and what the runner reports back. Both sides of the
|
|
4
|
+
// protocol live in this package (src/ci for the server, src/runner for the
|
|
5
|
+
// client), so these types are the single definition.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,256 @@
|
|
|
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.RUNNERS_FILE = void 0;
|
|
37
|
+
exports.runnersFilePath = runnersFilePath;
|
|
38
|
+
exports.loadRunners = loadRunners;
|
|
39
|
+
exports.hashRunnerToken = hashRunnerToken;
|
|
40
|
+
exports.registerRunner = registerRunner;
|
|
41
|
+
exports.regenerateRunnerToken = regenerateRunnerToken;
|
|
42
|
+
exports.setRunnerWake = setRunnerWake;
|
|
43
|
+
exports.removeRunner = removeRunner;
|
|
44
|
+
exports.noteRunnerSeen = noteRunnerSeen;
|
|
45
|
+
exports.runnerLastSeen = runnerLastSeen;
|
|
46
|
+
exports.authenticateRunner = authenticateRunner;
|
|
47
|
+
exports.runnerAllows = runnerAllows;
|
|
48
|
+
const crypto = __importStar(require("crypto"));
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const atomic_1 = require("../atomic");
|
|
52
|
+
const filecache_1 = require("../filecache");
|
|
53
|
+
const vault_1 = require("../vault");
|
|
54
|
+
// The runner registry, in <vault>/runners.json. A runner is not a user: it
|
|
55
|
+
// reads repositories (which are world-readable anyway) and writes run state
|
|
56
|
+
// for jobs it holds a lease on, and it can never push, edit, or administer.
|
|
57
|
+
// Keeping it out of vault.json keeps the two credential kinds from being
|
|
58
|
+
// confused for each other, and keeps a machine credential out of the file
|
|
59
|
+
// that governs human access.
|
|
60
|
+
//
|
|
61
|
+
// Only a hash of each runner token is stored, as with user tokens.
|
|
62
|
+
exports.RUNNERS_FILE = 'runners.json';
|
|
63
|
+
function runnersFilePath(root) {
|
|
64
|
+
return path.join(root, exports.RUNNERS_FILE);
|
|
65
|
+
}
|
|
66
|
+
function normalize(parsed) {
|
|
67
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
68
|
+
throw new Error('runners.json must be a JSON object');
|
|
69
|
+
const raw = parsed.runners;
|
|
70
|
+
if (raw === undefined)
|
|
71
|
+
return { runners: {} };
|
|
72
|
+
if (typeof raw !== 'object' || raw === null)
|
|
73
|
+
throw new Error('runners.json must have a "runners" object');
|
|
74
|
+
const runners = {};
|
|
75
|
+
for (const [name, v] of Object.entries(raw)) {
|
|
76
|
+
if (typeof v !== 'object' || v === null)
|
|
77
|
+
throw new Error(`runner ${name} must be an object`);
|
|
78
|
+
const r = v;
|
|
79
|
+
if (typeof r.hash !== 'string')
|
|
80
|
+
throw new Error(`runner ${name} needs a "hash"`);
|
|
81
|
+
const strings = (x, field) => {
|
|
82
|
+
if (x === undefined)
|
|
83
|
+
return [];
|
|
84
|
+
if (Array.isArray(x) && x.every((s) => typeof s === 'string'))
|
|
85
|
+
return x;
|
|
86
|
+
throw new Error(`runner ${name}: "${field}" must be a list of strings`);
|
|
87
|
+
};
|
|
88
|
+
runners[name] = {
|
|
89
|
+
hash: r.hash,
|
|
90
|
+
labels: strings(r.labels, 'labels'),
|
|
91
|
+
allow: strings(r.allow, 'allow'),
|
|
92
|
+
createdBy: typeof r.createdBy === 'string' ? r.createdBy : '',
|
|
93
|
+
createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
|
|
94
|
+
...(typeof r.tokenUpdatedAt === 'string' ? { tokenUpdatedAt: r.tokenUpdatedAt } : {}),
|
|
95
|
+
...(typeof r.wakeUrl === 'string' ? { wakeUrl: r.wakeUrl } : {}),
|
|
96
|
+
...(typeof r.wakeSecret === 'string' ? { wakeSecret: r.wakeSecret } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { runners };
|
|
100
|
+
}
|
|
101
|
+
const cache = (0, filecache_1.fileCache)({
|
|
102
|
+
read: (file) => {
|
|
103
|
+
try {
|
|
104
|
+
return normalize(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
console.error(`runners.json could not be read: ${e instanceof Error ? e.message : e}`);
|
|
108
|
+
return { runners: {} };
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
missing: () => ({ runners: {} }),
|
|
112
|
+
});
|
|
113
|
+
function loadRunners(root) {
|
|
114
|
+
return cache.get(runnersFilePath(root));
|
|
115
|
+
}
|
|
116
|
+
function write(root, registry) {
|
|
117
|
+
(0, atomic_1.writeFileAtomic)(runnersFilePath(root), JSON.stringify(registry, null, 2) + '\n', { mode: 0o600 });
|
|
118
|
+
cache.invalidate(runnersFilePath(root));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Hold the registry's lock across a read, an edit, and the write back.
|
|
122
|
+
*
|
|
123
|
+
* `mochi runner add` from a shell and the same call through the API edit
|
|
124
|
+
* this file the same way, and either one overlapping the other loses a runner
|
|
125
|
+
* outright. The cache is dropped on the way in because the decision it makes,
|
|
126
|
+
* that a file with an unchanged mtime and size has unchanged contents, is one
|
|
127
|
+
* this function cannot afford: another process may have written in the interval
|
|
128
|
+
* since the last read, and the copy about to be edited must be the one on disk.
|
|
129
|
+
*/
|
|
130
|
+
function editRunners(root, fn) {
|
|
131
|
+
return (0, atomic_1.withFileLock)(`${runnersFilePath(root)}.lock`, () => {
|
|
132
|
+
cache.invalidate(runnersFilePath(root));
|
|
133
|
+
return fn();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function hashRunnerToken(token) {
|
|
137
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
138
|
+
}
|
|
139
|
+
function newRunnerToken() {
|
|
140
|
+
return 'mochi_runner_' + crypto.randomBytes(32).toString('hex');
|
|
141
|
+
}
|
|
142
|
+
function registerRunner(root, name, opts) {
|
|
143
|
+
return editRunners(root, () => {
|
|
144
|
+
const registry = loadRunners(root);
|
|
145
|
+
const token = newRunnerToken();
|
|
146
|
+
const runner = {
|
|
147
|
+
hash: hashRunnerToken(token),
|
|
148
|
+
labels: opts.labels,
|
|
149
|
+
allow: opts.allow,
|
|
150
|
+
createdBy: opts.createdBy,
|
|
151
|
+
createdAt: new Date().toISOString(),
|
|
152
|
+
...(opts.wake ? { wakeUrl: opts.wake.url, wakeSecret: opts.wake.secret } : {}),
|
|
153
|
+
};
|
|
154
|
+
registry.runners[name] = runner;
|
|
155
|
+
write(root, registry);
|
|
156
|
+
return { token, runner };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Issue a new token for an existing runner, keeping its labels and allow list.
|
|
161
|
+
*
|
|
162
|
+
* The old token stops working the moment this returns, so a runner still
|
|
163
|
+
* polling with it gets a 401 and must be restarted with the new one. Returns
|
|
164
|
+
* null if no runner by that name is registered.
|
|
165
|
+
*/
|
|
166
|
+
function regenerateRunnerToken(root, name) {
|
|
167
|
+
return editRunners(root, () => {
|
|
168
|
+
const registry = loadRunners(root);
|
|
169
|
+
const existing = registry.runners[name];
|
|
170
|
+
if (!existing)
|
|
171
|
+
return null;
|
|
172
|
+
const token = newRunnerToken();
|
|
173
|
+
const runner = {
|
|
174
|
+
...existing,
|
|
175
|
+
hash: hashRunnerToken(token),
|
|
176
|
+
tokenUpdatedAt: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
registry.runners[name] = runner;
|
|
179
|
+
write(root, registry);
|
|
180
|
+
return { token, runner };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Point a runner's wake address somewhere, or clear it with null.
|
|
185
|
+
*
|
|
186
|
+
* Separate from registration because the two are learned at different times:
|
|
187
|
+
* `mochi deploy fly runner` registers the runner before the app it will
|
|
188
|
+
* run on exists, and only afterwards knows the URL that starts it. Returns
|
|
189
|
+
* null if no runner by that name is registered.
|
|
190
|
+
*/
|
|
191
|
+
function setRunnerWake(root, name, wake) {
|
|
192
|
+
return editRunners(root, () => {
|
|
193
|
+
const registry = loadRunners(root);
|
|
194
|
+
const existing = registry.runners[name];
|
|
195
|
+
if (!existing)
|
|
196
|
+
return null;
|
|
197
|
+
const runner = { ...existing };
|
|
198
|
+
delete runner.wakeUrl;
|
|
199
|
+
delete runner.wakeSecret;
|
|
200
|
+
if (wake) {
|
|
201
|
+
runner.wakeUrl = wake.url;
|
|
202
|
+
runner.wakeSecret = wake.secret;
|
|
203
|
+
}
|
|
204
|
+
registry.runners[name] = runner;
|
|
205
|
+
write(root, registry);
|
|
206
|
+
return runner;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function removeRunner(root, name) {
|
|
210
|
+
return editRunners(root, () => {
|
|
211
|
+
const registry = loadRunners(root);
|
|
212
|
+
if (!registry.runners[name])
|
|
213
|
+
return false;
|
|
214
|
+
delete registry.runners[name];
|
|
215
|
+
write(root, registry);
|
|
216
|
+
return true;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// When each runner last authenticated, kept in memory rather than in
|
|
220
|
+
// runners.json. A runner long-polls continuously, so recording this on disk
|
|
221
|
+
// would mean a write every few seconds per runner, for a fact that is only
|
|
222
|
+
// interesting while the server is up: after a restart every live runner
|
|
223
|
+
// re-announces itself within one poll, and one that does not is exactly the
|
|
224
|
+
// one worth reporting as absent.
|
|
225
|
+
const lastSeen = new Map();
|
|
226
|
+
function noteRunnerSeen(name) {
|
|
227
|
+
lastSeen.set(name, Date.now());
|
|
228
|
+
}
|
|
229
|
+
/** When this runner last spoke to the vault, or null if it has not since the server started. */
|
|
230
|
+
function runnerLastSeen(name) {
|
|
231
|
+
const at = lastSeen.get(name);
|
|
232
|
+
return at === undefined ? null : new Date(at).toISOString();
|
|
233
|
+
}
|
|
234
|
+
function authenticateRunner(root, token) {
|
|
235
|
+
const registry = loadRunners(root);
|
|
236
|
+
const presented = Buffer.from(hashRunnerToken(token), 'hex');
|
|
237
|
+
for (const [name, runner] of Object.entries(registry.runners)) {
|
|
238
|
+
let stored;
|
|
239
|
+
try {
|
|
240
|
+
stored = Buffer.from(runner.hash, 'hex');
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (stored.length === presented.length && crypto.timingSafeEqual(stored, presented)) {
|
|
246
|
+
return { name, runner };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
// Whether a runner is allowed to take jobs for a repository. An empty allow
|
|
252
|
+
// list means nothing, not everything: a runner handed out without a scope
|
|
253
|
+
// should sit idle rather than pick up every repository in the vault.
|
|
254
|
+
function runnerAllows(runner, collection, repo) {
|
|
255
|
+
return runner.allow.some((g) => (0, vault_1.globMatch)(g, `${collection}/${repo}`));
|
|
256
|
+
}
|