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.
@@ -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
  }
@@ -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({ type: zod_1.z.literal('sandbox'), id: zod_1.z.string().regex(runs_1.SANDBOX_ID_PATTERN, 'expected a sandbox id') })
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
- cacheDefault: options.cache ?? 'checksum',
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
- for (const { name, scope } of (0, run_plan_1.getRunScopes)(input.tasks, options, input.up?.services)) {
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
  });
@@ -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();
@@ -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({}),