hammerkit 1.27.0-next.9 → 2.0.0-next.1

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 (122) hide show
  1. package/build.schema.json +176 -6
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/cache-sync.js +1 -1
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +6 -3
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +49 -1
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +133 -9
  36. package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
  37. package/dist/kubernetes/ensure-persistent-data.js +211 -6
  38. package/dist/kubernetes/follow-pod-logs.js +182 -0
  39. package/dist/kubernetes/job-placement.js +36 -12
  40. package/dist/kubernetes/kubernetes-instance.js +1 -0
  41. package/dist/kubernetes/network-policy.js +2 -1
  42. package/dist/kubernetes/remove-stale-items.js +46 -0
  43. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  44. package/dist/kubernetes/sandbox-runner.js +628 -0
  45. package/dist/kubernetes/service-rollouts.js +74 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  47. package/dist/kubernetes/volumes.js +94 -9
  48. package/dist/main.js +2 -0
  49. package/dist/optimizer/calculate-checksum.js +3 -2
  50. package/dist/optimizer/check-key.js +94 -0
  51. package/dist/optimizer/check-keys.js +64 -0
  52. package/dist/optimizer/get-work-cache-stats.js +17 -3
  53. package/dist/optimizer/work-task-cache-description.js +49 -1
  54. package/dist/planner/service-definition.js +9 -4
  55. package/dist/planner/utils/append-work-dependencies.js +5 -3
  56. package/dist/planner/utils/append-work-service.js +18 -1
  57. package/dist/planner/utils/append-work-task.js +13 -3
  58. package/dist/planner/utils/get-work-runtime.js +6 -1
  59. package/dist/planner/utils/normalize-path.js +19 -1
  60. package/dist/planner/utils/parse-work-mount.js +7 -1
  61. package/dist/planner/utils/parse-work-port.js +4 -1
  62. package/dist/planner/validate.js +22 -0
  63. package/dist/planner/work-network.js +3 -1
  64. package/dist/planner/work-runtime-aggregate.js +32 -0
  65. package/dist/planner/work-runtime-kubernetes.js +136 -13
  66. package/dist/planner/work-task.js +6 -1
  67. package/dist/program.js +27 -9
  68. package/dist/remote/device-login.js +7 -2
  69. package/dist/remote/host-config.js +69 -3
  70. package/dist/remote/remote-commands.js +33 -7
  71. package/dist/remote/remote-status.js +112 -0
  72. package/dist/remote/remote-work-commands.js +562 -45
  73. package/dist/remote/server-client.js +15 -2
  74. package/dist/remote/token-source.js +64 -26
  75. package/dist/run-program.js +9 -0
  76. package/dist/schema/build-file-base-task-schema.js +3 -3
  77. package/dist/schema/build-file-container-service-schema.js +10 -0
  78. package/dist/schema/build-file-container-task-schema.js +16 -1
  79. package/dist/schema/build-file-include-schema.js +18 -4
  80. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  81. package/dist/schema/build-file-task-schema.js +5 -1
  82. package/dist/schema/capabilities-schema.js +56 -0
  83. package/dist/schema/reference-parser.js +119 -11
  84. package/dist/schema/removed-properties.js +20 -0
  85. package/dist/schema/schema-parser.js +94 -41
  86. package/dist/schema/work-scope-parser.js +33 -10
  87. package/dist/secrets/command-secret-provider.js +3 -1
  88. package/dist/secrets/github-app-token-command.js +3 -1
  89. package/dist/secrets/secret-catalog.js +43 -19
  90. package/dist/server/accounts-api.js +4 -1
  91. package/dist/server/approvals-api.js +1 -1
  92. package/dist/server/capacity-format.js +36 -0
  93. package/dist/server/capacity.js +300 -0
  94. package/dist/server/checks-api.js +83 -0
  95. package/dist/server/checks.js +116 -0
  96. package/dist/server/dashboard.js +196 -0
  97. package/dist/server/definitions-api.js +66 -3
  98. package/dist/server/endpoints.js +794 -0
  99. package/dist/server/executor.js +164 -9
  100. package/dist/server/kubernetes-backend.js +415 -48
  101. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  102. package/dist/server/overview.js +271 -0
  103. package/dist/server/plan.js +170 -121
  104. package/dist/server/policy.js +34 -2
  105. package/dist/server/proposals-api.js +17 -8
  106. package/dist/server/repo-access.js +5 -0
  107. package/dist/server/run-logs.js +4 -0
  108. package/dist/server/run-plan.js +38 -4
  109. package/dist/server/run-request.js +58 -3
  110. package/dist/server/run-worker.js +262 -13
  111. package/dist/server/runs.js +962 -36
  112. package/dist/server/sandbox-pod.js +28 -0
  113. package/dist/server/sandbox-storage.js +23 -0
  114. package/dist/server/sandboxes.js +8 -0
  115. package/dist/server/server-config.js +246 -10
  116. package/dist/server/server.js +429 -32
  117. package/dist/server/service-runs.js +82 -0
  118. package/dist/server/timings.js +132 -0
  119. package/dist/server/wait-reason.js +21 -0
  120. package/dist/utils/tar.js +28 -1
  121. package/dist/utils/terminal-prompt.js +53 -0
  122. package/package.json +8 -2
@@ -1,18 +1,23 @@
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.runsTheSame = 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");
14
16
  const repo_access_1 = require("./repo-access");
15
17
  const executor_1 = require("./executor");
18
+ const capacity_1 = require("./capacity");
19
+ const execute_docker_1 = require("../executer/execute-docker");
20
+ const timings_1 = require("./timings");
16
21
  const kubernetes_backend_1 = require("./kubernetes-backend");
17
22
  const run_plan_1 = require("./run-plan");
18
23
  const work_network_1 = require("../planner/work-network");
@@ -32,10 +37,15 @@ const sandbox_storage_1 = require("./sandbox-storage");
32
37
  const kubernetes_sandbox_storage_1 = require("./kubernetes-sandbox-storage");
33
38
  const sandbox_docker_1 = require("./sandbox-docker");
34
39
  const sandboxes_api_1 = require("./sandboxes-api");
40
+ const endpoints_1 = require("./endpoints");
41
+ const service_runs_1 = require("./service-runs");
35
42
  const units_1 = require("../utils/units");
36
43
  const run_logs_1 = require("./run-logs");
37
44
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
38
45
  const path_1 = require("path");
46
+ const checks_1 = require("./checks");
47
+ const checks_api_1 = require("./checks-api");
48
+ const overview_1 = require("./overview");
39
49
  var discovery_2 = require("./discovery");
40
50
  Object.defineProperty(exports, "DISCOVERY_PATH", { enumerable: true, get: function () { return discovery_2.DISCOVERY_PATH; } });
41
51
  Object.defineProperty(exports, "SERVER_PROTOCOL", { enumerable: true, get: function () { return discovery_2.SERVER_PROTOCOL; } });
@@ -64,6 +74,8 @@ const HEARTBEAT_MS = 15000;
64
74
  // the longest `?wait=` of a run: below the 60 seconds after which a proxy in front of the
65
75
  // server (nginx by default) closes a connection that says nothing
66
76
  const MAX_WAIT_MS = 50000;
77
+ // how often checks older than `runs.checksKeep` are removed
78
+ const CHECK_SWEEP_MS = 6 * 60 * 60 * 1000;
67
79
  // Server-sent events: `id` is the sequence number, so a client that lost the
68
80
  // connection continues with Last-Event-ID (or ?after=) and misses nothing.
69
81
  function streamEvents(registry, config, principal, id, req, res, url) {
@@ -96,8 +108,8 @@ function streamEvents(registry, config, principal, id, req, res, url) {
96
108
  watch.stop();
97
109
  });
98
110
  }
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);
111
+ async function handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res, url) {
112
+ const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events|logs|extend))?)?$/.exec(url.pathname);
101
113
  if (!match) {
102
114
  return false;
103
115
  }
@@ -106,16 +118,22 @@ async function handleRuns(config, registry, definitions, sources, sandboxes, pla
106
118
  if (!id) {
107
119
  if (method === 'GET') {
108
120
  // the results of each task are read one run at a time
121
+ const viewer = (0, overview_1.getViewer)(config, principal);
122
+ let filter;
123
+ try {
124
+ filter = (0, overview_1.parseRunFilter)(url.searchParams, viewer.grant);
125
+ }
126
+ catch (e) {
127
+ (0, http_2.send)(res, e instanceof overview_1.FilterRefused ? 403 : 400, { error: e instanceof Error ? e.message : String(e) });
128
+ return true;
129
+ }
130
+ const runs = filter.scope === 'mine' ? registry.list(principal) : registry.all();
109
131
  (0, http_2.send)(res, 200, {
110
- runs: registry.list(principal).map((run) => {
111
- const { results, ...listed } = toRunResponse(run);
112
- void results;
113
- return listed;
114
- }),
132
+ runs: (0, overview_1.filterRuns)(runs, viewer, filter).map((run) => listRun(registry, viewer, run)),
115
133
  });
116
134
  }
117
135
  else if (method === 'POST') {
118
- await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res);
136
+ await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res);
119
137
  }
120
138
  else {
121
139
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
@@ -134,6 +152,13 @@ async function handleRuns(config, registry, definitions, sources, sandboxes, pla
134
152
  log(`cancel iss=${principal.iss} sub=${principal.sub} run=${id} found=${Boolean(run)}`);
135
153
  run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
136
154
  }
155
+ else if (action === 'extend') {
156
+ if (method !== 'POST') {
157
+ (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
158
+ return true;
159
+ }
160
+ await extendRun(config, registry, log, principal, id, req, res);
161
+ }
137
162
  else if (method !== 'GET') {
138
163
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
139
164
  }
@@ -149,9 +174,12 @@ async function handleRuns(config, registry, definitions, sources, sandboxes, pla
149
174
  (0, http_2.send)(res, 400, { error: e instanceof Error ? e.message : String(e) });
150
175
  return true;
151
176
  }
152
- const logs = registry.logs(principal, id, accountsOf(config, principal));
177
+ // an entitlement with `observe` reads the log of every run
178
+ const logs = registry.logs(principal, id, accountsOf(config, principal)) ??
179
+ ((0, policy_1.getGrant)(config.entitlements, principal).observe ? registry.logsOf(id) : undefined);
180
+ // with the tasks, services and the server's own lines (`hammerkit`) the log has
153
181
  logs
154
- ? (0, http_2.send)(res, 200, (0, run_logs_1.readLogs)(logs.events, logs.index, query, logs.truncated))
182
+ ? (0, http_2.send)(res, 200, { ...(0, run_logs_1.readLogs)(logs.events, logs.index, query, logs.truncated), names: logs.index.names() })
155
183
  : (0, http_2.send)(res, 404, { error: 'not found' });
156
184
  }
157
185
  else {
@@ -169,9 +197,18 @@ async function handleRuns(config, registry, definitions, sources, sandboxes, pla
169
197
  // a caller that hangs up stops waiting
170
198
  const gone = new AbortController();
171
199
  res.on('close', () => gone.abort());
172
- const run = await registry.wait(principal, id, wait, accountsOf(config, principal), gone.signal);
200
+ const viewer = (0, overview_1.getViewer)(config, principal);
201
+ // an entitlement with `observe` reads every run as it is, without waiting
202
+ const run = (await registry.wait(principal, id, wait, accountsOf(config, principal), gone.signal)) ??
203
+ (viewer.grant.observe ? registry.peek(id) : undefined);
173
204
  if (!gone.signal.aborted) {
174
- run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
205
+ const may = run && (0, overview_1.runActions)(viewer, run);
206
+ run
207
+ ? (0, http_2.send)(res, 200, {
208
+ ...toRunResponse(run, (0, overview_1.queuePlace)(registry, viewer, run), !(0, overview_1.owns)(viewer, run)),
209
+ ...(may ? { may } : {}),
210
+ })
211
+ : (0, http_2.send)(res, 404, { error: 'not found' });
175
212
  }
176
213
  }
177
214
  return true;
@@ -181,12 +218,100 @@ function accountsOf(config, principal) {
181
218
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
182
219
  return (account) => (0, policy_1.canUse)(grant, account);
183
220
  }
184
- function toRunResponse(run) {
221
+ async function extendRun(config, registry, log, principal, id, req, res) {
222
+ const body = (await (0, http_2.readJson)(req));
223
+ let ttl;
224
+ try {
225
+ if (typeof body !== 'object' || body === null || typeof body.ttl !== 'string') {
226
+ throw new Error();
227
+ }
228
+ ttl = (0, units_1.parseDuration)(body.ttl);
229
+ if (!(ttl > 0)) {
230
+ throw new Error();
231
+ }
232
+ }
233
+ catch {
234
+ (0, http_2.send)(res, 400, { error: 'ttl: expected a duration like 30m, 8h or 1d' });
235
+ return;
236
+ }
237
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
238
+ const result = registry.extend(principal, id, ttl, (run) => (0, service_runs_1.mayOpenEndpoints)(grant, run));
239
+ log(`extend iss=${principal.iss} sub=${principal.sub} run=${id} result=${result.status}`);
240
+ if (result.status === 'not-found') {
241
+ (0, http_2.send)(res, 404, { error: 'not found' });
242
+ }
243
+ else if (result.status === 'not-up') {
244
+ (0, http_2.send)(res, 409, { error: `the run is ${result.state}, only a service run that is up can be extended` });
245
+ }
246
+ else {
247
+ (0, http_2.send)(res, 200, toRunResponse(result.run));
248
+ }
249
+ }
250
+ // Why the owner of a service run that sleeps may no longer run it, with the entitlements of
251
+ // the moment: a wake is started by who opens an endpoint, but runs as the owner, as a new
252
+ // run of theirs would.
253
+ function refuseWake(config, repos, run) {
254
+ if (run.source.type !== 'ref') {
255
+ return 'a run from a sandbox does not wake';
256
+ }
257
+ const grant = (0, policy_1.getGrant)(config.entitlements, {
258
+ iss: run.owner.iss,
259
+ sub: run.owner.sub,
260
+ groups: run.owner.groups ?? [],
261
+ claims: run.owner.claims ?? {},
262
+ });
263
+ const decision = (0, policy_1.decideRun)({
264
+ grant,
265
+ accounts: run.accounts,
266
+ catalog: config.accounts,
267
+ source: { type: 'ref', repo: run.source.repo, ref: run.source.ref },
268
+ // an account it may only request is refused, the wake has nobody to approve it
269
+ approvable: () => true,
270
+ readable: (repo) => repos.readable(repo),
271
+ });
272
+ if (decision.type === 'refuse') {
273
+ return `its owner ${run.owner.sub} may no longer run it: ${decision.error.replace(/^you /, 'they ')}`;
274
+ }
275
+ if (decision.type === 'approval') {
276
+ return `its owner ${run.owner.sub} may only request ${decision.accounts.join(', ')} now, start it again`;
277
+ }
278
+ if (run.up?.endpoints.length && (0, policy_1.endpointLevel)(grant, run.source.repo) !== 'publish') {
279
+ return `its owner ${run.owner.sub} may no longer publish endpoints of ${run.source.repo}`;
280
+ }
281
+ return undefined;
282
+ }
283
+ exports.refuseWake = refuseWake;
284
+ // Whether a service run that is not over runs what a new one would, but for its commit: then
285
+ // the new one moves it there (RunRegistry.move)
286
+ function runsTheSame(run, next) {
287
+ const sorted = (values) => JSON.stringify([...values].sort());
288
+ const options = (value) => JSON.stringify(value ?? {}, Object.keys(value ?? {}).sort());
289
+ const endpoints = (spec) => JSON.stringify(spec.endpoints.map((endpoint) => [endpoint.service, endpoint.port, endpoint.host]));
290
+ return (!!run.up &&
291
+ run.source.type === 'ref' &&
292
+ run.source.repo === next.source.repo &&
293
+ run.definition === next.definition &&
294
+ sorted(run.accounts) === sorted(next.accounts) &&
295
+ sorted(run.up.services) === sorted(next.up.services) &&
296
+ endpoints(run.up) === endpoints(next.up) &&
297
+ (run.up.sleepTtl ?? null) === (next.up.sleepTtl ?? null) &&
298
+ options(run.options) === options(next.request.options));
299
+ }
300
+ exports.runsTheSame = runsTheSame;
301
+ // A run whole, for its owner and who may read it; who is not its owner is told whose it is.
302
+ function toRunResponse(run, queue, named = false) {
185
303
  const { owner, ...visible } = run;
186
- void owner;
187
- return { ...visible, results: run.results ?? {} };
304
+ return {
305
+ ...visible,
306
+ ...(named ? { owner: { iss: owner.iss, sub: owner.sub } } : {}),
307
+ results: run.results ?? {},
308
+ ...(queue ? { queue } : {}),
309
+ };
310
+ }
311
+ function listRun(registry, viewer, run) {
312
+ return (0, overview_1.toListedRun)(viewer, run, (0, overview_1.queuePlace)(registry, viewer, run));
188
313
  }
189
- async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res) {
314
+ async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res) {
190
315
  const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
191
316
  if (!parsed.success) {
192
317
  const issue = parsed.error.issues[0];
@@ -218,6 +343,27 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
218
343
  (0, http_2.send)(res, 400, { error: `${version.hash} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
219
344
  return;
220
345
  }
346
+ const notServices = request.services.filter((service) => !version.services.includes(service));
347
+ if (notServices.length > 0) {
348
+ (0, http_2.send)(res, 400, { error: `${version.hash} has no service ${notServices.map(oauth_1.quote).join(', ')}` });
349
+ return;
350
+ }
351
+ if (request.endpoints.length > 0 && !config.endpoints) {
352
+ (0, http_2.send)(res, 400, { error: 'this server publishes no endpoints (endpoints is not configured)' });
353
+ return;
354
+ }
355
+ const ttl = request.services.length > 0 ? (0, units_1.parseDuration)(request.ttl ?? config.runs.up.ttl) : 0;
356
+ if (ttl > (0, units_1.parseDuration)(config.runs.up.maxTtl)) {
357
+ (0, http_2.send)(res, 400, { error: `ttl: at most ${config.runs.up.maxTtl} on this server (runs.up.maxTtl)` });
358
+ return;
359
+ }
360
+ // the files of a sandbox change under a run that sleeps, it would wake to others
361
+ if (request.sleepTtl !== undefined && request.source.type === 'sandbox') {
362
+ (0, http_2.send)(res, 400, { error: 'sleepTtl: a service run from a sandbox does not sleep, start it from a branch' });
363
+ return;
364
+ }
365
+ const sleepTtlText = request.source.type === 'ref' ? request.sleepTtl ?? config.runs.up.sleepTtl : undefined;
366
+ const sleepTtl = request.services.length > 0 && sleepTtlText ? (0, units_1.parseDuration)(sleepTtlText) : undefined;
221
367
  const argued = (request.options.args ?? []).length > 0 ? request.tasks[0] : undefined;
222
368
  if (argued !== undefined && version.services.includes(argued)) {
223
369
  (0, http_2.send)(res, 400, { error: `${argued} is a service, arguments go to a task` });
@@ -239,6 +385,12 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
239
385
  return;
240
386
  }
241
387
  }
388
+ if (request.services.length > 0 && request.source.type === 'sandbox' && config.backend.type === 'kubernetes') {
389
+ (0, http_2.send)(res, 400, {
390
+ error: 'on a cluster a service run starts from a branch, push the sandbox and start it from there',
391
+ });
392
+ return;
393
+ }
242
394
  const sandbox = request.source.type === 'sandbox' ? sandboxes.registry.get(principal, request.source.id) : undefined;
243
395
  if (request.source.type === 'sandbox') {
244
396
  if (!sandbox) {
@@ -257,6 +409,24 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
257
409
  (0, http_2.send)(res, 413, { error: over });
258
410
  return;
259
411
  }
412
+ // a check counts like a run from a commit of the repository, and leaves what it made in
413
+ // the repository's cache, which runs from main restore from: the caller must be allowed
414
+ // to start runs from it, and to push a branch of it, which with a run from that branch
415
+ // leaves the same
416
+ if (request.source.check && !(0, repo_1.matchesAnyRepo)(grant.repos, sandbox.repo)) {
417
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: check on ${sandbox.repo} without the repository`);
418
+ (0, http_2.send)(res, 403, {
419
+ error: `a check counts like a run from ${sandbox.repo}, which no entitlement of yours lists in repos`,
420
+ });
421
+ return;
422
+ }
423
+ if (request.source.check && (0, policy_1.sandboxPushBranches)(grant, sandbox.repo).length === 0) {
424
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: check on ${sandbox.repo} without a branch to push`);
425
+ (0, http_2.send)(res, 403, {
426
+ error: `a check counts like a run from a branch you pushed, and you may push no branch of ${sandbox.repo}`,
427
+ });
428
+ return;
429
+ }
260
430
  }
261
431
  // the accounts of a run are read from the files it is pinned to, with the engine
262
432
  const bundle = version.plan ? await definitions.files(version) : undefined;
@@ -275,12 +445,20 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
275
445
  (0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
276
446
  return;
277
447
  }
278
- const refused = (0, run_plan_1.refuseArguments)(request, planned.plan, sandbox ? sandboxes.policy : planning.policy);
448
+ const refused = (0, run_plan_1.refuseArguments)(request, planned.plan, sandbox ? sandboxes.policy : planning.policy) ??
449
+ (0, service_runs_1.refuseEndpoints)(request, planned.plan, config.runs.secretNetwork === 'open');
279
450
  if (refused) {
280
451
  log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${refused}`);
281
452
  (0, http_2.send)(res, 400, { error: refused });
282
453
  return;
283
454
  }
455
+ const runRepo = request.source.type === 'ref' ? request.source.repo : sandbox?.repo;
456
+ if (request.endpoints.length > 0 && (!runRepo || (0, policy_1.endpointLevel)(grant, runRepo) !== 'publish')) {
457
+ const error = `you may not publish endpoints of ${runRepo ?? 'this source'}, an entitlement needs endpoints with level publish for it`;
458
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${error}`);
459
+ (0, http_2.send)(res, 403, { error });
460
+ return;
461
+ }
284
462
  const decision = (0, policy_1.decideRun)({
285
463
  grant,
286
464
  accounts: planned.accounts,
@@ -324,13 +502,54 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
324
502
  else if (request.source.type === 'upload') {
325
503
  source = request.source;
326
504
  }
327
- const submit = (runSource) => registry.submit(principal, {
505
+ // a service run is named after its branch unless it says, and its endpoints after that
506
+ const serviceName = request.services.length > 0
507
+ ? request.name ??
508
+ (0, service_runs_1.getServiceRunName)(request.source.type === 'ref' ? request.source.ref : sandbox?.ref)
509
+ : '';
510
+ const up = request.services.length > 0
511
+ ? {
512
+ services: request.services,
513
+ ttl,
514
+ ...(sleepTtl ? { sleepTtl } : {}),
515
+ name: serviceName,
516
+ endpoints: (0, service_runs_1.toRunEndpoints)(request.endpoints, (0, service_runs_1.getEndpointHosts)(serviceName, principal, request.endpoints), endpointUrl),
517
+ }
518
+ : undefined;
519
+ // a run that may sleep keeps what its owner is entitled by, for the check of a wake
520
+ const claimNames = new Set(config.entitlements.flatMap((entitlement) => Object.keys(entitlement.claims?.match ?? {})));
521
+ const owner = {
522
+ ...principal,
523
+ claims: Object.fromEntries(Object.entries(principal.claims).filter(([name]) => claimNames.has(name))),
524
+ };
525
+ // A service run of the caller of this name that runs the same build file, services and
526
+ // endpoints moves to the commit, in place of a new run that would replace it: it keeps its
527
+ // id, its addresses and its volumes. A run that needs an approval is new as before.
528
+ if (up && source?.type === 'ref' && approvals.length === 0) {
529
+ const named = registry.named(principal, up.name);
530
+ if (named && runsTheSame(named, { definition: version.hash, accounts: planned.accounts, request, source, up })) {
531
+ const from = (registry.logs(principal, named.id)?.index.size ?? 0) + 1;
532
+ const moved = registry.move(principal, named.id, {
533
+ source,
534
+ plan: planned.plan,
535
+ owner,
536
+ ...(request.ttl ? { ttl } : {}),
537
+ });
538
+ if (moved) {
539
+ log(`move run ${moved.id} iss=${principal.iss} sub=${principal.sub} name=${up.name} to ${source.commit} (${moved.state})`);
540
+ (0, http_2.send)(res, 202, { ...toRunResponse(moved), moved: { logFrom: from } });
541
+ return;
542
+ }
543
+ }
544
+ }
545
+ const submit = (runSource) => registry.submit(owner, {
328
546
  definition: version.hash,
329
547
  tasks: request.tasks,
330
548
  accounts: planned.accounts,
331
549
  plan: planned.plan,
332
550
  source: runSource,
333
551
  options: request.options,
552
+ ...(up ? { up } : {}),
334
553
  }, approvals);
335
554
  try {
336
555
  // a sandbox is read and the run submitted while nothing else changes it: the run
@@ -346,10 +565,13 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
346
565
  commit: state.commit,
347
566
  tree: state.tree,
348
567
  clean: state.clean,
568
+ ...(request.source.type === 'sandbox' && request.source.check ? { check: true } : {}),
349
569
  });
350
570
  });
351
- 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(',')}]` : ''}`);
352
- (0, http_2.send)(res, 202, toRunResponse(run));
571
+ 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
572
+ ? ` services=[${run.up.services.join(',')}] name=${run.up.name} endpoints=${run.up.endpoints.length}`
573
+ : ''}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
574
+ (0, http_2.send)(res, 202, toRunResponse(run, (0, overview_1.queuePlace)(registry, (0, overview_1.getViewer)(config, principal), run)));
353
575
  }
354
576
  catch (e) {
355
577
  if (e instanceof runs_1.RunError) {
@@ -385,7 +607,14 @@ async function authenticate(verifier, log, req, res) {
385
607
  return null;
386
608
  }
387
609
  }
388
- async function handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res) {
610
+ async function handle(api, verifier, req, res) {
611
+ const { config, gateway, log } = api;
612
+ // the hosts of endpoints are the apps of service runs, not the API
613
+ const label = gateway?.hostOf(req);
614
+ if (gateway && label) {
615
+ await gateway.handle(label, req, res);
616
+ return;
617
+ }
389
618
  const url = new URL(req.url ?? '/', 'http://localhost');
390
619
  const path = url.pathname;
391
620
  if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
@@ -393,13 +622,30 @@ async function handle(config, registry, definitions, proposals, sources, sandbox
393
622
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, HEAD' });
394
623
  return;
395
624
  }
396
- (0, http_2.send)(res, 200, path === '/healthz' ? { status: 'ok' } : getDiscoveryDocument(config));
625
+ const dashboard = gateway?.dashboardUrl();
626
+ (0, http_2.send)(res, 200, path === '/healthz' ? { status: 'ok' } : { ...getDiscoveryDocument(config), ...(dashboard ? { dashboard } : {}) });
627
+ return;
628
+ }
629
+ // a person who opens the address of the server in a browser goes to the dashboard
630
+ const dashboard = gateway?.dashboardUrl();
631
+ if (path === '/' && req.method === 'GET' && dashboard && (req.headers.accept ?? '').includes('text/html')) {
632
+ res.writeHead(302, { location: dashboard, 'cache-control': 'no-store' });
633
+ res.end();
397
634
  return;
398
635
  }
399
636
  const principal = await authenticate(verifier, log, req, res);
400
637
  if (!principal) {
401
638
  return;
402
639
  }
640
+ await handleApi(api, principal, req, res, url);
641
+ }
642
+ /**
643
+ * The API for a caller who is known: by their token here, or by the sign-in of the
644
+ * dashboard, which asks the same API for what its pages show (dashboard.ts).
645
+ */
646
+ async function handleApi(api, principal, req, res, url) {
647
+ const { config, registry, gateway, definitions, proposals, sources, sandboxes, planning, repos, checks, capacity, log, } = api;
648
+ const path = url.pathname;
403
649
  if (await (0, accounts_api_1.handleAccounts)(config, log, principal, req, res, url)) {
404
650
  return;
405
651
  }
@@ -412,7 +658,51 @@ async function handle(config, registry, definitions, proposals, sources, sandbox
412
658
  if (await (0, sandboxes_api_1.handleSandboxes)(config, sandboxes.registry, repos, log, principal, req, res, url)) {
413
659
  return;
414
660
  }
415
- if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url)) {
661
+ if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, (host) => gateway?.url(host) ?? host, log, principal, req, res, url)) {
662
+ return;
663
+ }
664
+ if (path === '/v1/overview') {
665
+ if (req.method !== 'GET') {
666
+ (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
667
+ return;
668
+ }
669
+ const viewer = (0, overview_1.getViewer)(config, principal);
670
+ if (viewer.grant.matched.length === 0) {
671
+ (0, http_2.send)(res, 403, { error: 'you are not entitled to anything on this server' });
672
+ return;
673
+ }
674
+ (0, http_2.send)(res, 200, (0, overview_1.getOverview)(registry.snapshot(), registry.usage(principal), viewer));
675
+ return;
676
+ }
677
+ if (path === '/v1/capacity' || path === '/v1/tasks') {
678
+ if (req.method !== 'GET') {
679
+ (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
680
+ return;
681
+ }
682
+ const viewer = (0, overview_1.getViewer)(config, principal);
683
+ if (viewer.grant.matched.length === 0) {
684
+ (0, http_2.send)(res, 403, { error: 'you are not entitled to anything on this server' });
685
+ return;
686
+ }
687
+ if (path === '/v1/capacity') {
688
+ (0, http_2.send)(res, 200, (0, overview_1.visibleCapacity)(await capacity.read(registry.all()), viewer));
689
+ return;
690
+ }
691
+ // of every run the caller may see, unless they ask for their own
692
+ const params = new URLSearchParams(url.searchParams);
693
+ if (!params.has('scope')) {
694
+ params.set('scope', 'visible');
695
+ }
696
+ let filter;
697
+ try {
698
+ filter = (0, overview_1.parseRunFilter)(params, viewer.grant);
699
+ }
700
+ catch (e) {
701
+ (0, http_2.send)(res, e instanceof overview_1.FilterRefused ? 403 : 400, { error: e instanceof Error ? e.message : String(e) });
702
+ return;
703
+ }
704
+ const runs = (0, overview_1.filterRuns)(registry.all(), viewer, filter).map((run) => (0, overview_1.toListedRun)(viewer, run));
705
+ (0, http_2.send)(res, 200, { tasks: (0, timings_1.getTaskStats)(runs, Date.now()) });
416
706
  return;
417
707
  }
418
708
  if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
@@ -421,6 +711,9 @@ async function handle(config, registry, definitions, proposals, sources, sandbox
421
711
  if (await (0, proposals_api_1.handleProposals)(config, definitions, proposals, log, principal, req, res, url)) {
422
712
  return;
423
713
  }
714
+ if (await (0, checks_api_1.handleChecks)(config, checks, definitions, log, principal, req, res, url)) {
715
+ return;
716
+ }
424
717
  (0, http_2.send)(res, 404, { error: 'not found' });
425
718
  }
426
719
  async function startServer(config, environment, options = {}) {
@@ -447,12 +740,13 @@ async function startServer(config, environment, options = {}) {
447
740
  },
448
741
  policy: {
449
742
  envs: config.runs.env.allow,
450
- reserved: (0, server_config_1.getSourceAccounts)(config),
743
+ reserved: (0, server_config_1.getServerAccounts)(config),
451
744
  runtimes: config.runtimes,
452
745
  backend: config.backend.type,
453
746
  network: (0, work_network_1.parseWorkNetwork)(config.runs.network),
454
747
  secretNetwork: (0, work_network_1.parseWorkNetwork)(config.runs.secretNetwork),
455
748
  args: config.runs.args,
749
+ capabilities: config.backend.capabilities?.allow ?? [],
456
750
  proxiedProviders: config.backend.type === 'kubernetes'
457
751
  ? Object.entries(config.backend.providers)
458
752
  .filter(([, provider]) => provider.allow)
@@ -493,6 +787,8 @@ async function startServer(config, environment, options = {}) {
493
787
  envs: config.sandboxes.env.allow,
494
788
  envsFrom: 'sandboxes.env.allow',
495
789
  runtimes: sandboxRuntimes,
790
+ capabilities: config.sandboxes.capabilities,
791
+ capabilitiesFrom: 'sandboxes.capabilities',
496
792
  },
497
793
  };
498
794
  if (sandboxRuntimes.length === 0 && config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
@@ -512,6 +808,9 @@ async function startServer(config, environment, options = {}) {
512
808
  cache: (0, path_1.join)(storage, 'cache'),
513
809
  worker: options.worker,
514
810
  sandboxes: { ...sandboxes, network: config.sandboxes.network },
811
+ ...(config.backend.type === 'host' && config.backend.capabilities
812
+ ? { capabilityRuntime: config.backend.capabilities.runtime }
813
+ : {}),
515
814
  log,
516
815
  };
517
816
  const backend = config.backend;
@@ -519,7 +818,16 @@ async function startServer(config, environment, options = {}) {
519
818
  (backend.type === 'kubernetes'
520
819
  ? new kubernetes_backend_1.KubernetesExecutor({ ...config, backend }, environment.processEnvs, executorOptions)
521
820
  : new executor_1.HostExecutor(executorOptions));
821
+ const capacity = (0, capacity_1.cachedCapacity)(options.capacity ??
822
+ (executor instanceof kubernetes_backend_1.KubernetesExecutor
823
+ ? executor.capacity()
824
+ : new capacity_1.HostCapacity((0, execute_docker_1.createDockerClient)(undefined), !environment.processEnvs.DOCKER_HOST)));
522
825
  const sources = new sources_1.SourceStore((0, path_1.join)(storage, 'sources'));
826
+ const checks = new checks_1.CheckStore((0, path_1.join)(storage, 'checks'), { keep: (0, units_1.parseDuration)(config.runs.checksKeep) });
827
+ const sweepChecks = () => checks.sweep().catch((e) => log(`the old checks could not be removed: ${e instanceof Error ? e.message : e}`));
828
+ void sweepChecks();
829
+ const sweeping = setInterval(sweepChecks, CHECK_SWEEP_MS);
830
+ sweeping.unref();
523
831
  const registry = new runs_1.RunRegistry({
524
832
  executor,
525
833
  maxConcurrent: config.runs.maxConcurrent,
@@ -529,21 +837,74 @@ async function startServer(config, environment, options = {}) {
529
837
  limits: config.runs.limits,
530
838
  maxDuration: (0, units_1.parseDuration)(config.runs.maxDuration),
531
839
  maxLogBytes: config.runs.maxLogBytes,
840
+ up: {
841
+ maxTtl: (0, units_1.parseDuration)(config.runs.up.maxTtl),
842
+ perCaller: config.runs.up.limits.up,
843
+ asleep: config.runs.up.limits.asleep,
844
+ total: config.runs.up.total,
845
+ keepVolumes: config.runs.up.keepVolumes,
846
+ },
532
847
  store: new run_store_1.FileRunStore((0, path_1.join)(storage, 'records')),
848
+ // what a check run passed, for `remote verify` to find
849
+ onFinished: (run, keys) => {
850
+ const passed = (0, checks_1.getCheckRecords)(run, keys);
851
+ if (passed.length > 0) {
852
+ checks
853
+ .save(passed)
854
+ .then(() => log(`checks run=${run.id} passed=[${passed.map((record) => record.task).join(',')}]`))
855
+ .catch((e) => log(`the checks of run ${run.id} were not kept: ${e instanceof Error ? e.message : e}`));
856
+ }
857
+ },
533
858
  log,
534
859
  });
860
+ // the executor hears which service runs sleep with their volumes kept, and removes what it
861
+ // kept for any other (RunExecutor.restored)
535
862
  await registry.restore();
536
- sandboxes.registry.useRuns({
537
- holder: (id) => registry.holder(id),
538
- stop: (id) => registry.stopSandbox(id, 'the sandbox was deleted'),
539
- });
540
863
  const proposals = new proposals_1.ProposalRegistry({
541
864
  maxWaiting: config.approvals.maxWaiting,
542
865
  timeout: (0, units_1.parseDuration)(config.approvals.timeout),
543
866
  cooldown: (0, units_1.parseDuration)(config.approvals.cooldown),
544
867
  });
868
+ const login = config.endpoints?.login;
869
+ const gateway = config.endpoints
870
+ ? new endpoints_1.EndpointGateway(config, registry, {
871
+ keyFile: (0, path_1.join)(storage, 'endpoints', 'session.key'),
872
+ verifier,
873
+ ...(login?.clientSecret
874
+ ? { clientSecret: () => repos.serverSecret(login.clientSecret) }
875
+ : {}),
876
+ log,
877
+ ...(options.fetch ? { fetch: options.fetch } : {}),
878
+ ...(options.now ? { now: options.now } : {}),
879
+ mayWake: (run) => refuseWake(config, repos, run),
880
+ // the pages of the dashboard read and do what they show through the API
881
+ dashboard: {
882
+ api: (principal, req, res, url) => handleApi(api, principal, req, res, url),
883
+ ...(options.dashboardAssets ? { assets: options.dashboardAssets } : {}),
884
+ },
885
+ })
886
+ : undefined;
887
+ const api = {
888
+ config,
889
+ registry,
890
+ gateway,
891
+ definitions,
892
+ proposals,
893
+ sources,
894
+ sandboxes,
895
+ planning,
896
+ repos,
897
+ checks,
898
+ capacity,
899
+ log,
900
+ };
901
+ await gateway?.load();
902
+ sandboxes.registry.useRuns({
903
+ holder: (id) => registry.holder(id),
904
+ stop: (id) => registry.stopSandbox(id, 'the sandbox was deleted'),
905
+ });
545
906
  const listener = (req, res) => {
546
- handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res).catch((e) => {
907
+ handle(api, verifier, req, res).catch((e) => {
547
908
  if (e instanceof http_2.HttpError) {
548
909
  (0, http_2.send)(res, e.status, { error: e.message });
549
910
  return;
@@ -563,11 +924,44 @@ async function startServer(config, environment, options = {}) {
563
924
  environment.file.read(config.tls.cert),
564
925
  environment.file.read(config.tls.key),
565
926
  ]);
566
- server = (0, https_1.createServer)({ cert, key }, listener);
927
+ // the hosts of endpoints have a wildcard certificate of their own
928
+ const endpointsTls = config.endpoints?.tls;
929
+ const endpointContext = endpointsTls
930
+ ? (0, tls_1.createSecureContext)({
931
+ cert: await environment.file.read(endpointsTls.cert),
932
+ key: await environment.file.read(endpointsTls.key),
933
+ })
934
+ : undefined;
935
+ const domain = config.endpoints?.domain;
936
+ server = (0, https_1.createServer)({
937
+ cert,
938
+ key,
939
+ ...(endpointContext && domain
940
+ ? {
941
+ SNICallback: (servername, done) => done(null, servername.toLowerCase().endsWith(`.${domain}`) ? endpointContext : undefined),
942
+ }
943
+ : {}),
944
+ }, listener);
567
945
  }
568
946
  else {
569
947
  server = (0, http_1.createServer)(listener);
570
948
  }
949
+ // WebSockets of the apps behind endpoints; the API has none. An upgraded connection is
950
+ // no longer the HTTP server's, it goes when the server closes.
951
+ const upgraded = new Set();
952
+ server.on('upgrade', (req, socket, head) => {
953
+ upgraded.add(socket);
954
+ socket.once('close', () => upgraded.delete(socket));
955
+ const label = gateway?.hostOf(req);
956
+ if (!gateway || !label) {
957
+ socket.end('HTTP/1.1 404 Not Found\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
958
+ return;
959
+ }
960
+ gateway.upgrade(label, req, socket, head).catch((e) => {
961
+ log(`error upgrade ${label}: ${e instanceof Error ? e.message : String(e)}`);
962
+ socket.destroy();
963
+ });
964
+ });
571
965
  await new Promise((resolve, reject) => {
572
966
  server.once('error', reject);
573
967
  server.listen(config.listen.port, config.listen.host, () => {
@@ -576,11 +970,12 @@ async function startServer(config, environment, options = {}) {
576
970
  });
577
971
  });
578
972
  const address = server.address();
973
+ gateway?.listening(address.port, Boolean(config.tls));
579
974
  const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
580
975
  return {
581
976
  url: `${config.tls ? 'https' : 'http'}://${host}:${address.port}`,
582
977
  reload: (next) => {
583
- const readers = (0, server_config_1.getSourceAccounts)(config);
978
+ const readers = (0, server_config_1.getServerAccounts)(config);
584
979
  next.entitlements.forEach((entitlement, index) => {
585
980
  const iss = (entitlement.subject ?? entitlement.group ?? entitlement.claims)?.iss;
586
981
  if (iss && !config.issuers.some((issuer) => issuer.issuer === iss)) {
@@ -600,11 +995,13 @@ async function startServer(config, environment, options = {}) {
600
995
  return { restart };
601
996
  },
602
997
  close: async () => {
998
+ clearInterval(sweeping);
603
999
  proposals.close();
604
1000
  await registry.close();
605
1001
  await new Promise((resolve, reject) => {
606
1002
  server.close((err) => (err ? reject(err) : resolve()));
607
1003
  server.closeAllConnections();
1004
+ upgraded.forEach((socket) => socket.destroy());
608
1005
  });
609
1006
  },
610
1007
  };