hammerkit 1.27.0-next.16 → 1.27.0-next.18
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/execution-steps.js +5 -2
- 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/planner/utils/normalize-path.js +19 -1
- package/dist/planner/utils/parse-work-mount.js +7 -1
- package/dist/planner/validate.js +22 -0
- package/dist/remote/remote-work-commands.js +70 -0
- package/dist/remote/server-client.js +15 -2
- package/dist/run-program.js +9 -0
- package/dist/schema/reference-parser.js +8 -1
- package/dist/secrets/github-app-token-command.js +3 -1
- 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/plan.js +103 -78
- 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
|
@@ -16,7 +16,19 @@ const sandbox_client_1 = require("../harness/sandbox-client");
|
|
|
16
16
|
const units_1 = require("../utils/units");
|
|
17
17
|
const service_tools_1 = require("../harness/service-tools");
|
|
18
18
|
const path_1 = require("path");
|
|
19
|
+
const check_keys_1 = require("../optimizer/check-keys");
|
|
19
20
|
const RUN_ID = /^run_[A-Za-z0-9_-]{16}$/;
|
|
21
|
+
// how a check passed, one line
|
|
22
|
+
function describePassed(passed) {
|
|
23
|
+
const from = passed.source.type === 'ref'
|
|
24
|
+
? `${passed.source.ref} at ${passed.source.commit.slice(0, 12)}`
|
|
25
|
+
: `a check of sandbox ${passed.source.sandbox}, fetched at ${passed.source.commit.slice(0, 12)}`;
|
|
26
|
+
return `passed ${passed.finishedAt}${passed.status === 'cached'
|
|
27
|
+
? ' from the cache'
|
|
28
|
+
: passed.durationMs !== undefined
|
|
29
|
+
? ` in ${(0, units_1.formatDuration)(passed.durationMs)}`
|
|
30
|
+
: ''}, ${passed.run} by ${passed.by.sub}, from ${from}`;
|
|
31
|
+
}
|
|
20
32
|
// what the publisher said about where a version comes from, one line
|
|
21
33
|
// What a publisher said about where a version comes from: the server takes it as said,
|
|
22
34
|
// so anybody else is told it is not checked.
|
|
@@ -158,6 +170,7 @@ function registerWorkCommands(remote, context) {
|
|
|
158
170
|
.addOption(new commander_1.Option('--cache <method>', 'as --cache of run').choices(['checksum', 'modify-date', 'none']))
|
|
159
171
|
.addOption(new commander_1.Option('--timeout <duration>', 'as --timeout of run: for tasks without their own, such as 30m'))
|
|
160
172
|
.addOption(new commander_1.Option('--max-timeout <duration>', 'as --max-timeout of run: for every task, also one with its own timeout'))
|
|
173
|
+
.addOption(new commander_1.Option('--detach', 'submit the run and exit, without following it: for a check that CI verifies later'))
|
|
161
174
|
.action(guarded('Remote run was not successful', async (tasks, options) => {
|
|
162
175
|
const named = options.build ?? environment.processEnvs.HAMMERKIT_BUILD;
|
|
163
176
|
const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
|
|
@@ -194,6 +207,10 @@ function registerWorkCommands(remote, context) {
|
|
|
194
207
|
}
|
|
195
208
|
const at = submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : '';
|
|
196
209
|
environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${tasks.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(ref)}${at}\n`);
|
|
210
|
+
if (options.detach) {
|
|
211
|
+
environment.stdout.write(`follow it with: hammerkit remote show ${(0, server_client_1.printable)(submitted.id)}\n`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
197
214
|
const prefixed = tasks.length > 1;
|
|
198
215
|
const followed = await (0, sandbox_client_1.followRun)(connection, submitted, {
|
|
199
216
|
signal: environment.abortCtrl.signal,
|
|
@@ -297,6 +314,59 @@ function registerWorkCommands(remote, context) {
|
|
|
297
314
|
const run = (await (0, server_client_1.callServer)(server, token, 'POST', `/v1/runs/${id}/cancel`)).body;
|
|
298
315
|
environment.stdout.write(`${(0, server_client_1.printable)(run.id)} on ${serverName}: ${run.state === 'up' || run.state === 'running' ? 'stopping' : (0, server_client_1.printable)(run.state)}\n`);
|
|
299
316
|
}));
|
|
317
|
+
remote
|
|
318
|
+
.command('verify <tasks...>')
|
|
319
|
+
.description('check that the server already passed tasks for the files checked out here, without running them; ' +
|
|
320
|
+
'exits 1 at once for any it did not')
|
|
321
|
+
.addOption(new commander_1.Option('--server <name>', 'the server to ask; the default of remote use when left out'))
|
|
322
|
+
.addOption(new commander_1.Option('--repo <host/path>', 'the repository, such as github.com/corp/payments; origin of the checkout here when left out'))
|
|
323
|
+
.addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
|
|
324
|
+
.action(guarded('Remote verify was not successful', async (tasks, options) => {
|
|
325
|
+
const repo = options.repo ?? (await (0, annotations_1.readCheckout)((0, path_1.dirname)(buildFile))).repo;
|
|
326
|
+
if (!repo) {
|
|
327
|
+
throw new Error('name the repository with --repo, the checkout here has no origin it can be told by');
|
|
328
|
+
}
|
|
329
|
+
const keys = [];
|
|
330
|
+
const unkeyed = [];
|
|
331
|
+
for (const task of tasks) {
|
|
332
|
+
const found = (await (0, check_keys_1.getCheckKeys)(buildFile, environment, { taskName: task, environmentName: options.env ?? null }))[task];
|
|
333
|
+
if (!found) {
|
|
334
|
+
throw new Error(`the build file has no task ${(0, server_client_1.printable)(task)}`);
|
|
335
|
+
}
|
|
336
|
+
if (found.key === null) {
|
|
337
|
+
unkeyed.push({ task, reason: found.reason });
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
keys.push({ task, key: found.key });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const { name: serverName, server, token } = await connect(options.server);
|
|
344
|
+
const answer = keys.length > 0
|
|
345
|
+
? await (0, server_client_1.callServer)(server, token, 'POST', '/v1/checks/lookup', { repo, keys })
|
|
346
|
+
: { body: { checks: [] } };
|
|
347
|
+
const missing = [];
|
|
348
|
+
// by what was asked, never by what was answered: a task the answer leaves out, or
|
|
349
|
+
// answers for another key, is not verified
|
|
350
|
+
for (const { task, key } of keys) {
|
|
351
|
+
const check = answer.body.checks.find((answered) => answered.task === task && answered.key === key);
|
|
352
|
+
if (check?.passed) {
|
|
353
|
+
environment.stdout.write(`${(0, server_client_1.printable)(task)}: ${(0, server_client_1.printable)(describePassed(check.passed))}\n`);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
missing.push(task);
|
|
357
|
+
environment.stdout.write(`${(0, server_client_1.printable)(task)}: no run on ${serverName} passed it for these files${check?.reason ? `, ${(0, server_client_1.printable)(check.reason)}` : ''}\n`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const { task, reason } of unkeyed) {
|
|
361
|
+
environment.stdout.write(`${(0, server_client_1.printable)(task)}: can not be verified, ${(0, server_client_1.printable)(reason)}\n`);
|
|
362
|
+
}
|
|
363
|
+
if (missing.length > 0 || unkeyed.length > 0) {
|
|
364
|
+
const run = missing.length > 0 ? `; run on the server: hammerkit remote run ${missing.map(server_client_1.printable).join(' ')}` : '';
|
|
365
|
+
// whole names: a task of an include is named like build:all
|
|
366
|
+
const names = [...missing, ...unkeyed.map(({ task }) => task)].map(server_client_1.printable);
|
|
367
|
+
fail(`not verified: ${names.join(', ')}${run}`, 1);
|
|
368
|
+
}
|
|
369
|
+
}));
|
|
300
370
|
remote
|
|
301
371
|
.command('versions [server]')
|
|
302
372
|
.description('list the published versions of build files on a server, newest first, with where they come from')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.callServer = exports.getMe = exports.getDiscovery = exports.printableWhole = exports.printable = exports.ServerError = void 0;
|
|
3
|
+
exports.callServer = exports.getMe = exports.getDiscovery = exports.printableWhole = exports.printableLines = exports.printable = exports.ServerError = void 0;
|
|
4
4
|
const discovery_1 = require("../server/discovery");
|
|
5
5
|
const TIMEOUT_MS = 10000;
|
|
6
6
|
const WRITE_TIMEOUT_MS = 30000;
|
|
@@ -21,6 +21,17 @@ function printable(value) {
|
|
|
21
21
|
return text.length > 300 ? `${text.slice(0, 300)}… (+${text.length - 300} chars)` : text;
|
|
22
22
|
}
|
|
23
23
|
exports.printable = printable;
|
|
24
|
+
// Text of several lines, such as every problem of a refused publish: each line made
|
|
25
|
+
// printable and cut like `printable`, one per line, and at most `MAX_LINES` of them.
|
|
26
|
+
const MAX_LINES = 100;
|
|
27
|
+
function printableLines(value) {
|
|
28
|
+
const lines = String(value)
|
|
29
|
+
.split(/\r?\n/)
|
|
30
|
+
.filter((line) => line.trim().length > 0);
|
|
31
|
+
const shown = lines.slice(0, MAX_LINES).map(printable);
|
|
32
|
+
return lines.length > MAX_LINES ? [...shown, `… (+${lines.length - MAX_LINES} lines)`] : shown;
|
|
33
|
+
}
|
|
34
|
+
exports.printableLines = printableLines;
|
|
24
35
|
// The same, never cut: for what somebody decides on, such as a command.
|
|
25
36
|
function printableWhole(value) {
|
|
26
37
|
return String(value)
|
|
@@ -92,7 +103,9 @@ async function callServer(server, token, method, path, body) {
|
|
|
92
103
|
throw new ServerError(`${server.url} did not accept the token`);
|
|
93
104
|
}
|
|
94
105
|
if (!response.ok) {
|
|
95
|
-
const
|
|
106
|
+
const lines = answer && typeof answer.error === 'string' ? printableLines(answer.error) : [];
|
|
107
|
+
// a refused publish says every problem, each on a line of its own
|
|
108
|
+
const said = lines.length > 1 ? `:\n${lines.map((line) => ` ${line}`).join('\n')}` : lines.length === 1 ? `: ${lines[0]}` : '';
|
|
96
109
|
throw new ServerError(`${server.url} answered ${response.status}${said}`, response.status);
|
|
97
110
|
}
|
|
98
111
|
return { status: response.status, body: answer };
|
package/dist/run-program.js
CHANGED
|
@@ -7,6 +7,8 @@ exports.runProgram = void 0;
|
|
|
7
7
|
const program_1 = require("./program");
|
|
8
8
|
const parse_error_1 = require("./schema/parse-error");
|
|
9
9
|
const colors_1 = __importDefault(require("colors"));
|
|
10
|
+
const commander_1 = require("commander");
|
|
11
|
+
const log_1 = require("./log");
|
|
10
12
|
function runProgram(env, argv, exitOverride) {
|
|
11
13
|
return (0, program_1.getProgram)(env, argv)
|
|
12
14
|
.then(({ program, args }) => {
|
|
@@ -18,8 +20,15 @@ function runProgram(env, argv, exitOverride) {
|
|
|
18
20
|
for (const error of err.zod.errors) {
|
|
19
21
|
env.stderr.write(` ${colors_1.default.underline(colors_1.default.blue(`error`))} at ${colors_1.default.gray(error.path.join('.'))} ${error.message} ${colors_1.default.gray(error.code)}\n`);
|
|
20
22
|
}
|
|
23
|
+
// printed, but still a failure: CI must not pass on a build file that doesn't parse
|
|
24
|
+
throw err;
|
|
21
25
|
}
|
|
22
26
|
else {
|
|
27
|
+
// what commander reports (program.error, a wrong option) is printed already; anything
|
|
28
|
+
// else, such as a build file that is no YAML, would otherwise end the process silently
|
|
29
|
+
if (!(err instanceof commander_1.CommanderError)) {
|
|
30
|
+
env.stderr.write(`${colors_1.default.red('error')}: ${(0, log_1.getErrorMessage)(err)}\n`);
|
|
31
|
+
}
|
|
23
32
|
throw err;
|
|
24
33
|
}
|
|
25
34
|
});
|
|
@@ -210,7 +210,9 @@ function resolveNeeds(reference, taskOrService, scope, schema) {
|
|
|
210
210
|
for (const needName of schema.needs || []) {
|
|
211
211
|
if (typeof needName === 'string') {
|
|
212
212
|
taskOrService.needs.push({
|
|
213
|
-
|
|
213
|
+
// the host name it is reached by: a service of an include, `services:postgres`, as
|
|
214
|
+
// `services-postgres`, since Docker and DNS take no colon
|
|
215
|
+
relativeName: needName.replace(/:/g, '-'),
|
|
214
216
|
service: findService(reference, scope, needName),
|
|
215
217
|
});
|
|
216
218
|
}
|
|
@@ -221,6 +223,11 @@ function resolveNeeds(reference, taskOrService, scope, schema) {
|
|
|
221
223
|
});
|
|
222
224
|
}
|
|
223
225
|
}
|
|
226
|
+
const names = taskOrService.needs.map((need) => need.relativeName);
|
|
227
|
+
const twice = names.find((name, index) => names.indexOf(name) !== index);
|
|
228
|
+
if (twice) {
|
|
229
|
+
throw new Error(`${taskOrService.relativeName} needs two services as ${twice}, give one another name with name:`);
|
|
230
|
+
}
|
|
224
231
|
}
|
|
225
232
|
function findTask(ctx, scope, name, type) {
|
|
226
233
|
const ref = (0, split_name_1.splitName)(name);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registerGithubAppTokenCommand = void 0;
|
|
4
|
+
const commander_1 = require("commander");
|
|
4
5
|
const log_1 = require("../log");
|
|
5
6
|
const github_app_token_1 = require("./github-app-token");
|
|
6
7
|
// `hammerkit github-app-token <owner/repo>`: a secret provider command for the token a
|
|
@@ -18,7 +19,8 @@ function registerGithubAppTokenCommand(program, environment) {
|
|
|
18
19
|
}
|
|
19
20
|
catch (e) {
|
|
20
21
|
environment.stderr.write(`github-app-token: ${(0, log_1.getErrorMessage)(e)}\n`);
|
|
21
|
-
|
|
22
|
+
// said already: the CLI does not print it again
|
|
23
|
+
throw new commander_1.CommanderError(1, 'hammerkit.githubAppToken', (0, log_1.getErrorMessage)(e));
|
|
22
24
|
}
|
|
23
25
|
});
|
|
24
26
|
}
|
|
@@ -31,6 +31,7 @@ async function handleAccounts(config, log, principal, req, res, url) {
|
|
|
31
31
|
accounts,
|
|
32
32
|
repos: grant.repos,
|
|
33
33
|
builds: grant.builds,
|
|
34
|
+
checks: grant.checks || grant.builds !== null ? 'read' : null,
|
|
34
35
|
sandboxes: grant.sandboxes,
|
|
35
36
|
endpoints: grant.endpoints,
|
|
36
37
|
});
|
|
@@ -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
|
}
|