hammerkit 1.27.0-next.8 → 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 (129) 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 +76 -17
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-http-route.js +1 -1
  36. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +138 -18
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  38. package/dist/kubernetes/ensure-persistent-data.js +212 -10
  39. package/dist/kubernetes/follow-pod-logs.js +182 -0
  40. package/dist/kubernetes/get-service-ip.js +2 -2
  41. package/dist/kubernetes/job-placement.js +36 -12
  42. package/dist/kubernetes/job-secrets.js +9 -6
  43. package/dist/kubernetes/kept-claims.js +22 -10
  44. package/dist/kubernetes/kubernetes-instance.js +1 -0
  45. package/dist/kubernetes/network-policy.js +16 -8
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/remove-stale-items.js +46 -0
  48. package/dist/kubernetes/resources.js +25 -3
  49. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  50. package/dist/kubernetes/sandbox-runner.js +628 -0
  51. package/dist/kubernetes/secrets.js +7 -11
  52. package/dist/kubernetes/service-rollouts.js +74 -0
  53. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  54. package/dist/kubernetes/volumes.js +96 -11
  55. package/dist/main.js +2 -0
  56. package/dist/optimizer/calculate-checksum.js +3 -2
  57. package/dist/optimizer/check-key.js +94 -0
  58. package/dist/optimizer/check-keys.js +64 -0
  59. package/dist/optimizer/get-work-cache-stats.js +17 -3
  60. package/dist/optimizer/work-task-cache-description.js +49 -1
  61. package/dist/planner/service-definition.js +9 -4
  62. package/dist/planner/utils/append-work-dependencies.js +5 -3
  63. package/dist/planner/utils/append-work-service.js +18 -1
  64. package/dist/planner/utils/append-work-task.js +13 -3
  65. package/dist/planner/utils/get-work-runtime.js +6 -1
  66. package/dist/planner/utils/normalize-path.js +19 -1
  67. package/dist/planner/utils/parse-work-mount.js +7 -1
  68. package/dist/planner/utils/parse-work-port.js +4 -1
  69. package/dist/planner/validate.js +22 -0
  70. package/dist/planner/work-network.js +3 -1
  71. package/dist/planner/work-runtime-aggregate.js +32 -0
  72. package/dist/planner/work-runtime-kubernetes.js +153 -26
  73. package/dist/planner/work-task.js +6 -1
  74. package/dist/program.js +27 -9
  75. package/dist/remote/device-login.js +7 -2
  76. package/dist/remote/host-config.js +69 -3
  77. package/dist/remote/remote-commands.js +33 -7
  78. package/dist/remote/remote-status.js +112 -0
  79. package/dist/remote/remote-work-commands.js +562 -45
  80. package/dist/remote/server-client.js +15 -2
  81. package/dist/remote/token-source.js +64 -26
  82. package/dist/run-program.js +9 -0
  83. package/dist/schema/build-file-base-task-schema.js +3 -3
  84. package/dist/schema/build-file-container-service-schema.js +10 -0
  85. package/dist/schema/build-file-container-task-schema.js +16 -1
  86. package/dist/schema/build-file-include-schema.js +18 -4
  87. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  88. package/dist/schema/build-file-task-schema.js +5 -1
  89. package/dist/schema/capabilities-schema.js +56 -0
  90. package/dist/schema/reference-parser.js +119 -11
  91. package/dist/schema/removed-properties.js +20 -0
  92. package/dist/schema/schema-parser.js +94 -41
  93. package/dist/schema/work-scope-parser.js +33 -10
  94. package/dist/secrets/command-secret-provider.js +3 -1
  95. package/dist/secrets/github-app-token-command.js +3 -1
  96. package/dist/secrets/secret-catalog.js +43 -19
  97. package/dist/server/accounts-api.js +4 -1
  98. package/dist/server/approvals-api.js +1 -1
  99. package/dist/server/capacity-format.js +36 -0
  100. package/dist/server/capacity.js +300 -0
  101. package/dist/server/checks-api.js +83 -0
  102. package/dist/server/checks.js +116 -0
  103. package/dist/server/dashboard.js +196 -0
  104. package/dist/server/definitions-api.js +66 -3
  105. package/dist/server/endpoints.js +794 -0
  106. package/dist/server/executor.js +164 -9
  107. package/dist/server/kubernetes-backend.js +478 -70
  108. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  109. package/dist/server/overview.js +271 -0
  110. package/dist/server/plan.js +170 -121
  111. package/dist/server/policy.js +34 -2
  112. package/dist/server/proposals-api.js +17 -8
  113. package/dist/server/repo-access.js +5 -0
  114. package/dist/server/run-logs.js +4 -0
  115. package/dist/server/run-plan.js +38 -4
  116. package/dist/server/run-request.js +58 -3
  117. package/dist/server/run-worker.js +262 -13
  118. package/dist/server/runs.js +969 -36
  119. package/dist/server/sandbox-pod.js +28 -0
  120. package/dist/server/sandbox-storage.js +23 -0
  121. package/dist/server/sandboxes.js +8 -0
  122. package/dist/server/server-config.js +246 -10
  123. package/dist/server/server.js +429 -32
  124. package/dist/server/service-runs.js +82 -0
  125. package/dist/server/timings.js +132 -0
  126. package/dist/server/wait-reason.js +21 -0
  127. package/dist/utils/tar.js +28 -1
  128. package/dist/utils/terminal-prompt.js +53 -0
  129. package/package.json +8 -2
@@ -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) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.detectPlatform = exports.getSecretCatalog = exports.readServerConfig = exports.getSourceAccounts = exports.getSandboxRuntimes = exports.serverConfigSchema = exports.sourceSchema = exports.entitlementSchema = exports.sandboxGrantSchema = exports.sandboxBranchTypeSchema = exports.buildsLevelSchema = exports.accountLevelSchema = exports.getMaxVolumeSize = exports.DEFAULT_MAX_VOLUME_SIZE = exports.DEFAULT_DENIED_EGRESS = exports.kubernetesPoolSchema = exports.accountSchema = exports.issuerSchema = exports.serverPlatformSchema = void 0;
3
+ exports.detectPlatform = exports.getSecretCatalog = exports.readServerConfig = exports.getServerAccounts = exports.getSandboxRuntimes = exports.serverConfigSchema = exports.sourceSchema = exports.entitlementSchema = exports.endpointGrantSchema = exports.endpointLevelSchema = exports.sandboxGrantSchema = exports.sandboxBranchTypeSchema = exports.buildsLevelSchema = exports.accountLevelSchema = exports.getMaxVolumeSize = exports.DEFAULT_MAX_VOLUME_SIZE = exports.DEFAULT_DENIED_EGRESS = exports.kubernetesPoolSchema = exports.accountSchema = exports.issuerSchema = exports.serverPlatformSchema = void 0;
4
4
  const net_1 = require("net");
5
5
  const zod_1 = require("zod");
6
6
  const yaml_1 = require("yaml");
@@ -8,6 +8,7 @@ const log_1 = require("../log");
8
8
  const units_1 = require("../utils/units");
9
9
  const version_1 = require("../version");
10
10
  const network_schema_1 = require("../schema/network-schema");
11
+ const capabilities_schema_1 = require("../schema/capabilities-schema");
11
12
  const build_file_secret_provider_schema_1 = require("../schema/build-file-secret-provider-schema");
12
13
  const repo_1 = require("./repo");
13
14
  const work_network_1 = require("../planner/work-network");
@@ -117,12 +118,24 @@ exports.accountSchema = zod_1.z
117
118
  });
118
119
  }
119
120
  });
121
+ // The capabilities (`capAdd`) a task of a run may add, and the sandboxed runtime its
122
+ // container then runs in, so they hold in the sandbox's own kernel and never in the host's
123
+ const capabilitiesAllowSchema = zod_1.z
124
+ .array(zod_1.z.enum(capabilities_schema_1.LINUX_CAPABILITIES))
125
+ .min(1)
126
+ .refine((names) => new Set(names).size === names.length, 'a capability is listed twice');
120
127
  // The tasks of runs execute on this machine, with its docker daemon and local tools.
121
128
  const hostBackendSchema = zod_1.z
122
129
  .object({
123
130
  type: zod_1.z.literal('host'),
124
131
  // what this machine offers; detected when left out
125
132
  platform: exports.serverPlatformSchema.optional(),
133
+ // `runtime` is a runtime of the docker daemon (`runtimes` in its daemon.json), such as
134
+ // runsc of gVisor
135
+ capabilities: zod_1.z
136
+ .object({ allow: capabilitiesAllowSchema, runtime: zod_1.z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*$/) })
137
+ .strict()
138
+ .optional(),
126
139
  })
127
140
  .strict();
128
141
  const tolerationSchema = zod_1.z
@@ -188,8 +201,12 @@ const kubernetesSandboxesSchema = zod_1.z
188
201
  // when left out. With a class of their own, a ResourceQuota on that class in each
189
202
  // caller's namespace holds `sandboxes.limits`.
190
203
  storageClass: zod_1.z.string().min(1).optional(),
191
- // how long the pod of a sandbox's files stays after the last request
204
+ // how long the pod of a sandbox's files stays after the last request; the runner of the
205
+ // sandbox goes with it
192
206
  idle: duration.default('1m'),
207
+ // exec the commands of a task shaped like a shell (it mounts the sandbox only, has no
208
+ // needs, outputs or secrets) into one pod per sandbox, in place of a Job each
209
+ runner: zod_1.z.boolean().default(true),
193
210
  // of the pod of a sandbox's files, which runs git and ripgrep over all of them
194
211
  resources: zod_1.z
195
212
  .object({ requests: quantitiesSchema.optional(), limits: quantitiesSchema.optional() })
@@ -278,6 +295,9 @@ const kubernetesBackendSchema = zod_1.z
278
295
  // build file's environment does: hammerkit can't tell from the API, and without it a
279
296
  // task with a limited network fails rather than runs open
280
297
  networkPolicy: zod_1.z.literal('enforced').optional(),
298
+ // `runtimeClass` is a RuntimeClass of a sandbox, such as gvisor; the pods of tasks that
299
+ // add capabilities run in it
300
+ capabilities: zod_1.z.object({ allow: capabilitiesAllowSchema, runtimeClass: kubernetesNameSchema }).strict().optional(),
281
301
  })
282
302
  .strict();
283
303
  // the most a task's claim may request on a server whose operator states no maxVolumeSize
@@ -323,6 +343,11 @@ exports.sandboxGrantSchema = zod_1.z
323
343
  .refine((branches) => Object.keys(branches).length > 0, 'name the branches a sandbox may start from'),
324
344
  })
325
345
  .strict();
346
+ // `open`: open the endpoints of the service runs of a repository, whoever started them, and
347
+ // extend those runs; `publish`: also name endpoints in one's own service runs.
348
+ exports.endpointLevelSchema = zod_1.z.enum(['open', 'publish']);
349
+ // One repository (`host/path` pattern) whose endpoints an entitlement opens or publishes.
350
+ exports.endpointGrantSchema = zod_1.z.object({ repo: repoPatternSchema, level: exports.endpointLevelSchema }).strict();
326
351
  exports.entitlementSchema = zod_1.z
327
352
  .object({
328
353
  ...whoShape,
@@ -332,19 +357,38 @@ exports.entitlementSchema = zod_1.z
332
357
  // the repositories (`host/path` patterns) the caller may run from a git ref
333
358
  repos: zod_1.z.array(repoPatternSchema).default([]),
334
359
  builds: exports.buildsLevelSchema.optional(),
360
+ // `read`: may look up which tasks runs passed for files of the `repos` (remote verify),
361
+ // and nothing more; a builds level includes it
362
+ checks: zod_1.z.literal('read').optional(),
335
363
  // the repositories the caller may keep sandboxes of, checkouts on the server that runs
336
364
  // work on: a list of its own, the `repos` above are for runs from a ref
337
365
  sandboxes: zod_1.z.array(exports.sandboxGrantSchema).optional(),
366
+ // the repositories whose service runs' endpoints the caller may open, or publish
367
+ endpoints: zod_1.z.array(exports.endpointGrantSchema).optional(),
368
+ // sees every run of the server in full, who started it, its tasks and its log, in
369
+ // `remote status`, `remote runs --all` and the dashboard: for operators, it stops,
370
+ // extends and approves nothing
371
+ observe: zod_1.z.literal(true).optional(),
338
372
  })
339
373
  .strict()
340
374
  .superRefine((entitlement, ctx) => {
341
375
  checkWho(entitlement, ctx);
342
376
  if (Object.keys(entitlement.accounts).length === 0 &&
343
377
  entitlement.builds === undefined &&
344
- !entitlement.sandboxes?.length) {
378
+ entitlement.checks === undefined &&
379
+ !entitlement.sandboxes?.length &&
380
+ !entitlement.endpoints?.length &&
381
+ !entitlement.observe) {
345
382
  ctx.addIssue({
346
383
  code: zod_1.z.ZodIssueCode.custom,
347
- message: 'name accounts to use or request, a builds level or sandboxes',
384
+ message: 'name accounts to use or request, a builds level, checks, sandboxes, endpoints or observe',
385
+ });
386
+ }
387
+ if (entitlement.checks && entitlement.repos.length === 0) {
388
+ ctx.addIssue({
389
+ code: zod_1.z.ZodIssueCode.custom,
390
+ path: ['repos'],
391
+ message: 'checks are read for repositories: name them in repos',
348
392
  });
349
393
  }
350
394
  });
@@ -429,6 +473,10 @@ exports.serverConfigSchema = zod_1.z
429
473
  maxConcurrent: zod_1.z.number().int().min(1).default(4),
430
474
  // finished runs that are kept for their state and logs
431
475
  keep: zod_1.z.number().int().min(1).default(200),
476
+ // who sees the runs of others: `everyone` with an entitlement sees who runs what,
477
+ // where it stands and how long it took, never its arguments, error or log;
478
+ // `involved` only sees the runs they decide about or may open the endpoints of
479
+ activity: zod_1.z.enum(['everyone', 'involved']).default('everyone'),
432
480
  // what one caller (issuer and subject) may have at a time, so a single
433
481
  // caller can not take the whole machine; over it a request gets 429
434
482
  limits: zod_1.z
@@ -472,6 +520,40 @@ exports.serverConfigSchema = zod_1.z
472
520
  // which tasks take arguments: `any` but those that say `args: false`, or
473
521
  // `declared`, only those that say `args: true`
474
522
  args: zod_1.z.enum(['any', 'declared']).default('any'),
523
+ // runs of services (`services` in a run), kept up until their ttl ends
524
+ up: zod_1.z
525
+ .object({
526
+ // how long the services stay once ready, when the run names no ttl; then they are removed
527
+ ttl: duration.default('8h'),
528
+ // how long the services stay up without a request to an endpoint before they sleep,
529
+ // when the run names none: the next request wakes them. Unset, they never sleep.
530
+ sleepTtl: duration.optional(),
531
+ // the furthest a run, or an extend, sets the end from now
532
+ maxTtl: duration.default('72h'),
533
+ limits: zod_1.z
534
+ .object({
535
+ // service runs one caller has starting or up at a time; one more is 429
536
+ up: zod_1.z.number().int().min(1).default(3),
537
+ // service runs one caller has asleep, which take no place of `up`; a new run
538
+ // when there are this many is 429
539
+ asleep: zod_1.z.number().int().min(1).default(10),
540
+ })
541
+ .strict()
542
+ .default({}),
543
+ // service runs starting or up on the whole server; one more is 503
544
+ total: zod_1.z.number().int().min(1).default(20),
545
+ // on a Kubernetes backend, the ResourceQuota of the namespace of each service
546
+ // run; backend.quota when left out
547
+ quota: zod_1.z.record(zod_1.z.union([zod_1.z.string().min(1), zod_1.z.number()]).transform(String)).optional(),
548
+ // the volumes of a run's services stay while it sleeps, and are mounted again when
549
+ // it wakes; they go when the run ends. false for a server short on disk: a woken
550
+ // run starts from nothing.
551
+ keepVolumes: zod_1.z.boolean().default(true),
552
+ })
553
+ .strict()
554
+ .default({}),
555
+ // how long a passed check stays for `remote verify` to find (see checks.ts)
556
+ checksKeep: duration.default('30d'),
475
557
  })
476
558
  .strict()
477
559
  .default({}),
@@ -496,6 +578,9 @@ exports.serverConfigSchema = zod_1.z
496
578
  .object({ allow: zod_1.z.array(zod_1.z.string().regex(/^[A-Za-z_][A-Za-z0-9_()]*$/)).default([]) })
497
579
  .strict()
498
580
  .default({}),
581
+ // the capabilities of `backend.capabilities.allow` a task of a run on a sandbox may
582
+ // add: none by default, a sandbox holds code nobody reviewed
583
+ capabilities: zod_1.z.array(zod_1.z.enum(capabilities_schema_1.LINUX_CAPABILITIES)).default([]),
499
584
  // what one caller may keep; a sandbox over it can't be created
500
585
  limits: zod_1.z
501
586
  .object({
@@ -509,6 +594,59 @@ exports.serverConfigSchema = zod_1.z
509
594
  })
510
595
  .strict()
511
596
  .default({}),
597
+ // ports of service runs published at hosts of their own below `domain`, behind the
598
+ // server's sign-in. Absent, a run naming endpoints is refused.
599
+ endpoints: zod_1.z
600
+ .object({
601
+ // `run.example.com`: an endpoint is https://<label>.run.example.com, all of them
602
+ // pointing at this server with one wildcard DNS record
603
+ domain: zod_1.z
604
+ .string()
605
+ .regex(/^(?=.{1,200}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, 'expected a domain name in lower case, such as run.example.com'),
606
+ // a wildcard certificate for *.<domain>, served for the hosts of endpoints; left out
607
+ // behind a TLS proxy, which holds it
608
+ tls: zod_1.z
609
+ .object({ cert: zod_1.z.string().min(1), key: zod_1.z.string().min(1) })
610
+ .strict()
611
+ .optional(),
612
+ // how a person signs in with a browser: the authorization code flow of an issuer of
613
+ // `issuers`, with a client of its own; without it only tokens open endpoints
614
+ login: zod_1.z
615
+ .object({
616
+ issuer: zod_1.z.string().min(1),
617
+ clientId: zod_1.z.string().min(1),
618
+ // for providers whose web clients need one (Google): read as an account no
619
+ // entitlement may name, as the secret of a source is
620
+ clientSecret: zod_1.z
621
+ .object({
622
+ from: zod_1.z.string().regex(/^[A-Za-z][A-Za-z0-9_-]*:.+$/, 'expected provider:reference'),
623
+ account: accountNameSchema,
624
+ })
625
+ .strict()
626
+ .optional(),
627
+ scope: zod_1.z.string().min(1).default('openid'),
628
+ })
629
+ .strict()
630
+ .optional(),
631
+ // how long a sign-in at one endpoint lasts, at most until its run ends: the groups and
632
+ // claims of the sign-in hold that long, a change at the issuer counts at the next one
633
+ session: duration.default('1h'),
634
+ // the host of the dashboard below the domain, `hammerkit.<domain>`, signed in at like an
635
+ // endpoint (it needs `login`); false for none
636
+ dashboard: zod_1.z
637
+ .union([
638
+ zod_1.z.literal(false),
639
+ zod_1.z
640
+ .string()
641
+ .regex(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, 'expected a host label, such as hammerkit')
642
+ .refine((label) => label !== 'login', 'login is the host of the sign-in')
643
+ // the hosts of endpoints end in -<port>-<12 hex>
644
+ .refine((label) => !/-\d+-[0-9a-f]{12}$/.test(label), 'that is how the hosts of endpoints are named'),
645
+ ])
646
+ .default('hammerkit'),
647
+ })
648
+ .strict()
649
+ .optional(),
512
650
  backend: zod_1.z.discriminatedUnion('type', [hostBackendSchema, kubernetesBackendSchema]).default({ type: 'host' }),
513
651
  })
514
652
  .strict()
@@ -576,7 +714,7 @@ exports.serverConfigSchema = zod_1.z
576
714
  });
577
715
  }
578
716
  });
579
- const readers = getSourceAccounts(config);
717
+ const readers = getServerAccounts(config);
580
718
  config.entitlements.forEach((entitlement, index) => {
581
719
  for (const account of Object.keys(entitlement.accounts)) {
582
720
  if (readers.includes(account)) {
@@ -588,6 +726,16 @@ exports.serverConfigSchema = zod_1.z
588
726
  }
589
727
  }
590
728
  });
729
+ const allowed = config.backend.capabilities?.allow ?? [];
730
+ config.sandboxes.capabilities.forEach((capability, index) => {
731
+ if (!allowed.includes(capability)) {
732
+ ctx.addIssue({
733
+ code: zod_1.z.ZodIssueCode.custom,
734
+ path: ['sandboxes', 'capabilities', index],
735
+ message: `the capability ${capability} is not in backend.capabilities.allow, no run may add it`,
736
+ });
737
+ }
738
+ });
591
739
  const sandboxNetwork = (0, work_network_1.parseWorkNetwork)(config.sandboxes.network);
592
740
  const runsNetwork = (0, work_network_1.parseWorkNetwork)(config.runs.network);
593
741
  if ((0, work_network_1.exceedsNetwork)(sandboxNetwork, runsNetwork)) {
@@ -659,7 +807,7 @@ exports.serverConfigSchema = zod_1.z
659
807
  });
660
808
  // the secrets of tasks are read in their Job, with the image of the provider; the
661
809
  // ones of the sources are read by the server, which checks the code out itself
662
- const readers = getSourceAccounts(config);
810
+ const readers = getServerAccounts(config);
663
811
  for (const [name, account] of Object.entries(config.accounts)) {
664
812
  if (readers.includes(name)) {
665
813
  continue;
@@ -745,6 +893,87 @@ exports.serverConfigSchema = zod_1.z
745
893
  message: 'the network of a local task can not be limited, allow only the docker runtime with runs.network',
746
894
  });
747
895
  }
896
+ if ((0, units_1.parseDuration)(config.runs.up.ttl) > (0, units_1.parseDuration)(config.runs.up.maxTtl)) {
897
+ ctx.addIssue({
898
+ code: zod_1.z.ZodIssueCode.custom,
899
+ path: ['runs', 'up', 'ttl'],
900
+ message: `a ttl of ${config.runs.up.ttl} is more than maxTtl of ${config.runs.up.maxTtl}`,
901
+ });
902
+ }
903
+ const endpoints = config.endpoints;
904
+ if (endpoints) {
905
+ if (endpoints.tls && config.behindTlsProxy) {
906
+ ctx.addIssue({
907
+ code: zod_1.z.ZodIssueCode.custom,
908
+ path: ['endpoints', 'tls'],
909
+ message: 'behind a TLS proxy the proxy holds the certificate of the endpoints, leave out endpoints.tls',
910
+ });
911
+ }
912
+ if (endpoints.tls && !config.tls) {
913
+ ctx.addIssue({
914
+ code: zod_1.z.ZodIssueCode.custom,
915
+ path: ['endpoints', 'tls'],
916
+ message: 'the certificate of the endpoints is served next to the one of the server, set tls as well',
917
+ });
918
+ }
919
+ if (!endpoints.tls && !config.behindTlsProxy && (config.tls || !LOOPBACK_HOSTS.includes(config.listen.host))) {
920
+ ctx.addIssue({
921
+ code: zod_1.z.ZodIssueCode.custom,
922
+ path: ['endpoints', 'tls'],
923
+ message: `name the certificate of *.${endpoints.domain} in endpoints.tls, or set behindTlsProxy: true when a proxy in front holds it`,
924
+ });
925
+ }
926
+ const login = endpoints.login;
927
+ if (login && !config.issuers.some((issuer) => issuer.issuer === login.issuer)) {
928
+ ctx.addIssue({
929
+ code: zod_1.z.ZodIssueCode.custom,
930
+ path: ['endpoints', 'login', 'issuer'],
931
+ message: `issuer ${login.issuer} is not one of the accepted issuers`,
932
+ });
933
+ }
934
+ if (login?.clientSecret) {
935
+ const { from, account } = login.clientSecret;
936
+ const kind = from.substring(0, from.indexOf(':'));
937
+ const where = ['endpoints', 'login', 'clientSecret'];
938
+ if (!config.accounts[account]) {
939
+ ctx.addIssue({
940
+ code: zod_1.z.ZodIssueCode.custom,
941
+ path: [...where, 'account'],
942
+ message: `unknown account ${account}`,
943
+ });
944
+ }
945
+ else if (!config.accounts[account].providers[kind]) {
946
+ ctx.addIssue({
947
+ code: zod_1.z.ZodIssueCode.custom,
948
+ path: [...where, 'from'],
949
+ message: `account ${account} has no credentials for the provider ${kind}`,
950
+ });
951
+ }
952
+ if (!config.secretProviders[kind]) {
953
+ ctx.addIssue({
954
+ code: zod_1.z.ZodIssueCode.custom,
955
+ path: [...where, 'from'],
956
+ message: `secret provider ${kind} is not declared in secretProviders`,
957
+ });
958
+ }
959
+ }
960
+ // only the server is to reach the services behind endpoints, which the NetworkPolicy of
961
+ // a service run says: without it, any pod of the cluster would get past the sign-in
962
+ if (backend.type === 'kubernetes' && backend.networkPolicy !== 'enforced') {
963
+ ctx.addIssue({
964
+ code: zod_1.z.ZodIssueCode.custom,
965
+ path: ['backend', 'networkPolicy'],
966
+ message: 'endpoints keep the services of a run to the server with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them',
967
+ });
968
+ }
969
+ if (backend.type === 'kubernetes' && backend.kubeconfig) {
970
+ ctx.addIssue({
971
+ code: zod_1.z.ZodIssueCode.custom,
972
+ path: ['endpoints'],
973
+ message: 'endpoints reach the services of runs at their Service in the cluster, run the server in the cluster rather than with a kubeconfig',
974
+ });
975
+ }
976
+ }
748
977
  if (!config.tls && !config.behindTlsProxy && !LOOPBACK_HOSTS.includes(config.listen.host)) {
749
978
  ctx.addIssue({
750
979
  code: zod_1.z.ZodIssueCode.custom,
@@ -760,11 +989,18 @@ function getSandboxRuntimes(config) {
760
989
  return config.sandboxes.runtimes ?? config.runtimes.filter((runtime) => runtime === 'docker');
761
990
  }
762
991
  exports.getSandboxRuntimes = getSandboxRuntimes;
763
- // The accounts that read repositories: no caller uses them and no build file names them.
764
- function getSourceAccounts(config) {
765
- return [...new Set(config.sources.flatMap((source) => (source.secret ? [source.secret.account] : [])))];
992
+ // The accounts only the server reads secrets as: those that read the repositories of
993
+ // `sources`, and the one of the client secret of `endpoints.login`. No caller uses them and
994
+ // no build file names them.
995
+ function getServerAccounts(config) {
996
+ return [
997
+ ...new Set([
998
+ ...config.sources.flatMap((source) => (source.secret ? [source.secret.account] : [])),
999
+ ...(config.endpoints?.login?.clientSecret ? [config.endpoints.login.clientSecret.account] : []),
1000
+ ]),
1001
+ ];
766
1002
  }
767
- exports.getSourceAccounts = getSourceAccounts;
1003
+ exports.getServerAccounts = getServerAccounts;
768
1004
  async function readServerConfig(environment, path) {
769
1005
  if (!(await environment.file.exists(path))) {
770
1006
  throw new Error(`unable to read ${path}: no such file`);