hammerkit 1.27.0-next.16 → 1.27.0-next.17
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/dist/executer/scheduler/state-key.js +37 -5
- package/dist/harness/mcp.js +2 -1
- package/dist/harness/opencode.js +9 -2
- package/dist/harness/pi.js +15 -0
- package/dist/harness/sandbox-client.js +26 -2
- package/dist/harness/task-tools.js +12 -1
- package/dist/optimizer/calculate-checksum.js +3 -2
- package/dist/optimizer/check-key.js +94 -0
- package/dist/optimizer/check-keys.js +64 -0
- package/dist/optimizer/get-work-cache-stats.js +17 -3
- package/dist/optimizer/work-task-cache-description.js +49 -1
- package/dist/remote/remote-work-commands.js +68 -0
- package/dist/server/accounts-api.js +1 -0
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/executor.js +41 -3
- package/dist/server/kubernetes-backend.js +1 -1
- package/dist/server/kubernetes-sandbox-storage.js +41 -1
- package/dist/server/policy.js +10 -2
- package/dist/server/run-request.js +12 -1
- package/dist/server/run-worker.js +16 -2
- package/dist/server/runs.js +18 -0
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-config.js +14 -1
- package/dist/server/server.js +46 -3
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleChecks = exports.MAX_LOOKUP_KEYS = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const http_1 = require("./http");
|
|
6
|
+
const policy_1 = require("./policy");
|
|
7
|
+
const checks_1 = require("./checks");
|
|
8
|
+
const repo_1 = require("./repo");
|
|
9
|
+
// the tasks one lookup may ask about
|
|
10
|
+
exports.MAX_LOOKUP_KEYS = 100;
|
|
11
|
+
const lookupSchema = zod_1.z
|
|
12
|
+
.object({
|
|
13
|
+
repo: zod_1.z.string().transform((value, ctx) => {
|
|
14
|
+
const repo = (0, repo_1.normalizeRepo)(value);
|
|
15
|
+
if (!repo) {
|
|
16
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: 'expected a repository like github.com/corp/payments' });
|
|
17
|
+
return zod_1.z.NEVER;
|
|
18
|
+
}
|
|
19
|
+
return repo;
|
|
20
|
+
}),
|
|
21
|
+
keys: zod_1.z
|
|
22
|
+
.array(zod_1.z
|
|
23
|
+
.object({
|
|
24
|
+
task: zod_1.z.string().min(1).max(200),
|
|
25
|
+
key: zod_1.z.string().regex(checks_1.CHECK_KEY_PATTERN, 'expected a check key, 64 hex digits'),
|
|
26
|
+
})
|
|
27
|
+
.strict())
|
|
28
|
+
.min(1)
|
|
29
|
+
.max(exports.MAX_LOOKUP_KEYS),
|
|
30
|
+
})
|
|
31
|
+
.strict();
|
|
32
|
+
/**
|
|
33
|
+
* What runs passed, for CI that verifies instead of running (`hammerkit remote verify`):
|
|
34
|
+
*
|
|
35
|
+
* POST /v1/checks/lookup { repo, keys: [{ task, key }] } the newest passed run per key
|
|
36
|
+
*
|
|
37
|
+
* The caller computed the keys from its own checkout (check-key.ts); a key nothing passed
|
|
38
|
+
* for, or whose version of the build file was yanked since, is answered with none.
|
|
39
|
+
*/
|
|
40
|
+
async function handleChecks(config, checks, definitions, log, principal, req, res, url) {
|
|
41
|
+
if (url.pathname !== '/v1/checks/lookup') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (req.method !== 'POST') {
|
|
45
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const parsed = lookupSchema.safeParse(await (0, http_1.readJson)(req));
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
const issue = parsed.error.issues[0];
|
|
51
|
+
(0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const { repo, keys } = parsed.data;
|
|
55
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
56
|
+
if (!(0, policy_1.mayReadChecks)(grant, repo)) {
|
|
57
|
+
log(`refuse checks iss=${principal.iss} sub=${principal.sub} repo=${repo}`);
|
|
58
|
+
(0, http_1.send)(res, 403, { error: `you may not read the checks of ${repo}` });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const answers = await Promise.all(keys.map(async ({ task, key }) => {
|
|
62
|
+
const record = await checks.find(repo, key);
|
|
63
|
+
const yanked = record ? definitions.find(record.definition)?.yanked : undefined;
|
|
64
|
+
if (!record || yanked) {
|
|
65
|
+
return {
|
|
66
|
+
task,
|
|
67
|
+
key,
|
|
68
|
+
passed: null,
|
|
69
|
+
...(yanked ? { reason: `it passed with ${record?.definition}, which was yanked since` } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const { repo: _repo, key: _key, accounts: _accounts, ...view } = record;
|
|
73
|
+
void _repo;
|
|
74
|
+
void _key;
|
|
75
|
+
void _accounts;
|
|
76
|
+
return { task, key, passed: view };
|
|
77
|
+
}));
|
|
78
|
+
log(`checks iss=${principal.iss} sub=${principal.sub} repo=${repo} passed=${answers.filter((a) => a.passed).length}/${answers.length}`);
|
|
79
|
+
(0, http_1.send)(res, 200, { checks: answers });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
exports.handleChecks = handleChecks;
|
|
83
|
+
//# sourceMappingURL=checks-api.js.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CheckStore = exports.getCheckRecords = exports.isCheckRun = exports.CHECK_KEY_PATTERN = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
exports.CHECK_KEY_PATTERN = /^[0-9a-f]{64}$/;
|
|
8
|
+
// The runs whose results are checks: those of a commit the server checked out, and those
|
|
9
|
+
// on a copy of the files of a sandbox, which hold nothing a commit of them would not. A run
|
|
10
|
+
// with arguments ran something other than the task, and is no check of it.
|
|
11
|
+
function isCheckRun(run) {
|
|
12
|
+
// a run of services keeps them up, it passes nothing
|
|
13
|
+
if ((run.options.args ?? []).length > 0 || run.up) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return run.source.type === 'ref' || (run.source.type === 'sandbox' && run.source.check === true);
|
|
17
|
+
}
|
|
18
|
+
exports.isCheckRun = isCheckRun;
|
|
19
|
+
// What a run that ended passed, with the keys its executor reported before the tasks ran.
|
|
20
|
+
function getCheckRecords(run, keys) {
|
|
21
|
+
if (!isCheckRun(run) || run.source.type === 'upload') {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const source = run.source.type === 'ref'
|
|
25
|
+
? { type: 'ref', ref: run.source.ref, commit: run.source.commit }
|
|
26
|
+
: { type: 'sandbox', sandbox: run.source.id, commit: run.source.commit, tree: run.source.tree };
|
|
27
|
+
const records = [];
|
|
28
|
+
for (const [task, result] of Object.entries(run.results ?? {})) {
|
|
29
|
+
const key = keys[task];
|
|
30
|
+
if (!key || !exports.CHECK_KEY_PATTERN.test(key) || (result.status !== 'succeeded' && result.status !== 'cached')) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
records.push({
|
|
34
|
+
repo: run.source.repo,
|
|
35
|
+
key,
|
|
36
|
+
task,
|
|
37
|
+
status: result.status,
|
|
38
|
+
run: run.id,
|
|
39
|
+
by: { iss: run.owner.iss, sub: run.owner.sub },
|
|
40
|
+
source,
|
|
41
|
+
definition: run.definition,
|
|
42
|
+
accounts: [...run.accounts],
|
|
43
|
+
finishedAt: result.finishedAt ?? run.finishedAt ?? new Date().toISOString(),
|
|
44
|
+
...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return records;
|
|
48
|
+
}
|
|
49
|
+
exports.getCheckRecords = getCheckRecords;
|
|
50
|
+
/**
|
|
51
|
+
* One file per repository and key, the newest run's: `<directory>/<repo hash>/<key>.json`,
|
|
52
|
+
* written to a temporary file and renamed. A record older than `keep` is not found and is
|
|
53
|
+
* removed by `sweep`.
|
|
54
|
+
*/
|
|
55
|
+
class CheckStore {
|
|
56
|
+
constructor(directory, options) {
|
|
57
|
+
this.directory = directory;
|
|
58
|
+
this.options = options;
|
|
59
|
+
this.now = options.now ?? Date.now;
|
|
60
|
+
}
|
|
61
|
+
async save(records) {
|
|
62
|
+
for (const record of records) {
|
|
63
|
+
const folder = (0, path_1.join)(this.directory, repoFolder(record.repo));
|
|
64
|
+
await (0, promises_1.mkdir)(folder, { recursive: true, mode: 0o700 });
|
|
65
|
+
const file = (0, path_1.join)(folder, `${record.key}.json`);
|
|
66
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
67
|
+
await (0, promises_1.writeFile)(temporary, JSON.stringify(record), { mode: 0o600 });
|
|
68
|
+
await (0, promises_1.rename)(temporary, file);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async find(repo, key) {
|
|
72
|
+
if (!exports.CHECK_KEY_PATTERN.test(key)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
let record;
|
|
76
|
+
try {
|
|
77
|
+
record = JSON.parse(await (0, promises_1.readFile)((0, path_1.join)(this.directory, repoFolder(repo), `${key}.json`), 'utf8'));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
if (record.repo !== repo || record.key !== key || this.expired(record)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return record;
|
|
86
|
+
}
|
|
87
|
+
// Removes the records that are older than `keep`.
|
|
88
|
+
async sweep() {
|
|
89
|
+
let folders;
|
|
90
|
+
try {
|
|
91
|
+
folders = await (0, promises_1.readdir)(this.directory);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const folder of folders) {
|
|
97
|
+
const path = (0, path_1.join)(this.directory, folder);
|
|
98
|
+
for (const name of await (0, promises_1.readdir)(path).catch(() => [])) {
|
|
99
|
+
const file = (0, path_1.join)(path, name);
|
|
100
|
+
const stats = await (0, promises_1.stat)(file).catch(() => undefined);
|
|
101
|
+
if (stats && this.now() - stats.mtimeMs > this.options.keep) {
|
|
102
|
+
await (0, promises_1.rm)(file, { force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
expired(record) {
|
|
108
|
+
const at = Date.parse(record.finishedAt);
|
|
109
|
+
return !Number.isFinite(at) || this.now() - at > this.options.keep;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
exports.CheckStore = CheckStore;
|
|
113
|
+
function repoFolder(repo) {
|
|
114
|
+
return (0, crypto_1.createHash)('sha256').update(repo).digest('hex').slice(0, 32);
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=checks.js.map
|
package/dist/server/executor.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.HostExecutor = exports.volumeScope = exports.cacheNamespace = exports.defaultWorker = void 0;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
5
|
const crypto_1 = require("crypto");
|
|
6
|
+
const checks_1 = require("./checks");
|
|
6
7
|
const fs_1 = require("fs");
|
|
7
8
|
const promises_1 = require("fs/promises");
|
|
8
9
|
const path_1 = require("path");
|
|
@@ -35,9 +36,18 @@ exports.defaultWorker = defaultWorker;
|
|
|
35
36
|
// share one, those of an upload or a sandbox only their owner's. Somebody who can only
|
|
36
37
|
// upload, or an agent on a sandbox, can not leave an entry for a run from a reviewed
|
|
37
38
|
// repository to find.
|
|
39
|
+
//
|
|
40
|
+
// A check on a sandbox runs on a copy of the files a commit of them would hold, so it is a
|
|
41
|
+
// run of the repository like a ref run of that commit, and shares its cache: what its
|
|
42
|
+
// owner may write in the sandbox is not part of it. It leaves entries a run from main can
|
|
43
|
+
// find, keyed by the files it ran on, as a ref run of a branch does: so only a caller who
|
|
44
|
+
// may push a branch of the repository and run from it may check (server.ts).
|
|
38
45
|
function cacheNamespace(run) {
|
|
39
|
-
const
|
|
40
|
-
|
|
46
|
+
const repository = run.source.type === 'ref' || (run.source.type === 'sandbox' && run.source.check);
|
|
47
|
+
const key = repository
|
|
48
|
+
? `ref\0${run.source.repo}`
|
|
49
|
+
: `${run.source.type}\0${run.owner.iss}\0${run.owner.sub}`;
|
|
50
|
+
return `${repository ? 'ref' : run.source.type}-${(0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 24)}`;
|
|
41
51
|
}
|
|
42
52
|
exports.cacheNamespace = cacheNamespace;
|
|
43
53
|
// The docker volumes of a run's services are its caller's, within the cache's source trust:
|
|
@@ -121,7 +131,34 @@ class HostExecutor {
|
|
|
121
131
|
let claim;
|
|
122
132
|
// the files of the sandbox the tasks work on
|
|
123
133
|
let work;
|
|
124
|
-
if (source.type === '
|
|
134
|
+
if (source.type === 'sandbox' && source.check) {
|
|
135
|
+
// a check works on the files a push of the sandbox commits, copied out of it: never
|
|
136
|
+
// on the sandbox, whose ignored files (node_modules, outputs) its owner writes
|
|
137
|
+
const { registry, network } = sandboxes;
|
|
138
|
+
cwd = (0, path_1.join)(root, 'source');
|
|
139
|
+
try {
|
|
140
|
+
await registry.exportTree(source.id, source.tree, cwd, abortCtrl.signal);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
throw new Error(`the files of the sandbox ${source.id} could not be copied for the check, submit it again: ${e instanceof Error ? e.message : String(e)}`);
|
|
144
|
+
}
|
|
145
|
+
// a repository of its own, as a checkout of the commit is: the root of the project,
|
|
146
|
+
// which the paths of the tasks' keys are relative to, is the one of git
|
|
147
|
+
await (0, git_checkout_1.git)(['init', '--quiet', cwd], {}, abortCtrl.signal, `the check of sandbox ${source.id}`, 'init');
|
|
148
|
+
context.emit({
|
|
149
|
+
type: 'log',
|
|
150
|
+
task: 'hammerkit',
|
|
151
|
+
stream: 'stdout',
|
|
152
|
+
text: `check of sandbox ${source.id} of ${source.repo}: files ${source.tree}, fetched at ${source.commit}`,
|
|
153
|
+
});
|
|
154
|
+
await this.writeBundle(cwd, bundle.files);
|
|
155
|
+
input = {
|
|
156
|
+
entry: (0, path_1.join)(cwd, version.entry),
|
|
157
|
+
...(planning.policy.network ? { network: (0, work_network_1.toNetworkSchema)(planning.policy.network) } : {}),
|
|
158
|
+
sandboxNetwork: network,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
else if (source.type === 'ref') {
|
|
125
162
|
cwd = (0, path_1.join)(root, 'source');
|
|
126
163
|
const { repo, ref, commit: pinned } = source;
|
|
127
164
|
const credentials = await repos.credentials(repo);
|
|
@@ -190,6 +227,7 @@ class HostExecutor {
|
|
|
190
227
|
options: run.options,
|
|
191
228
|
accounts: run.accounts,
|
|
192
229
|
volumeScope: volumeScope(run),
|
|
230
|
+
...((0, checks_1.isCheckRun)(run) ? { check: true } : {}),
|
|
193
231
|
catalog: planning.environment.hosted?.catalog,
|
|
194
232
|
...(run.up
|
|
195
233
|
? {
|
|
@@ -569,7 +569,7 @@ exports.KubernetesExecutor = KubernetesExecutor;
|
|
|
569
569
|
// cache, and on a sandbox the runs of that sandbox only, whose claim is on the same node or
|
|
570
570
|
// zone as the kept ones. The owner is the namespace's.
|
|
571
571
|
function getVolumeScope(run) {
|
|
572
|
-
if (run.source.type === 'sandbox') {
|
|
572
|
+
if (run.source.type === 'sandbox' && !run.source.check) {
|
|
573
573
|
return `sandbox-${(0, crypto_1.createHash)('sha256').update(run.source.id).digest('hex').slice(0, 24)}`;
|
|
574
574
|
}
|
|
575
575
|
return (0, executor_1.cacheNamespace)(run);
|
|
@@ -261,6 +261,41 @@ class KubernetesSandboxStorage {
|
|
|
261
261
|
await (0, promises_1.rm)(repository, { recursive: true, force: true });
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
+
// The pod writes the tree's files beside the sandbox on the claim and sends them as a tar,
|
|
265
|
+
// which is extracted here as the files of `attach` are: what a run plans with, up to the
|
|
266
|
+
// bytes a sandbox may take.
|
|
267
|
+
async exportTree(key, tree, into, signal) {
|
|
268
|
+
await (0, promises_1.mkdir)(into, { recursive: true });
|
|
269
|
+
let bytes = 0;
|
|
270
|
+
const extract = (0, tar_1.extract)({
|
|
271
|
+
cwd: into,
|
|
272
|
+
filter: (_path, entry) => 'type' in entry && ['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type),
|
|
273
|
+
});
|
|
274
|
+
const counted = new stream_1.PassThrough();
|
|
275
|
+
counted.on('data', (chunk) => {
|
|
276
|
+
bytes += chunk.length;
|
|
277
|
+
if (bytes > this.limits.bytes) {
|
|
278
|
+
counted.destroy(new Error(`the files of the sandbox ${key.id} take more than ${(0, units_1.formatSize)(this.limits.bytes)}`));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
const extracted = new Promise((resolve, reject) => {
|
|
282
|
+
extract.on('finish', resolve);
|
|
283
|
+
extract.on('error', reject);
|
|
284
|
+
counted.on('error', reject);
|
|
285
|
+
});
|
|
286
|
+
extracted.catch(() => undefined);
|
|
287
|
+
counted.pipe(extract);
|
|
288
|
+
const answer = await this.exclusive(key.id, () => this.call(key, { op: 'archive', id: key.id, tree }, signal, true));
|
|
289
|
+
if ('stream' in answer) {
|
|
290
|
+
// a pipeline, so the answer is ended with the copy: an error of the pod after the copy
|
|
291
|
+
// stopped (over the size limit) lands nowhere else
|
|
292
|
+
await (0, promises_2.pipeline)(answer.stream, counted);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
counted.end(answer.body);
|
|
296
|
+
}
|
|
297
|
+
await extracted;
|
|
298
|
+
}
|
|
264
299
|
async moveTo(key, commit) {
|
|
265
300
|
await this.request(key, { op: 'move', id: key.id, commit }, AbortSignal.timeout(FETCH_TIMEOUT_MS));
|
|
266
301
|
}
|
|
@@ -506,7 +541,7 @@ class KubernetesSandboxStorage {
|
|
|
506
541
|
const reader = new AnswerReader(body);
|
|
507
542
|
// the process ends when the server stops waiting for it: closing the connection does
|
|
508
543
|
// not end a command in a pod
|
|
509
|
-
const seconds = Math.ceil((['create', 'fetch', 'move'].includes(request.op) ? FETCH_TIMEOUT_MS : REQUEST_TIMEOUT_MS) / 1000);
|
|
544
|
+
const seconds = Math.ceil((['create', 'fetch', 'move', 'archive'].includes(request.op) ? FETCH_TIMEOUT_MS : REQUEST_TIMEOUT_MS) / 1000);
|
|
510
545
|
const done = this.withPod(key, (pod) => this.exec(pod, ['timeout', String(seconds), 'hammerkit', 'server-sandbox', CLAIM_PATH], stream_1.Readable.from([Buffer.from(JSON.stringify(encoded))]), reader, signal));
|
|
511
546
|
done.then(() => reader.complete(), (e) => reader.fail(e));
|
|
512
547
|
const answer = await reader.answer;
|
|
@@ -889,6 +924,11 @@ class AnswerReader extends stream_1.Writable {
|
|
|
889
924
|
}
|
|
890
925
|
_write(chunk, _encoding, callback) {
|
|
891
926
|
if (this.stream) {
|
|
927
|
+
if (this.stream.destroyed) {
|
|
928
|
+
// its reader stopped (over a size limit): the rest of the answer is nobody's
|
|
929
|
+
callback();
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
892
932
|
this.stream.write(chunk, callback);
|
|
893
933
|
return;
|
|
894
934
|
}
|
package/dist/server/policy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.endpointLevel = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
|
|
3
|
+
exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.endpointLevel = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayReadChecks = exports.mayRun = exports.matches = void 0;
|
|
4
4
|
const repo_1 = require("./repo");
|
|
5
5
|
const BUILDS_ORDER = ['run', 'propose', 'publish'];
|
|
6
6
|
function matches(entitlement, principal) {
|
|
@@ -22,6 +22,12 @@ function atLeast(grant, level) {
|
|
|
22
22
|
}
|
|
23
23
|
const mayRun = (grant) => atLeast(grant, 'run');
|
|
24
24
|
exports.mayRun = mayRun;
|
|
25
|
+
// Who may run from a repository's refs may see what passed for its files, and so may who
|
|
26
|
+
// is entitled to just that (a CI job that only verifies).
|
|
27
|
+
function mayReadChecks(grant, repo) {
|
|
28
|
+
return (grant.checks || grant.builds !== null) && (0, repo_1.matchesAnyRepo)(grant.repos, repo);
|
|
29
|
+
}
|
|
30
|
+
exports.mayReadChecks = mayReadChecks;
|
|
25
31
|
const mayPropose = (grant) => atLeast(grant, 'propose');
|
|
26
32
|
exports.mayPropose = mayPropose;
|
|
27
33
|
const mayPublish = (grant) => atLeast(grant, 'publish');
|
|
@@ -57,11 +63,13 @@ function getGrant(entitlements, principal) {
|
|
|
57
63
|
const sandboxes = [];
|
|
58
64
|
const endpoints = [];
|
|
59
65
|
let builds = null;
|
|
66
|
+
let checks = false;
|
|
60
67
|
entitlements.forEach((entitlement, index) => {
|
|
61
68
|
if (!matches(entitlement, principal)) {
|
|
62
69
|
return;
|
|
63
70
|
}
|
|
64
71
|
matched.push(index);
|
|
72
|
+
checks = checks || entitlement.checks === 'read';
|
|
65
73
|
for (const [account, level] of Object.entries(entitlement.accounts)) {
|
|
66
74
|
if (accounts[account] !== 'use') {
|
|
67
75
|
accounts[account] = level;
|
|
@@ -96,7 +104,7 @@ function getGrant(entitlements, principal) {
|
|
|
96
104
|
}
|
|
97
105
|
}
|
|
98
106
|
});
|
|
99
|
-
return { accounts, repos, builds, sandboxes, endpoints, matched };
|
|
107
|
+
return { accounts, repos, builds, sandboxes, endpoints, checks, matched };
|
|
100
108
|
}
|
|
101
109
|
exports.getGrant = getGrant;
|
|
102
110
|
// What the caller may do with the endpoints of service runs of a repository: the higher
|
|
@@ -94,7 +94,13 @@ exports.runRequestSchema = zod_1.z
|
|
|
94
94
|
zod_1.z.object({ type: zod_1.z.literal('upload'), snapshot: zod_1.z.string().regex(bundle_1.HASH_PATTERN) }).strict(),
|
|
95
95
|
// a sandbox of the caller's, see POST /v1/sandboxes
|
|
96
96
|
zod_1.z
|
|
97
|
-
.object({
|
|
97
|
+
.object({
|
|
98
|
+
type: zod_1.z.literal('sandbox'),
|
|
99
|
+
id: zod_1.z.string().regex(runs_1.SANDBOX_ID_PATTERN, 'expected a sandbox id'),
|
|
100
|
+
// a check: run on a copy of the files a push of the sandbox would commit, so what
|
|
101
|
+
// passes counts for that commit (remote verify)
|
|
102
|
+
check: zod_1.z.literal(true).optional(),
|
|
103
|
+
})
|
|
98
104
|
.strict(),
|
|
99
105
|
]),
|
|
100
106
|
options: exports.runOptionsSchema.default({}),
|
|
@@ -144,5 +150,10 @@ exports.runRequestSchema = zod_1.z
|
|
|
144
150
|
.refine((request) => (request.options.args ?? []).length === 0 || request.tasks.length === 1, {
|
|
145
151
|
message: 'arguments go to one task, name exactly one in tasks',
|
|
146
152
|
path: ['options', 'args'],
|
|
153
|
+
})
|
|
154
|
+
.refine((request) => request.source.type !== 'sandbox' || !request.source.check || (request.options.args ?? []).length === 0, { message: 'a check runs the task as the build file says, it takes no arguments', path: ['options', 'args'] })
|
|
155
|
+
.refine((request) => request.source.type !== 'sandbox' || !request.source.check || request.services.length === 0, {
|
|
156
|
+
message: 'a check runs tasks, a run of services keeps them up and passes nothing',
|
|
157
|
+
path: ['services'],
|
|
147
158
|
});
|
|
148
159
|
//# sourceMappingURL=run-request.js.map
|
|
@@ -22,6 +22,7 @@ const work_network_1 = require("../planner/work-network");
|
|
|
22
22
|
const run_plan_1 = require("./run-plan");
|
|
23
23
|
const runs_1 = require("./runs");
|
|
24
24
|
const resources_1 = require("../kubernetes/resources");
|
|
25
|
+
const check_keys_1 = require("../optimizer/check-keys");
|
|
25
26
|
// one log line of a task is cut here, the registry caps how many events a run keeps
|
|
26
27
|
const MAX_LOG_TEXT = 8 * 1024;
|
|
27
28
|
/**
|
|
@@ -69,6 +70,8 @@ exports.workerInputSchema = zod_1.z
|
|
|
69
70
|
})
|
|
70
71
|
.strict()
|
|
71
72
|
.optional(),
|
|
73
|
+
// the run's results count as checks: report each task's check key before it runs
|
|
74
|
+
check: zod_1.z.boolean().optional(),
|
|
72
75
|
})
|
|
73
76
|
.strict();
|
|
74
77
|
// The ceiling of the run, combined here and not in the server: the lower of two allow lists
|
|
@@ -183,7 +186,8 @@ up) {
|
|
|
183
186
|
const options = input.options;
|
|
184
187
|
const execOptions = {
|
|
185
188
|
logMode: 'live',
|
|
186
|
-
|
|
189
|
+
// a check takes a cached result only for the same files, never for the same dates
|
|
190
|
+
cacheDefault: input.check && options.cache === 'modify-date' ? 'checksum' : options.cache ?? 'checksum',
|
|
187
191
|
cacheReadOnly: options.cacheReadOnly ?? false,
|
|
188
192
|
timeout: options.timeout ? (0, units_1.parseDuration)(options.timeout) : null,
|
|
189
193
|
maxTimeout: options.maxTimeout ? (0, units_1.parseDuration)(options.maxTimeout) : null,
|
|
@@ -192,7 +196,17 @@ up) {
|
|
|
192
196
|
// why a task was not taken from the cache goes into its result
|
|
193
197
|
explain: true,
|
|
194
198
|
};
|
|
195
|
-
|
|
199
|
+
const scopes = (0, run_plan_1.getRunScopes)(input.tasks, options, input.up?.services);
|
|
200
|
+
if (input.check) {
|
|
201
|
+
// the files as they are before any task wrote to them, a second task's too: a first
|
|
202
|
+
// one that formats or generates into src would key it by files no commit holds
|
|
203
|
+
for (const { scope } of scopes) {
|
|
204
|
+
for (const [task, { key }] of Object.entries(await (0, check_keys_1.getCheckKeys)(input.entry, environment, scope))) {
|
|
205
|
+
emit({ type: 'key', task, key });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const { name, scope } of scopes) {
|
|
196
210
|
const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(input.entry, environment, {
|
|
197
211
|
directory: input.directory,
|
|
198
212
|
});
|
package/dist/server/runs.js
CHANGED
|
@@ -30,6 +30,8 @@ exports.SANDBOX_ID_PATTERN = /^sbx_[A-Za-z0-9_-]{16}$/;
|
|
|
30
30
|
function onSandbox(run, sandbox) {
|
|
31
31
|
return run.source.type === 'sandbox' && run.source.id === sandbox;
|
|
32
32
|
}
|
|
33
|
+
// the tasks of a run whose check keys are kept, a build file has fewer
|
|
34
|
+
const MAX_KEYED_TASKS = 1000;
|
|
33
35
|
// how much of the end of a task's output its result carries
|
|
34
36
|
const TAIL_LINES = 20;
|
|
35
37
|
const TAIL_LINE_LENGTH = 1000;
|
|
@@ -100,6 +102,7 @@ class RunRegistry {
|
|
|
100
102
|
bytes: 0,
|
|
101
103
|
tails: new Map(Object.entries(run.results ?? {}).map(([task, result]) => [task, result.lastLog])),
|
|
102
104
|
saving: Promise.resolve(),
|
|
105
|
+
keys: {},
|
|
103
106
|
};
|
|
104
107
|
this.entries.set(run.id, entry);
|
|
105
108
|
if (run.state === 'awaiting-approval') {
|
|
@@ -166,6 +169,7 @@ class RunRegistry {
|
|
|
166
169
|
bytes: 0,
|
|
167
170
|
tails: new Map(),
|
|
168
171
|
saving: Promise.resolve(),
|
|
172
|
+
keys: {},
|
|
169
173
|
};
|
|
170
174
|
this.entries.set(run.id, entry);
|
|
171
175
|
if (waiting) {
|
|
@@ -790,9 +794,23 @@ class RunRegistry {
|
|
|
790
794
|
}
|
|
791
795
|
this.record(entry, { type: 'state', state, ...(error ? { error } : {}) }, true);
|
|
792
796
|
entry.listeners.clear();
|
|
797
|
+
try {
|
|
798
|
+
this.options.onFinished?.(this.copy(entry.run), { ...entry.keys });
|
|
799
|
+
}
|
|
800
|
+
catch (e) {
|
|
801
|
+
this.options.log?.(`run ${entry.run.id} ended, but what it passed was not kept: ${e instanceof Error ? e.message : e}`);
|
|
802
|
+
}
|
|
793
803
|
this.evict();
|
|
794
804
|
}
|
|
795
805
|
record(entry, recorded, force = false) {
|
|
806
|
+
if (recorded.type === 'key') {
|
|
807
|
+
// a task name is the executor's; the first key it reports for a task is the one
|
|
808
|
+
// computed before anything of the run had run
|
|
809
|
+
if (!(recorded.task in entry.keys) && Object.keys(entry.keys).length < MAX_KEYED_TASKS) {
|
|
810
|
+
entry.keys[recorded.task] = recorded.key;
|
|
811
|
+
}
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
796
814
|
const event = recorded.type === 'task' ? bounded(recorded) : recorded;
|
|
797
815
|
if (event.type === 'log') {
|
|
798
816
|
this.keepTail(entry, event.task, event.text);
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.sandboxPodMain = exports.decodePodError = exports.encodeEditOps = exports.INCOMING_REF = exports.INCOMING = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
4
5
|
const promises_1 = require("fs/promises");
|
|
5
6
|
const path_1 = require("path");
|
|
6
7
|
const url_1 = require("url");
|
|
7
8
|
const git_checkout_1 = require("./git-checkout");
|
|
8
9
|
const sandbox_files_1 = require("./sandbox-files");
|
|
9
10
|
const sandbox_storage_1 = require("./sandbox-storage");
|
|
11
|
+
const tar_1 = require("../utils/tar");
|
|
10
12
|
// On a cluster a sandbox is a claim, and what the server does with its files runs in a
|
|
11
13
|
// short pod that mounts it (see KubernetesSandboxStorage): `hammerkit server-sandbox
|
|
12
14
|
// <directory>` reads one request on its standard input, does it with the code of a host
|
|
@@ -91,6 +93,32 @@ async function answer(storage, directory, request, out) {
|
|
|
91
93
|
}
|
|
92
94
|
return send({ files: plan.files, bytes: plan.bytes });
|
|
93
95
|
}
|
|
96
|
+
case 'archive': {
|
|
97
|
+
// written beside the sandbox on the claim, sent, and removed
|
|
98
|
+
const into = (0, path_1.join)(directory, `${request.id}-archive-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
|
|
99
|
+
try {
|
|
100
|
+
await storage.exportTree(key, request.tree, into, AbortSignal.timeout(10 * 60000));
|
|
101
|
+
await write(out, JSON.stringify({ ok: true, result: null, stream: true }) + '\n');
|
|
102
|
+
const pack = (0, tar_1.packArchive)({ cwd: into, portable: true }, ['.']);
|
|
103
|
+
try {
|
|
104
|
+
for await (const chunk of pack) {
|
|
105
|
+
await write(out, chunk);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
throw new AnswerStarted(e instanceof Error ? e.message : String(e));
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
// a reader that went away (over its size limit) leaves the pack reading files
|
|
113
|
+
// that are removed next: what fails then is no answer of anybody's
|
|
114
|
+
pack.on('error', () => undefined);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await (0, promises_1.rm)(into, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
94
122
|
case 'raw': {
|
|
95
123
|
const raw = await files().raw(request.path);
|
|
96
124
|
const header = {
|
|
@@ -276,6 +276,29 @@ class HostSandboxStorage {
|
|
|
276
276
|
// files change, and what it wrote to the git data is pruned again. Files a task made
|
|
277
277
|
// unreadable are made readable first: git would leave them out and call the sandbox
|
|
278
278
|
// clean.
|
|
279
|
+
async exportTree({ id }, tree, into, signal) {
|
|
280
|
+
if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(tree)) {
|
|
281
|
+
throw new Error(`${JSON.stringify(tree.slice(0, 80))} is not a git tree`);
|
|
282
|
+
}
|
|
283
|
+
return this.withIndex(id, signal, async (run) => {
|
|
284
|
+
const now = (await run(['write-tree'], 'read')).trim();
|
|
285
|
+
if (now !== tree) {
|
|
286
|
+
throw new Error(`the files of the sandbox ${id} are ${now} now, not ${tree}`);
|
|
287
|
+
}
|
|
288
|
+
// an index of its own holds the tree alone, whatever the sandbox's index says of its
|
|
289
|
+
// entries; the files are written as git has them, through no filter of the server's
|
|
290
|
+
const index = (0, path_1.join)(this.root(id), `export-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
|
|
291
|
+
await (0, promises_1.mkdir)(into, { recursive: true });
|
|
292
|
+
try {
|
|
293
|
+
const env = this.readEnv({ GIT_INDEX_FILE: index });
|
|
294
|
+
await this.git(id, ['read-tree', tree], env, signal, 'read');
|
|
295
|
+
await this.git(id, ['checkout-index', '--all', '--force', `--prefix=${(0, path_1.join)(into, '/')}`], env, signal, 'read');
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
await (0, promises_1.rm)(index, { force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
279
302
|
async state({ id }, signal = AbortSignal.timeout(5 * 60 * 1000)) {
|
|
280
303
|
return this.withIndex(id, signal, async (run) => {
|
|
281
304
|
const tree = (await run(['write-tree'], 'read')).trim();
|
package/dist/server/sandboxes.js
CHANGED
|
@@ -412,6 +412,14 @@ class SandboxRegistry {
|
|
|
412
412
|
async state(id, signal) {
|
|
413
413
|
return this.options.storage.state(keyOf(this.existing(id)), signal);
|
|
414
414
|
}
|
|
415
|
+
// The files of `tree`, which the sandbox's files must be now, written into `into`: what a
|
|
416
|
+
// check runs on.
|
|
417
|
+
async exportTree(id, tree, into, signal) {
|
|
418
|
+
const sandbox = this.existing(id);
|
|
419
|
+
sandbox.lastUsedAt = this.timestamp();
|
|
420
|
+
await this.save(sandbox);
|
|
421
|
+
await this.options.storage.exportTree(keyOf(sandbox), tree, into, signal);
|
|
422
|
+
}
|
|
415
423
|
// What a run works on, `into` a directory of the run when the storage copies the files;
|
|
416
424
|
// the run is the sandbox's use.
|
|
417
425
|
async attach(id, into) {
|
|
@@ -341,6 +341,9 @@ exports.entitlementSchema = zod_1.z
|
|
|
341
341
|
// the repositories (`host/path` patterns) the caller may run from a git ref
|
|
342
342
|
repos: zod_1.z.array(repoPatternSchema).default([]),
|
|
343
343
|
builds: exports.buildsLevelSchema.optional(),
|
|
344
|
+
// `read`: may look up which tasks runs passed for files of the `repos` (remote verify),
|
|
345
|
+
// and nothing more; a builds level includes it
|
|
346
|
+
checks: zod_1.z.literal('read').optional(),
|
|
344
347
|
// the repositories the caller may keep sandboxes of, checkouts on the server that runs
|
|
345
348
|
// work on: a list of its own, the `repos` above are for runs from a ref
|
|
346
349
|
sandboxes: zod_1.z.array(exports.sandboxGrantSchema).optional(),
|
|
@@ -352,11 +355,19 @@ exports.entitlementSchema = zod_1.z
|
|
|
352
355
|
checkWho(entitlement, ctx);
|
|
353
356
|
if (Object.keys(entitlement.accounts).length === 0 &&
|
|
354
357
|
entitlement.builds === undefined &&
|
|
358
|
+
entitlement.checks === undefined &&
|
|
355
359
|
!entitlement.sandboxes?.length &&
|
|
356
360
|
!entitlement.endpoints?.length) {
|
|
357
361
|
ctx.addIssue({
|
|
358
362
|
code: zod_1.z.ZodIssueCode.custom,
|
|
359
|
-
message: 'name accounts to use or request, a builds level, sandboxes or endpoints',
|
|
363
|
+
message: 'name accounts to use or request, a builds level, checks, sandboxes or endpoints',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
if (entitlement.checks && entitlement.repos.length === 0) {
|
|
367
|
+
ctx.addIssue({
|
|
368
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
369
|
+
path: ['repos'],
|
|
370
|
+
message: 'checks are read for repositories: name them in repos',
|
|
360
371
|
});
|
|
361
372
|
}
|
|
362
373
|
});
|
|
@@ -509,6 +520,8 @@ exports.serverConfigSchema = zod_1.z
|
|
|
509
520
|
})
|
|
510
521
|
.strict()
|
|
511
522
|
.default({}),
|
|
523
|
+
// how long a passed check stays for `remote verify` to find (see checks.ts)
|
|
524
|
+
checksKeep: duration.default('30d'),
|
|
512
525
|
})
|
|
513
526
|
.strict()
|
|
514
527
|
.default({}),
|