hammerkit 1.27.0-next.2 → 1.27.0-next.20

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 (100) hide show
  1. package/build.schema.json +9 -0
  2. package/dist/cache/backends/registry-cache-backend.js +2 -2
  3. package/dist/cache/explain.js +17 -0
  4. package/dist/cli.js +1 -0
  5. package/dist/docker/item-network.js +13 -5
  6. package/dist/executer/deadline.js +17 -3
  7. package/dist/executer/docker-service.js +30 -8
  8. package/dist/executer/event-cache.js +20 -2
  9. package/dist/executer/execute-work-task.js +5 -3
  10. package/dist/executer/execution-steps.js +5 -2
  11. package/dist/executer/scheduler/enqueue-next.js +7 -0
  12. package/dist/executer/scheduler/state-key.js +42 -5
  13. package/dist/harness/connect.js +20 -6
  14. package/dist/harness/mcp-command.js +22 -6
  15. package/dist/harness/mcp.js +63 -114
  16. package/dist/harness/opencode.js +158 -50
  17. package/dist/harness/pi.js +29 -0
  18. package/dist/harness/sandbox-client.js +207 -65
  19. package/dist/harness/service-tools.js +141 -0
  20. package/dist/harness/task-tools.js +221 -0
  21. package/dist/kubernetes/apply.js +27 -16
  22. package/dist/kubernetes/claim-layout.js +71 -0
  23. package/dist/kubernetes/ensure-http-route.js +1 -1
  24. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  25. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  26. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  27. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  28. package/dist/kubernetes/get-service-ip.js +2 -2
  29. package/dist/kubernetes/job-secrets.js +33 -19
  30. package/dist/kubernetes/kept-claims.js +22 -10
  31. package/dist/kubernetes/network-policy.js +85 -27
  32. package/dist/kubernetes/remove-persistent-data.js +3 -1
  33. package/dist/kubernetes/resources.js +25 -3
  34. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  35. package/dist/kubernetes/sandbox-runner.js +626 -0
  36. package/dist/kubernetes/secrets.js +7 -11
  37. package/dist/kubernetes/volumes.js +6 -3
  38. package/dist/network/egress-proxy.js +45 -6
  39. package/dist/optimizer/calculate-checksum.js +3 -2
  40. package/dist/optimizer/check-key.js +94 -0
  41. package/dist/optimizer/check-keys.js +64 -0
  42. package/dist/optimizer/get-work-cache-stats.js +17 -3
  43. package/dist/optimizer/work-task-cache-description.js +51 -1
  44. package/dist/planner/utils/append-work-dependencies.js +10 -5
  45. package/dist/planner/utils/append-work-service.js +1 -0
  46. package/dist/planner/utils/append-work-task.js +15 -2
  47. package/dist/planner/utils/get-work-runtime.js +6 -1
  48. package/dist/planner/utils/normalize-path.js +19 -1
  49. package/dist/planner/utils/parse-work-mount.js +7 -1
  50. package/dist/planner/utils/parse-work-port.js +4 -1
  51. package/dist/planner/utils/task-arguments.js +3 -0
  52. package/dist/planner/validate.js +22 -0
  53. package/dist/planner/work-network.js +3 -1
  54. package/dist/planner/work-runtime-aggregate.js +32 -0
  55. package/dist/planner/work-runtime-docker.js +2 -1
  56. package/dist/planner/work-runtime-kubernetes.js +97 -48
  57. package/dist/planner/work-runtime-local.js +2 -1
  58. package/dist/planner/work-task.js +6 -1
  59. package/dist/program.js +14 -4
  60. package/dist/remote/annotations.js +13 -1
  61. package/dist/remote/device-login.js +7 -2
  62. package/dist/remote/host-config.js +64 -3
  63. package/dist/remote/remote-commands.js +41 -9
  64. package/dist/remote/remote-work-commands.js +246 -1
  65. package/dist/remote/server-client.js +15 -2
  66. package/dist/remote/token-source.js +64 -26
  67. package/dist/run-program.js +9 -0
  68. package/dist/schema/build-file-base-task-schema.js +5 -1
  69. package/dist/schema/build-file-container-service-schema.js +2 -0
  70. package/dist/schema/reference-parser.js +9 -1
  71. package/dist/schema/work-scope-parser.js +6 -0
  72. package/dist/secrets/github-app-token-command.js +3 -1
  73. package/dist/server/accounts-api.js +3 -1
  74. package/dist/server/approvals-api.js +1 -1
  75. package/dist/server/checks-api.js +83 -0
  76. package/dist/server/checks.js +116 -0
  77. package/dist/server/definitions-api.js +4 -2
  78. package/dist/server/endpoints.js +612 -0
  79. package/dist/server/executor.js +56 -5
  80. package/dist/server/kubernetes-backend.js +303 -64
  81. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  82. package/dist/server/oauth.js +12 -1
  83. package/dist/server/plan.js +126 -62
  84. package/dist/server/policy.js +32 -2
  85. package/dist/server/repo-access.js +5 -0
  86. package/dist/server/run-plan.js +50 -19
  87. package/dist/server/run-request.js +64 -3
  88. package/dist/server/run-worker.js +146 -10
  89. package/dist/server/runs.js +347 -27
  90. package/dist/server/sandbox-pod.js +28 -0
  91. package/dist/server/sandbox-storage.js +23 -0
  92. package/dist/server/sandboxes-api.js +10 -4
  93. package/dist/server/sandboxes.js +8 -0
  94. package/dist/server/server-command.js +39 -20
  95. package/dist/server/server-config.js +213 -14
  96. package/dist/server/server.js +268 -15
  97. package/dist/server/service-runs.js +82 -0
  98. package/dist/utils/abort-on-signals.js +46 -6
  99. package/dist/utils/tar.js +36 -0
  100. package/package.json +1 -1
@@ -1,13 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.startServer = exports.getDiscoveryDocument = exports.SERVER_PROTOCOL = exports.DISCOVERY_PATH = void 0;
3
+ exports.startServer = exports.refuseWake = exports.getDiscoveryDocument = exports.SERVER_PROTOCOL = exports.DISCOVERY_PATH = void 0;
4
4
  const http_1 = require("http");
5
5
  const https_1 = require("https");
6
+ const tls_1 = require("tls");
6
7
  const version_1 = require("../version");
7
8
  const server_config_1 = require("./server-config");
8
9
  const discovery_1 = require("./discovery");
9
10
  const oauth_1 = require("./oauth");
10
11
  const policy_1 = require("./policy");
12
+ const repo_1 = require("./repo");
11
13
  const run_store_1 = require("./run-store");
12
14
  const runs_1 = require("./runs");
13
15
  const git_checkout_1 = require("./git-checkout");
@@ -32,10 +34,14 @@ const sandbox_storage_1 = require("./sandbox-storage");
32
34
  const kubernetes_sandbox_storage_1 = require("./kubernetes-sandbox-storage");
33
35
  const sandbox_docker_1 = require("./sandbox-docker");
34
36
  const sandboxes_api_1 = require("./sandboxes-api");
37
+ const endpoints_1 = require("./endpoints");
38
+ const service_runs_1 = require("./service-runs");
35
39
  const units_1 = require("../utils/units");
36
40
  const run_logs_1 = require("./run-logs");
37
41
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
38
42
  const path_1 = require("path");
43
+ const checks_1 = require("./checks");
44
+ const checks_api_1 = require("./checks-api");
39
45
  var discovery_2 = require("./discovery");
40
46
  Object.defineProperty(exports, "DISCOVERY_PATH", { enumerable: true, get: function () { return discovery_2.DISCOVERY_PATH; } });
41
47
  Object.defineProperty(exports, "SERVER_PROTOCOL", { enumerable: true, get: function () { return discovery_2.SERVER_PROTOCOL; } });
@@ -64,6 +70,8 @@ const HEARTBEAT_MS = 15000;
64
70
  // the longest `?wait=` of a run: below the 60 seconds after which a proxy in front of the
65
71
  // server (nginx by default) closes a connection that says nothing
66
72
  const MAX_WAIT_MS = 50000;
73
+ // how often checks older than `runs.checksKeep` are removed
74
+ const CHECK_SWEEP_MS = 6 * 60 * 60 * 1000;
67
75
  // Server-sent events: `id` is the sequence number, so a client that lost the
68
76
  // connection continues with Last-Event-ID (or ?after=) and misses nothing.
69
77
  function streamEvents(registry, config, principal, id, req, res, url) {
@@ -96,8 +104,8 @@ function streamEvents(registry, config, principal, id, req, res, url) {
96
104
  watch.stop();
97
105
  });
98
106
  }
99
- async function handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url) {
100
- const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events|logs))?)?$/.exec(url.pathname);
107
+ async function handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res, url) {
108
+ const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events|logs|extend))?)?$/.exec(url.pathname);
101
109
  if (!match) {
102
110
  return false;
103
111
  }
@@ -115,7 +123,7 @@ async function handleRuns(config, registry, definitions, sources, sandboxes, pla
115
123
  });
116
124
  }
117
125
  else if (method === 'POST') {
118
- await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res);
126
+ await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res);
119
127
  }
120
128
  else {
121
129
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
@@ -134,6 +142,13 @@ async function handleRuns(config, registry, definitions, sources, sandboxes, pla
134
142
  log(`cancel iss=${principal.iss} sub=${principal.sub} run=${id} found=${Boolean(run)}`);
135
143
  run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
136
144
  }
145
+ else if (action === 'extend') {
146
+ if (method !== 'POST') {
147
+ (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
148
+ return true;
149
+ }
150
+ await extendRun(config, registry, log, principal, id, req, res);
151
+ }
137
152
  else if (method !== 'GET') {
138
153
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
139
154
  }
@@ -181,12 +196,75 @@ function accountsOf(config, principal) {
181
196
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
182
197
  return (account) => (0, policy_1.canUse)(grant, account);
183
198
  }
199
+ async function extendRun(config, registry, log, principal, id, req, res) {
200
+ const body = (await (0, http_2.readJson)(req));
201
+ let ttl;
202
+ try {
203
+ if (typeof body !== 'object' || body === null || typeof body.ttl !== 'string') {
204
+ throw new Error();
205
+ }
206
+ ttl = (0, units_1.parseDuration)(body.ttl);
207
+ if (!(ttl > 0)) {
208
+ throw new Error();
209
+ }
210
+ }
211
+ catch {
212
+ (0, http_2.send)(res, 400, { error: 'ttl: expected a duration like 30m, 8h or 1d' });
213
+ return;
214
+ }
215
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
216
+ const result = registry.extend(principal, id, ttl, (run) => (0, service_runs_1.mayOpenEndpoints)(grant, run));
217
+ log(`extend iss=${principal.iss} sub=${principal.sub} run=${id} result=${result.status}`);
218
+ if (result.status === 'not-found') {
219
+ (0, http_2.send)(res, 404, { error: 'not found' });
220
+ }
221
+ else if (result.status === 'not-up') {
222
+ (0, http_2.send)(res, 409, { error: `the run is ${result.state}, only a service run that is up can be extended` });
223
+ }
224
+ else {
225
+ (0, http_2.send)(res, 200, toRunResponse(result.run));
226
+ }
227
+ }
228
+ // Why the owner of a service run that sleeps may no longer run it, with the entitlements of
229
+ // the moment: a wake is started by who opens an endpoint, but runs as the owner, as a new
230
+ // run of theirs would.
231
+ function refuseWake(config, repos, run) {
232
+ if (run.source.type !== 'ref') {
233
+ return 'a run from a sandbox does not wake';
234
+ }
235
+ const grant = (0, policy_1.getGrant)(config.entitlements, {
236
+ iss: run.owner.iss,
237
+ sub: run.owner.sub,
238
+ groups: run.owner.groups ?? [],
239
+ claims: run.owner.claims ?? {},
240
+ });
241
+ const decision = (0, policy_1.decideRun)({
242
+ grant,
243
+ accounts: run.accounts,
244
+ catalog: config.accounts,
245
+ source: { type: 'ref', repo: run.source.repo, ref: run.source.ref },
246
+ // an account it may only request is refused, the wake has nobody to approve it
247
+ approvable: () => true,
248
+ readable: (repo) => repos.readable(repo),
249
+ });
250
+ if (decision.type === 'refuse') {
251
+ return `its owner ${run.owner.sub} may no longer run it: ${decision.error.replace(/^you /, 'they ')}`;
252
+ }
253
+ if (decision.type === 'approval') {
254
+ return `its owner ${run.owner.sub} may only request ${decision.accounts.join(', ')} now, start it again`;
255
+ }
256
+ if (run.up?.endpoints.length && (0, policy_1.endpointLevel)(grant, run.source.repo) !== 'publish') {
257
+ return `its owner ${run.owner.sub} may no longer publish endpoints of ${run.source.repo}`;
258
+ }
259
+ return undefined;
260
+ }
261
+ exports.refuseWake = refuseWake;
184
262
  function toRunResponse(run) {
185
263
  const { owner, ...visible } = run;
186
264
  void owner;
187
265
  return { ...visible, results: run.results ?? {} };
188
266
  }
189
- async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res) {
267
+ async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res) {
190
268
  const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
191
269
  if (!parsed.success) {
192
270
  const issue = parsed.error.issues[0];
@@ -218,6 +296,27 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
218
296
  (0, http_2.send)(res, 400, { error: `${version.hash} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
219
297
  return;
220
298
  }
299
+ const notServices = request.services.filter((service) => !version.services.includes(service));
300
+ if (notServices.length > 0) {
301
+ (0, http_2.send)(res, 400, { error: `${version.hash} has no service ${notServices.map(oauth_1.quote).join(', ')}` });
302
+ return;
303
+ }
304
+ if (request.endpoints.length > 0 && !config.endpoints) {
305
+ (0, http_2.send)(res, 400, { error: 'this server publishes no endpoints (endpoints is not configured)' });
306
+ return;
307
+ }
308
+ const ttl = request.services.length > 0 ? (0, units_1.parseDuration)(request.ttl ?? config.runs.up.ttl) : 0;
309
+ if (ttl > (0, units_1.parseDuration)(config.runs.up.maxTtl)) {
310
+ (0, http_2.send)(res, 400, { error: `ttl: at most ${config.runs.up.maxTtl} on this server (runs.up.maxTtl)` });
311
+ return;
312
+ }
313
+ // the files of a sandbox change under a run that sleeps, it would wake to others
314
+ if (request.sleepTtl !== undefined && request.source.type === 'sandbox') {
315
+ (0, http_2.send)(res, 400, { error: 'sleepTtl: a service run from a sandbox does not sleep, start it from a branch' });
316
+ return;
317
+ }
318
+ const sleepTtlText = request.source.type === 'ref' ? request.sleepTtl ?? config.runs.up.sleepTtl : undefined;
319
+ const sleepTtl = request.services.length > 0 && sleepTtlText ? (0, units_1.parseDuration)(sleepTtlText) : undefined;
221
320
  const argued = (request.options.args ?? []).length > 0 ? request.tasks[0] : undefined;
222
321
  if (argued !== undefined && version.services.includes(argued)) {
223
322
  (0, http_2.send)(res, 400, { error: `${argued} is a service, arguments go to a task` });
@@ -239,6 +338,12 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
239
338
  return;
240
339
  }
241
340
  }
341
+ if (request.services.length > 0 && request.source.type === 'sandbox' && config.backend.type === 'kubernetes') {
342
+ (0, http_2.send)(res, 400, {
343
+ error: 'on a cluster a service run starts from a branch, push the sandbox and start it from there',
344
+ });
345
+ return;
346
+ }
242
347
  const sandbox = request.source.type === 'sandbox' ? sandboxes.registry.get(principal, request.source.id) : undefined;
243
348
  if (request.source.type === 'sandbox') {
244
349
  if (!sandbox) {
@@ -257,6 +362,24 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
257
362
  (0, http_2.send)(res, 413, { error: over });
258
363
  return;
259
364
  }
365
+ // a check counts like a run from a commit of the repository, and leaves what it made in
366
+ // the repository's cache, which runs from main restore from: the caller must be allowed
367
+ // to start runs from it, and to push a branch of it, which with a run from that branch
368
+ // leaves the same
369
+ if (request.source.check && !(0, repo_1.matchesAnyRepo)(grant.repos, sandbox.repo)) {
370
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: check on ${sandbox.repo} without the repository`);
371
+ (0, http_2.send)(res, 403, {
372
+ error: `a check counts like a run from ${sandbox.repo}, which no entitlement of yours lists in repos`,
373
+ });
374
+ return;
375
+ }
376
+ if (request.source.check && (0, policy_1.sandboxPushBranches)(grant, sandbox.repo).length === 0) {
377
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: check on ${sandbox.repo} without a branch to push`);
378
+ (0, http_2.send)(res, 403, {
379
+ error: `a check counts like a run from a branch you pushed, and you may push no branch of ${sandbox.repo}`,
380
+ });
381
+ return;
382
+ }
260
383
  }
261
384
  // the accounts of a run are read from the files it is pinned to, with the engine
262
385
  const bundle = version.plan ? await definitions.files(version) : undefined;
@@ -275,12 +398,20 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
275
398
  (0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
276
399
  return;
277
400
  }
278
- const refused = (0, run_plan_1.refuseArguments)(request, planned.plan);
401
+ const refused = (0, run_plan_1.refuseArguments)(request, planned.plan, sandbox ? sandboxes.policy : planning.policy) ??
402
+ (0, service_runs_1.refuseEndpoints)(request, planned.plan, config.runs.secretNetwork === 'open');
279
403
  if (refused) {
280
404
  log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${refused}`);
281
405
  (0, http_2.send)(res, 400, { error: refused });
282
406
  return;
283
407
  }
408
+ const runRepo = request.source.type === 'ref' ? request.source.repo : sandbox?.repo;
409
+ if (request.endpoints.length > 0 && (!runRepo || (0, policy_1.endpointLevel)(grant, runRepo) !== 'publish')) {
410
+ const error = `you may not publish endpoints of ${runRepo ?? 'this source'}, an entitlement needs endpoints with level publish for it`;
411
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${error}`);
412
+ (0, http_2.send)(res, 403, { error });
413
+ return;
414
+ }
284
415
  const decision = (0, policy_1.decideRun)({
285
416
  grant,
286
417
  accounts: planned.accounts,
@@ -304,6 +435,12 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
304
435
  if (request.source.type === 'ref') {
305
436
  try {
306
437
  const commit = await repos.resolve(request.source.repo, request.source.ref);
438
+ if (request.source.commit && commit !== request.source.commit) {
439
+ const error = `${request.source.ref} is at ${commit} now, not at ${request.source.commit}`;
440
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${error}`);
441
+ (0, http_2.send)(res, 409, { error });
442
+ return;
443
+ }
307
444
  source = { type: 'ref', repo: request.source.repo, ref: request.source.ref, commit };
308
445
  }
309
446
  catch (e) {
@@ -318,13 +455,34 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
318
455
  else if (request.source.type === 'upload') {
319
456
  source = request.source;
320
457
  }
321
- const submit = (runSource) => registry.submit(principal, {
458
+ // a service run is named after its branch unless it says, and its endpoints after that
459
+ const serviceName = request.services.length > 0
460
+ ? request.name ??
461
+ (0, service_runs_1.getServiceRunName)(request.source.type === 'ref' ? request.source.ref : sandbox?.ref)
462
+ : '';
463
+ const up = request.services.length > 0
464
+ ? {
465
+ services: request.services,
466
+ ttl,
467
+ ...(sleepTtl ? { sleepTtl } : {}),
468
+ name: serviceName,
469
+ endpoints: (0, service_runs_1.toRunEndpoints)(request.endpoints, (0, service_runs_1.getEndpointHosts)(serviceName, principal, request.endpoints), endpointUrl),
470
+ }
471
+ : undefined;
472
+ // a run that may sleep keeps what its owner is entitled by, for the check of a wake
473
+ const claimNames = new Set(config.entitlements.flatMap((entitlement) => Object.keys(entitlement.claims?.match ?? {})));
474
+ const owner = {
475
+ ...principal,
476
+ claims: Object.fromEntries(Object.entries(principal.claims).filter(([name]) => claimNames.has(name))),
477
+ };
478
+ const submit = (runSource) => registry.submit(owner, {
322
479
  definition: version.hash,
323
480
  tasks: request.tasks,
324
481
  accounts: planned.accounts,
325
482
  plan: planned.plan,
326
483
  source: runSource,
327
484
  options: request.options,
485
+ ...(up ? { up } : {}),
328
486
  }, approvals);
329
487
  try {
330
488
  // a sandbox is read and the run submitted while nothing else changes it: the run
@@ -340,9 +498,12 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
340
498
  commit: state.commit,
341
499
  tree: state.tree,
342
500
  clean: state.clean,
501
+ ...(request.source.type === 'sandbox' && request.source.check ? { check: true } : {}),
343
502
  });
344
503
  });
345
- log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type}${run.source.type === 'sandbox' ? ` sandbox=${run.source.id}` : ''} tasks=${run.tasks.length}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
504
+ log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type}${run.source.type === 'sandbox' ? ` sandbox=${run.source.id}${run.source.check ? ' check' : ''}` : ''} tasks=${run.tasks.length}${run.up
505
+ ? ` services=[${run.up.services.join(',')}] name=${run.up.name} endpoints=${run.up.endpoints.length}`
506
+ : ''}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
346
507
  (0, http_2.send)(res, 202, toRunResponse(run));
347
508
  }
348
509
  catch (e) {
@@ -379,7 +540,13 @@ async function authenticate(verifier, log, req, res) {
379
540
  return null;
380
541
  }
381
542
  }
382
- async function handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res) {
543
+ async function handle(config, registry, gateway, definitions, proposals, sources, sandboxes, planning, repos, checks, verifier, log, req, res) {
544
+ // the hosts of endpoints are the apps of service runs, not the API
545
+ const label = gateway?.hostOf(req);
546
+ if (gateway && label) {
547
+ await gateway.handle(label, req, res);
548
+ return;
549
+ }
383
550
  const url = new URL(req.url ?? '/', 'http://localhost');
384
551
  const path = url.pathname;
385
552
  if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
@@ -406,7 +573,7 @@ async function handle(config, registry, definitions, proposals, sources, sandbox
406
573
  if (await (0, sandboxes_api_1.handleSandboxes)(config, sandboxes.registry, repos, log, principal, req, res, url)) {
407
574
  return;
408
575
  }
409
- if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url)) {
576
+ if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, (host) => gateway?.url(host) ?? host, log, principal, req, res, url)) {
410
577
  return;
411
578
  }
412
579
  if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
@@ -415,6 +582,9 @@ async function handle(config, registry, definitions, proposals, sources, sandbox
415
582
  if (await (0, proposals_api_1.handleProposals)(config, definitions, proposals, log, principal, req, res, url)) {
416
583
  return;
417
584
  }
585
+ if (await (0, checks_api_1.handleChecks)(config, checks, definitions, log, principal, req, res, url)) {
586
+ return;
587
+ }
418
588
  (0, http_2.send)(res, 404, { error: 'not found' });
419
589
  }
420
590
  async function startServer(config, environment, options = {}) {
@@ -436,14 +606,22 @@ async function startServer(config, environment, options = {}) {
436
606
  environment: {
437
607
  ...environment,
438
608
  processEnvs: (0, run_environment_1.runEnvironment)(config, environment.processEnvs),
439
- hosted: { catalog: (0, server_config_1.getSecretCatalog)(config) },
609
+ // on a cluster a local task runs in a pod of a pool's tool image
610
+ hosted: { catalog: (0, server_config_1.getSecretCatalog)(config), localTasksInContainers: config.backend.type === 'kubernetes' },
440
611
  },
441
612
  policy: {
442
613
  envs: config.runs.env.allow,
443
- reserved: (0, server_config_1.getSourceAccounts)(config),
614
+ reserved: (0, server_config_1.getServerAccounts)(config),
444
615
  runtimes: config.runtimes,
445
616
  backend: config.backend.type,
446
617
  network: (0, work_network_1.parseWorkNetwork)(config.runs.network),
618
+ secretNetwork: (0, work_network_1.parseWorkNetwork)(config.runs.secretNetwork),
619
+ args: config.runs.args,
620
+ proxiedProviders: config.backend.type === 'kubernetes'
621
+ ? Object.entries(config.backend.providers)
622
+ .filter(([, provider]) => provider.allow)
623
+ .map(([name]) => name)
624
+ : [],
447
625
  },
448
626
  };
449
627
  const storage = config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server');
@@ -506,6 +684,11 @@ async function startServer(config, environment, options = {}) {
506
684
  ? new kubernetes_backend_1.KubernetesExecutor({ ...config, backend }, environment.processEnvs, executorOptions)
507
685
  : new executor_1.HostExecutor(executorOptions));
508
686
  const sources = new sources_1.SourceStore((0, path_1.join)(storage, 'sources'));
687
+ const checks = new checks_1.CheckStore((0, path_1.join)(storage, 'checks'), { keep: (0, units_1.parseDuration)(config.runs.checksKeep) });
688
+ const sweepChecks = () => checks.sweep().catch((e) => log(`the old checks could not be removed: ${e instanceof Error ? e.message : e}`));
689
+ void sweepChecks();
690
+ const sweeping = setInterval(sweepChecks, CHECK_SWEEP_MS);
691
+ sweeping.unref();
509
692
  const registry = new runs_1.RunRegistry({
510
693
  executor,
511
694
  maxConcurrent: config.runs.maxConcurrent,
@@ -515,10 +698,44 @@ async function startServer(config, environment, options = {}) {
515
698
  limits: config.runs.limits,
516
699
  maxDuration: (0, units_1.parseDuration)(config.runs.maxDuration),
517
700
  maxLogBytes: config.runs.maxLogBytes,
701
+ up: {
702
+ maxTtl: (0, units_1.parseDuration)(config.runs.up.maxTtl),
703
+ perCaller: config.runs.up.limits.up,
704
+ total: config.runs.up.total,
705
+ },
518
706
  store: new run_store_1.FileRunStore((0, path_1.join)(storage, 'records')),
707
+ // what a check run passed, for `remote verify` to find
708
+ onFinished: (run, keys) => {
709
+ const passed = (0, checks_1.getCheckRecords)(run, keys);
710
+ if (passed.length > 0) {
711
+ checks
712
+ .save(passed)
713
+ .then(() => log(`checks run=${run.id} passed=[${passed.map((record) => record.task).join(',')}]`))
714
+ .catch((e) => log(`the checks of run ${run.id} were not kept: ${e instanceof Error ? e.message : e}`));
715
+ }
716
+ },
519
717
  log,
520
718
  });
521
719
  await registry.restore();
720
+ if (executor instanceof kubernetes_backend_1.KubernetesExecutor) {
721
+ // the services of the runs of a server that stopped are gone with it
722
+ executor.sweepServiceRuns();
723
+ }
724
+ const login = config.endpoints?.login;
725
+ const gateway = config.endpoints
726
+ ? new endpoints_1.EndpointGateway(config, registry, {
727
+ keyFile: (0, path_1.join)(storage, 'endpoints', 'session.key'),
728
+ verifier,
729
+ ...(login?.clientSecret
730
+ ? { clientSecret: () => repos.serverSecret(login.clientSecret) }
731
+ : {}),
732
+ log,
733
+ ...(options.fetch ? { fetch: options.fetch } : {}),
734
+ ...(options.now ? { now: options.now } : {}),
735
+ mayWake: (run) => refuseWake(config, repos, run),
736
+ })
737
+ : undefined;
738
+ await gateway?.load();
522
739
  sandboxes.registry.useRuns({
523
740
  holder: (id) => registry.holder(id),
524
741
  stop: (id) => registry.stopSandbox(id, 'the sandbox was deleted'),
@@ -529,7 +746,7 @@ async function startServer(config, environment, options = {}) {
529
746
  cooldown: (0, units_1.parseDuration)(config.approvals.cooldown),
530
747
  });
531
748
  const listener = (req, res) => {
532
- handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res).catch((e) => {
749
+ handle(config, registry, gateway, definitions, proposals, sources, sandboxes, planning, repos, checks, verifier, log, req, res).catch((e) => {
533
750
  if (e instanceof http_2.HttpError) {
534
751
  (0, http_2.send)(res, e.status, { error: e.message });
535
752
  return;
@@ -549,11 +766,44 @@ async function startServer(config, environment, options = {}) {
549
766
  environment.file.read(config.tls.cert),
550
767
  environment.file.read(config.tls.key),
551
768
  ]);
552
- server = (0, https_1.createServer)({ cert, key }, listener);
769
+ // the hosts of endpoints have a wildcard certificate of their own
770
+ const endpointsTls = config.endpoints?.tls;
771
+ const endpointContext = endpointsTls
772
+ ? (0, tls_1.createSecureContext)({
773
+ cert: await environment.file.read(endpointsTls.cert),
774
+ key: await environment.file.read(endpointsTls.key),
775
+ })
776
+ : undefined;
777
+ const domain = config.endpoints?.domain;
778
+ server = (0, https_1.createServer)({
779
+ cert,
780
+ key,
781
+ ...(endpointContext && domain
782
+ ? {
783
+ SNICallback: (servername, done) => done(null, servername.toLowerCase().endsWith(`.${domain}`) ? endpointContext : undefined),
784
+ }
785
+ : {}),
786
+ }, listener);
553
787
  }
554
788
  else {
555
789
  server = (0, http_1.createServer)(listener);
556
790
  }
791
+ // WebSockets of the apps behind endpoints; the API has none. An upgraded connection is
792
+ // no longer the HTTP server's, it goes when the server closes.
793
+ const upgraded = new Set();
794
+ server.on('upgrade', (req, socket, head) => {
795
+ upgraded.add(socket);
796
+ socket.once('close', () => upgraded.delete(socket));
797
+ const label = gateway?.hostOf(req);
798
+ if (!gateway || !label) {
799
+ socket.end('HTTP/1.1 404 Not Found\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
800
+ return;
801
+ }
802
+ gateway.upgrade(label, req, socket, head).catch((e) => {
803
+ log(`error upgrade ${label}: ${e instanceof Error ? e.message : String(e)}`);
804
+ socket.destroy();
805
+ });
806
+ });
557
807
  await new Promise((resolve, reject) => {
558
808
  server.once('error', reject);
559
809
  server.listen(config.listen.port, config.listen.host, () => {
@@ -562,11 +812,12 @@ async function startServer(config, environment, options = {}) {
562
812
  });
563
813
  });
564
814
  const address = server.address();
815
+ gateway?.listening(address.port, Boolean(config.tls));
565
816
  const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
566
817
  return {
567
818
  url: `${config.tls ? 'https' : 'http'}://${host}:${address.port}`,
568
819
  reload: (next) => {
569
- const readers = (0, server_config_1.getSourceAccounts)(config);
820
+ const readers = (0, server_config_1.getServerAccounts)(config);
570
821
  next.entitlements.forEach((entitlement, index) => {
571
822
  const iss = (entitlement.subject ?? entitlement.group ?? entitlement.claims)?.iss;
572
823
  if (iss && !config.issuers.some((issuer) => issuer.issuer === iss)) {
@@ -586,11 +837,13 @@ async function startServer(config, environment, options = {}) {
586
837
  return { restart };
587
838
  },
588
839
  close: async () => {
840
+ clearInterval(sweeping);
589
841
  proposals.close();
590
842
  await registry.close();
591
843
  await new Promise((resolve, reject) => {
592
844
  server.close((err) => (err ? reject(err) : resolve()));
593
845
  server.closeAllConnections();
846
+ upgraded.forEach((socket) => socket.destroy());
594
847
  });
595
848
  },
596
849
  };
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mayOpenEndpoints = exports.getRunRepo = exports.toRunEndpoints = exports.refuseEndpoints = exports.getEndpointHosts = exports.getServiceRunName = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const plan_1 = require("./plan");
6
+ const policy_1 = require("./policy");
7
+ const run_request_1 = require("./run-request");
8
+ // The name of a service run when the request names none: the branch it runs from, as a DNS
9
+ // label, so the runs of several branches stand side by side and each keeps its hosts.
10
+ function getServiceRunName(branch) {
11
+ const name = (branch ?? '')
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9-]+/g, '-')
14
+ .replace(/-+/g, '-')
15
+ .slice(0, 40)
16
+ .replace(/^-+|-+$/g, '');
17
+ return run_request_1.SERVICE_RUN_NAME_PATTERN.test(name) ? name : 'run';
18
+ }
19
+ exports.getServiceRunName = getServiceRunName;
20
+ /**
21
+ * The host labels of a run's endpoints: its name, the port and twelve hex digits of its
22
+ * owner (at most 40 + 5 + 2 + 12 and three dashes: a DNS label), so two callers with a branch of the same name get hosts of their own, and a run
23
+ * that replaces another of the same name takes over its hosts. Two endpoints on the same
24
+ * port (of two services) are told apart by their place.
25
+ */
26
+ function getEndpointHosts(name, owner, endpoints) {
27
+ const of = (0, crypto_1.createHash)('sha256').update(`${owner.iss}\0${owner.sub}`).digest('hex').slice(0, 12);
28
+ return endpoints.map((endpoint, index) => endpoints.findIndex((other) => other.port === endpoint.port) === index
29
+ ? `${name}-${endpoint.port}-${of}`
30
+ : `${name}-${endpoint.port}-${index}-${of}`);
31
+ }
32
+ exports.getEndpointHosts = getEndpointHosts;
33
+ // The container port of each `host:container` port of a planned service.
34
+ function containerPorts(plan) {
35
+ return plan.ports.map((port) => Number(port.split(':').pop()));
36
+ }
37
+ /**
38
+ * Why the endpoints of a service run can't be published, or null. Each is a port its
39
+ * service declares, of a service the run starts. Under a `runs.secretNetwork` below `open`
40
+ * a service behind an endpoint may not read a secret or a credential of the server: a page
41
+ * is a way out that no mask of the log covers.
42
+ */
43
+ function refuseEndpoints(request, plan, secretNetworkOpen) {
44
+ for (const endpoint of request.endpoints) {
45
+ const item = plan[endpoint.service];
46
+ if (!item || item.kind !== 'service') {
47
+ return `the endpoint ${endpoint.service}:${endpoint.port} names no service the run starts`;
48
+ }
49
+ if (!containerPorts(item).includes(endpoint.port)) {
50
+ const ports = containerPorts(item);
51
+ return `${endpoint.service} has no port ${endpoint.port}${ports.length > 0 ? `, its ports are ${ports.join(', ')}` : ', it declares none'}`;
52
+ }
53
+ const reads = (0, plan_1.getCredentialReads)(item);
54
+ if (!secretNetworkOpen && reads.length > 0) {
55
+ return `${endpoint.service} reads ${reads.join(', ')}, it can not be behind an endpoint on this server (runs.secretNetwork): a page could show it`;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+ exports.refuseEndpoints = refuseEndpoints;
61
+ function toRunEndpoints(endpoints, hosts, url) {
62
+ return endpoints.map((endpoint, index) => ({
63
+ service: endpoint.service,
64
+ port: endpoint.port,
65
+ host: hosts[index],
66
+ url: url(hosts[index]),
67
+ }));
68
+ }
69
+ exports.toRunEndpoints = toRunEndpoints;
70
+ // The repository of a run's source, which the endpoints grants are written for.
71
+ function getRunRepo(run) {
72
+ return run.source.type === 'upload' ? null : run.source.repo;
73
+ }
74
+ exports.getRunRepo = getRunRepo;
75
+ // Who may open the endpoints of a service run, and extend it, besides its owner: those
76
+ // with an `open` or `publish` level for its repository.
77
+ function mayOpenEndpoints(grant, run) {
78
+ const repo = getRunRepo(run);
79
+ return repo !== null && (0, policy_1.endpointLevel)(grant, repo) !== null;
80
+ }
81
+ exports.mayOpenEndpoints = mayOpenEndpoints;
82
+ //# sourceMappingURL=service-runs.js.map
@@ -1,17 +1,57 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
3
+ exports.takeOverSignal = exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
4
4
  // Ctrl-C (SIGINT), a CI job being cancelled or a supervisor stopping hammerkit
5
5
  // (SIGTERM) and a closed terminal (SIGHUP) all stop the run the same way: abort,
6
6
  // so services are removed and local tasks' process groups are killed. Without a
7
7
  // handler Node exits on SIGTERM/SIGHUP at once and leaves both behind.
8
8
  exports.ABORT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
9
- function abortOnSignals(source, abortCtrl) {
10
- for (const signal of exports.ABORT_SIGNALS) {
11
- source.on(signal, () => {
12
- abortCtrl.abort();
13
- });
9
+ // signals a running command gives a meaning of its own (the server reloads its
10
+ // entitlements on SIGHUP), per source and counted by how many hold them: they don't
11
+ // abort the run
12
+ const takenOver = new WeakMap();
13
+ function heldOn(source) {
14
+ let held = takenOver.get(source);
15
+ if (!held) {
16
+ held = new Map();
17
+ takenOver.set(source, held);
14
18
  }
19
+ return held;
20
+ }
21
+ /**
22
+ * Stops the run on the abort signals, all but those a command took over with
23
+ * `takeOverSignal`. Returns what removes the listeners again.
24
+ */
25
+ function abortOnSignals(source, abortCtrl) {
26
+ const listeners = exports.ABORT_SIGNALS.map((signal) => {
27
+ const listener = () => {
28
+ if (!takenOver.get(source)?.get(signal)) {
29
+ abortCtrl.abort();
30
+ }
31
+ };
32
+ source.on(signal, listener);
33
+ return { signal, listener };
34
+ });
35
+ return () => listeners.forEach(({ signal, listener }) => source.off(signal, listener));
15
36
  }
16
37
  exports.abortOnSignals = abortOnSignals;
38
+ /**
39
+ * Calls `handler` on `signal` instead of stopping the run, until the returned function
40
+ * gives the signal back.
41
+ */
42
+ function takeOverSignal(source, signal, handler) {
43
+ const held = heldOn(source);
44
+ held.set(signal, (held.get(signal) ?? 0) + 1);
45
+ source.on(signal, handler);
46
+ let released = false;
47
+ return () => {
48
+ if (released) {
49
+ return;
50
+ }
51
+ released = true;
52
+ source.off(signal, handler);
53
+ held.set(signal, (held.get(signal) ?? 1) - 1);
54
+ };
55
+ }
56
+ exports.takeOverSignal = takeOverSignal;
17
57
  //# sourceMappingURL=abort-on-signals.js.map