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.
Files changed (37) hide show
  1. package/dist/executer/execution-steps.js +5 -2
  2. package/dist/executer/scheduler/state-key.js +37 -5
  3. package/dist/harness/mcp.js +2 -1
  4. package/dist/harness/opencode.js +9 -2
  5. package/dist/harness/pi.js +15 -0
  6. package/dist/harness/sandbox-client.js +26 -2
  7. package/dist/harness/task-tools.js +12 -1
  8. package/dist/optimizer/calculate-checksum.js +3 -2
  9. package/dist/optimizer/check-key.js +94 -0
  10. package/dist/optimizer/check-keys.js +64 -0
  11. package/dist/optimizer/get-work-cache-stats.js +17 -3
  12. package/dist/optimizer/work-task-cache-description.js +49 -1
  13. package/dist/planner/utils/normalize-path.js +19 -1
  14. package/dist/planner/utils/parse-work-mount.js +7 -1
  15. package/dist/planner/validate.js +22 -0
  16. package/dist/remote/remote-work-commands.js +70 -0
  17. package/dist/remote/server-client.js +15 -2
  18. package/dist/run-program.js +9 -0
  19. package/dist/schema/reference-parser.js +8 -1
  20. package/dist/secrets/github-app-token-command.js +3 -1
  21. package/dist/server/accounts-api.js +1 -0
  22. package/dist/server/checks-api.js +83 -0
  23. package/dist/server/checks.js +116 -0
  24. package/dist/server/executor.js +41 -3
  25. package/dist/server/kubernetes-backend.js +1 -1
  26. package/dist/server/kubernetes-sandbox-storage.js +41 -1
  27. package/dist/server/plan.js +103 -78
  28. package/dist/server/policy.js +10 -2
  29. package/dist/server/run-request.js +12 -1
  30. package/dist/server/run-worker.js +16 -2
  31. package/dist/server/runs.js +18 -0
  32. package/dist/server/sandbox-pod.js +28 -0
  33. package/dist/server/sandbox-storage.js +23 -0
  34. package/dist/server/sandboxes.js +8 -0
  35. package/dist/server/server-config.js +14 -1
  36. package/dist/server/server.js +46 -3
  37. 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 said = answer && typeof answer.error === 'string' ? `: ${printable(answer.error)}` : '';
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 };
@@ -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
- relativeName: needName,
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
- throw e;
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
@@ -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 key = run.source.type === 'ref' ? `ref\0${run.source.repo}` : `${run.source.type}\0${run.owner.iss}\0${run.owner.sub}`;
40
- return `${run.source.type}-${(0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 24)}`;
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 === 'ref') {
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
  }