hammerkit 1.27.0-next.3 → 1.27.0-next.30

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 (119) hide show
  1. package/build.schema.json +188 -0
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry-cache-backend.js +2 -2
  4. package/dist/cache/explain.js +17 -0
  5. package/dist/cli.js +2 -0
  6. package/dist/docker/item-network.js +13 -5
  7. package/dist/executer/deadline.js +17 -3
  8. package/dist/executer/deferred-restore.js +36 -0
  9. package/dist/executer/docker-service.js +30 -8
  10. package/dist/executer/docker-task.js +21 -1
  11. package/dist/executer/event-cache.js +20 -2
  12. package/dist/executer/execute-work-service.js +2 -0
  13. package/dist/executer/execute-work-task.js +10 -3
  14. package/dist/executer/execution-steps.js +25 -4
  15. package/dist/executer/reset-work-tree.js +4 -0
  16. package/dist/executer/scheduler/enqueue-next.js +57 -6
  17. package/dist/executer/scheduler/state-key.js +42 -5
  18. package/dist/executer/watch-loop.js +4 -1
  19. package/dist/harness/connect.js +20 -6
  20. package/dist/harness/mcp-command.js +22 -6
  21. package/dist/harness/mcp.js +63 -114
  22. package/dist/harness/opencode.js +158 -50
  23. package/dist/harness/pi.js +29 -0
  24. package/dist/harness/sandbox-client.js +168 -17
  25. package/dist/harness/service-tools.js +141 -0
  26. package/dist/harness/task-tools.js +221 -0
  27. package/dist/kubernetes/apply.js +27 -16
  28. package/dist/kubernetes/await-running-state.js +59 -13
  29. package/dist/kubernetes/claim-layout.js +71 -0
  30. package/dist/kubernetes/cluster-state.js +245 -0
  31. package/dist/kubernetes/ensure-http-route.js +1 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +35 -12
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  35. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  36. package/dist/kubernetes/follow-pod-logs.js +182 -0
  37. package/dist/kubernetes/get-service-ip.js +2 -2
  38. package/dist/kubernetes/job-placement.js +36 -12
  39. package/dist/kubernetes/job-secrets.js +33 -19
  40. package/dist/kubernetes/kept-claims.js +22 -10
  41. package/dist/kubernetes/network-policy.js +85 -27
  42. package/dist/kubernetes/remove-persistent-data.js +3 -1
  43. package/dist/kubernetes/resources.js +25 -3
  44. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  45. package/dist/kubernetes/sandbox-runner.js +628 -0
  46. package/dist/kubernetes/secrets.js +7 -11
  47. package/dist/kubernetes/volumes.js +6 -3
  48. package/dist/main.js +2 -0
  49. package/dist/network/egress-proxy.js +45 -6
  50. package/dist/optimizer/calculate-checksum.js +3 -2
  51. package/dist/optimizer/check-key.js +94 -0
  52. package/dist/optimizer/check-keys.js +64 -0
  53. package/dist/optimizer/get-work-cache-stats.js +17 -3
  54. package/dist/optimizer/work-task-cache-description.js +51 -1
  55. package/dist/planner/utils/append-work-dependencies.js +10 -5
  56. package/dist/planner/utils/append-work-service.js +19 -1
  57. package/dist/planner/utils/append-work-task.js +18 -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/utils/task-arguments.js +3 -0
  63. package/dist/planner/validate.js +22 -0
  64. package/dist/planner/work-network.js +3 -1
  65. package/dist/planner/work-runtime-aggregate.js +32 -0
  66. package/dist/planner/work-runtime-docker.js +2 -1
  67. package/dist/planner/work-runtime-kubernetes.js +124 -48
  68. package/dist/planner/work-runtime-local.js +2 -1
  69. package/dist/planner/work-task.js +6 -1
  70. package/dist/program.js +31 -10
  71. package/dist/remote/device-login.js +7 -2
  72. package/dist/remote/host-config.js +69 -3
  73. package/dist/remote/remote-commands.js +30 -7
  74. package/dist/remote/remote-work-commands.js +426 -45
  75. package/dist/remote/server-client.js +15 -2
  76. package/dist/remote/token-source.js +64 -26
  77. package/dist/run-program.js +9 -0
  78. package/dist/schema/build-file-base-task-schema.js +8 -1
  79. package/dist/schema/build-file-container-service-schema.js +12 -0
  80. package/dist/schema/build-file-container-task-schema.js +16 -1
  81. package/dist/schema/build-file-include-schema.js +18 -4
  82. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  83. package/dist/schema/build-file-task-schema.js +5 -1
  84. package/dist/schema/capabilities-schema.js +56 -0
  85. package/dist/schema/reference-parser.js +120 -11
  86. package/dist/schema/schema-parser.js +92 -41
  87. package/dist/schema/work-scope-parser.js +33 -10
  88. package/dist/secrets/command-secret-provider.js +3 -1
  89. package/dist/secrets/github-app-token-command.js +3 -1
  90. package/dist/secrets/secret-catalog.js +43 -19
  91. package/dist/server/accounts-api.js +3 -1
  92. package/dist/server/approvals-api.js +1 -1
  93. package/dist/server/checks-api.js +83 -0
  94. package/dist/server/checks.js +116 -0
  95. package/dist/server/definitions-api.js +66 -3
  96. package/dist/server/endpoints.js +739 -0
  97. package/dist/server/executor.js +57 -5
  98. package/dist/server/kubernetes-backend.js +367 -65
  99. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  100. package/dist/server/oauth.js +12 -1
  101. package/dist/server/plan.js +188 -103
  102. package/dist/server/policy.js +32 -2
  103. package/dist/server/repo-access.js +5 -0
  104. package/dist/server/run-plan.js +50 -19
  105. package/dist/server/run-request.js +58 -3
  106. package/dist/server/run-worker.js +245 -12
  107. package/dist/server/runs.js +410 -27
  108. package/dist/server/sandbox-pod.js +28 -0
  109. package/dist/server/sandbox-storage.js +23 -0
  110. package/dist/server/sandboxes-api.js +10 -4
  111. package/dist/server/sandboxes.js +8 -0
  112. package/dist/server/server-command.js +39 -20
  113. package/dist/server/server-config.js +255 -14
  114. package/dist/server/server.js +269 -15
  115. package/dist/server/service-runs.js +82 -0
  116. package/dist/utils/abort-on-signals.js +46 -6
  117. package/dist/utils/tar.js +36 -0
  118. package/dist/utils/terminal-prompt.js +53 -0
  119. package/package.json +1 -1
@@ -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();
@@ -175,7 +175,8 @@ function parse(schema, body) {
175
175
  /**
176
176
  * Who the commit of a push is by: the caller, as their token names them (`name` and
177
177
  * `email`, unless the issuer says the address is not verified), else by their subject at the
178
- * host of their issuer. Never what the request says, so a commit can not claim to be
178
+ * host of their issuer. Without a name, the local part of the address names them, then the
179
+ * subject. Never what the request says, so a commit can not claim to be
179
180
  * somebody else's.
180
181
  */
181
182
  function author(principal) {
@@ -183,11 +184,16 @@ function author(principal) {
183
184
  .replace(/[<>\0\r\n]/g, '')
184
185
  .trim()
185
186
  .slice(0, 200);
186
- const name = clean(principal.claims.name) || clean(principal.claims.preferred_username) || clean(principal.sub);
187
187
  // an address the issuer says it did not verify is whatever the caller typed in
188
188
  const claimed = principal.emailVerified === false ? '' : clean(principal.claims.email);
189
- if (/^[^\s@]+@[^\s@]+$/.test(claimed)) {
190
- return { name, email: claimed };
189
+ const email = /^[^\s@]+@[^\s@]+$/.test(claimed) ? claimed : '';
190
+ // a Google service account's token has an address but no name: its local part reads better than the number in sub
191
+ const name = clean(principal.claims.name) ||
192
+ clean(principal.claims.preferred_username) ||
193
+ email.slice(0, email.indexOf('@')) ||
194
+ clean(principal.sub);
195
+ if (email) {
196
+ return { name, email };
191
197
  }
192
198
  let host = 'hammerkit';
193
199
  try {
@@ -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) {
@@ -5,6 +5,7 @@ const commander_1 = require("commander");
5
5
  const log_1 = require("../log");
6
6
  const server_config_1 = require("./server-config");
7
7
  const server_1 = require("./server");
8
+ const abort_on_signals_1 = require("../utils/abort-on-signals");
8
9
  // Runs this machine as a hammerkit server until interrupted. Like `remote`, it
9
10
  // only needs its own config file, not a build file.
10
11
  function registerServerCommand(program, environment) {
@@ -13,31 +14,46 @@ function registerServerCommand(program, environment) {
13
14
  .description('serve this machine to remote runs (experimental)')
14
15
  .addOption(new commander_1.Option('-c, --config <file>', 'server config file').env('HAMMERKIT_SERVER_CONFIG'))
15
16
  .action(async (options) => {
17
+ // SIGHUP reads the entitlements again, so who may do what changes without a
18
+ // restart; the rest of the config needs one. The server takes it over from the
19
+ // abort of the CLI, which stops other commands on it, before it starts: a SIGHUP
20
+ // while it reads its config and starts waits for it. Windows sends SIGHUP when the
21
+ // console closes, which stops the server there too.
22
+ let server;
23
+ let reloadWhenStarted = false;
24
+ const reload = async (running) => {
25
+ try {
26
+ const result = running.reload(await (0, server_config_1.readServerConfig)(environment, options.config));
27
+ if (result.restart.length > 0) {
28
+ environment.stdout.write(`changed but not reloaded, restart for: ${result.restart.join(', ')}\n`);
29
+ }
30
+ }
31
+ catch (e) {
32
+ environment.stdout.write(`entitlements were not reloaded: ${(0, log_1.getErrorMessage)(e)}\n`);
33
+ }
34
+ };
35
+ const releaseReload = process.platform !== 'win32'
36
+ ? (0, abort_on_signals_1.takeOverSignal)(process, 'SIGHUP', () => {
37
+ if (server) {
38
+ void reload(server);
39
+ }
40
+ else {
41
+ reloadWhenStarted = true;
42
+ }
43
+ })
44
+ : () => undefined;
16
45
  try {
17
46
  if (!options.config) {
18
47
  throw new Error('pass the server config with --config or HAMMERKIT_SERVER_CONFIG');
19
48
  }
20
49
  const config = await (0, server_config_1.readServerConfig)(environment, options.config);
21
- const server = await (0, server_1.startServer)(config, environment, {
50
+ const running = await (0, server_1.startServer)(config, environment, {
22
51
  log: (line) => environment.stdout.write(`${line}\n`),
23
52
  });
24
- environment.stdout.write(`hammerkit server listening on ${server.url}\n`);
25
- // SIGHUP reads the entitlements again, so who may do what changes without
26
- // a restart; the rest of the config needs one
27
- const reload = async () => {
28
- try {
29
- const result = server.reload(await (0, server_config_1.readServerConfig)(environment, options.config));
30
- if (result.restart.length > 0) {
31
- environment.stdout.write(`changed but not reloaded, restart for: ${result.restart.join(', ')}\n`);
32
- }
33
- }
34
- catch (e) {
35
- environment.stdout.write(`entitlements were not reloaded: ${(0, log_1.getErrorMessage)(e)}\n`);
36
- }
37
- };
38
- const onReload = () => void reload();
39
- if (process.platform !== 'win32') {
40
- process.on('SIGHUP', onReload);
53
+ server = running;
54
+ environment.stdout.write(`hammerkit server listening on ${running.url}\n`);
55
+ if (reloadWhenStarted) {
56
+ void reload(running);
41
57
  }
42
58
  await new Promise((resolve) => {
43
59
  if (environment.abortCtrl.signal.aborted) {
@@ -45,8 +61,8 @@ function registerServerCommand(program, environment) {
45
61
  }
46
62
  environment.abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true });
47
63
  });
48
- process.off('SIGHUP', onReload);
49
- await server.close();
64
+ releaseReload();
65
+ await running.close();
50
66
  environment.stdout.write('hammerkit server stopped\n');
51
67
  }
52
68
  catch (e) {
@@ -55,6 +71,9 @@ function registerServerCommand(program, environment) {
55
71
  }
56
72
  program.error(`Server was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
57
73
  }
74
+ finally {
75
+ releaseReload();
76
+ }
58
77
  });
59
78
  }
60
79
  exports.registerServerCommand = registerServerCommand;
@@ -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() })
@@ -238,8 +255,22 @@ const kubernetesBackendSchema = zod_1.z
238
255
  .min(1)
239
256
  .default([{ name: 'default', platform: { os: 'linux', arch: 'amd64' }, nodeSelector: {}, tolerations: [] }]),
240
257
  // per secret provider, the image its command runs in: secrets are read in the
241
- // Job of the task, not by the server
242
- providers: zod_1.z.record(zod_1.z.object({ image: zod_1.z.string().min(1) }).strict()).default({}),
258
+ // Job of the task, not by the server. `allow` are the hosts its command reaches, such
259
+ // as secretmanager.googleapis.com: the egress proxy of a task with a limited network
260
+ // lets the init containers that read its secrets reach them, and nothing else does
261
+ providers: zod_1.z
262
+ .record(zod_1.z
263
+ .object({
264
+ image: zod_1.z.string().min(1),
265
+ allow: zod_1.z
266
+ .array(zod_1.z
267
+ .string()
268
+ .refine((host) => (0, network_schema_1.isAllowEntry)(host) && !host.includes(':'), 'expected a host name such as secretmanager.googleapis.com or *.example.com'))
269
+ .min(1)
270
+ .optional(),
271
+ })
272
+ .strict())
273
+ .default({}),
243
274
  // the ResourceQuota of every caller's namespace, such as `requests.cpu: '8'`
244
275
  quota: zod_1.z.record(zod_1.z.union([zod_1.z.string().min(1), zod_1.z.number()]).transform(String)).optional(),
245
276
  // what a container without `resources` requests (a LimitRange), so it is
@@ -264,6 +295,9 @@ const kubernetesBackendSchema = zod_1.z
264
295
  // build file's environment does: hammerkit can't tell from the API, and without it a
265
296
  // task with a limited network fails rather than runs open
266
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(),
267
301
  })
268
302
  .strict();
269
303
  // the most a task's claim may request on a server whose operator states no maxVolumeSize
@@ -309,6 +343,11 @@ exports.sandboxGrantSchema = zod_1.z
309
343
  .refine((branches) => Object.keys(branches).length > 0, 'name the branches a sandbox may start from'),
310
344
  })
311
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();
312
351
  exports.entitlementSchema = zod_1.z
313
352
  .object({
314
353
  ...whoShape,
@@ -318,19 +357,33 @@ exports.entitlementSchema = zod_1.z
318
357
  // the repositories (`host/path` patterns) the caller may run from a git ref
319
358
  repos: zod_1.z.array(repoPatternSchema).default([]),
320
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(),
321
363
  // the repositories the caller may keep sandboxes of, checkouts on the server that runs
322
364
  // work on: a list of its own, the `repos` above are for runs from a ref
323
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(),
324
368
  })
325
369
  .strict()
326
370
  .superRefine((entitlement, ctx) => {
327
371
  checkWho(entitlement, ctx);
328
372
  if (Object.keys(entitlement.accounts).length === 0 &&
329
373
  entitlement.builds === undefined &&
330
- !entitlement.sandboxes?.length) {
374
+ entitlement.checks === undefined &&
375
+ !entitlement.sandboxes?.length &&
376
+ !entitlement.endpoints?.length) {
377
+ ctx.addIssue({
378
+ code: zod_1.z.ZodIssueCode.custom,
379
+ message: 'name accounts to use or request, a builds level, checks, sandboxes or endpoints',
380
+ });
381
+ }
382
+ if (entitlement.checks && entitlement.repos.length === 0) {
331
383
  ctx.addIssue({
332
384
  code: zod_1.z.ZodIssueCode.custom,
333
- message: 'name accounts to use or request, a builds level or sandboxes',
385
+ path: ['repos'],
386
+ message: 'checks are read for repositories: name them in repos',
334
387
  });
335
388
  }
336
389
  });
@@ -451,6 +504,43 @@ exports.serverConfigSchema = zod_1.z
451
504
  // the most any task or service of a run reaches besides its needs: none, open
452
505
  // or { allow: [hosts] }. A version that asks for more is refused at publish.
453
506
  network: network_schema_1.networkSchema.default('open'),
507
+ // the most a task or service that reads a secret, or a variable of the server
508
+ // other than PATH, HOME and CI, reaches, under `network`: so a tool it runs can't
509
+ // send the value anywhere. Checked like `network`.
510
+ secretNetwork: network_schema_1.networkSchema.default('open'),
511
+ // which tasks take arguments: `any` but those that say `args: false`, or
512
+ // `declared`, only those that say `args: true`
513
+ args: zod_1.z.enum(['any', 'declared']).default('any'),
514
+ // runs of services (`services` in a run), kept up until their ttl ends
515
+ up: zod_1.z
516
+ .object({
517
+ // how long the services stay once ready, when the run names no ttl; then they are removed
518
+ ttl: duration.default('8h'),
519
+ // how long the services stay up without a request to an endpoint before they sleep,
520
+ // when the run names none: the next request wakes them. Unset, they never sleep.
521
+ sleepTtl: duration.optional(),
522
+ // the furthest a run, or an extend, sets the end from now
523
+ maxTtl: duration.default('72h'),
524
+ limits: zod_1.z
525
+ .object({
526
+ // service runs one caller has starting or up at a time; one more is 429
527
+ up: zod_1.z.number().int().min(1).default(3),
528
+ // service runs one caller has asleep, which take no place of `up`; a new run
529
+ // when there are this many is 429
530
+ asleep: zod_1.z.number().int().min(1).default(10),
531
+ })
532
+ .strict()
533
+ .default({}),
534
+ // service runs starting or up on the whole server; one more is 503
535
+ total: zod_1.z.number().int().min(1).default(20),
536
+ // on a Kubernetes backend, the ResourceQuota of the namespace of each service
537
+ // run; backend.quota when left out
538
+ quota: zod_1.z.record(zod_1.z.union([zod_1.z.string().min(1), zod_1.z.number()]).transform(String)).optional(),
539
+ })
540
+ .strict()
541
+ .default({}),
542
+ // how long a passed check stays for `remote verify` to find (see checks.ts)
543
+ checksKeep: duration.default('30d'),
454
544
  })
455
545
  .strict()
456
546
  .default({}),
@@ -475,6 +565,9 @@ exports.serverConfigSchema = zod_1.z
475
565
  .object({ allow: zod_1.z.array(zod_1.z.string().regex(/^[A-Za-z_][A-Za-z0-9_()]*$/)).default([]) })
476
566
  .strict()
477
567
  .default({}),
568
+ // the capabilities of `backend.capabilities.allow` a task of a run on a sandbox may
569
+ // add: none by default, a sandbox holds code nobody reviewed
570
+ capabilities: zod_1.z.array(zod_1.z.enum(capabilities_schema_1.LINUX_CAPABILITIES)).default([]),
478
571
  // what one caller may keep; a sandbox over it can't be created
479
572
  limits: zod_1.z
480
573
  .object({
@@ -488,6 +581,46 @@ exports.serverConfigSchema = zod_1.z
488
581
  })
489
582
  .strict()
490
583
  .default({}),
584
+ // ports of service runs published at hosts of their own below `domain`, behind the
585
+ // server's sign-in. Absent, a run naming endpoints is refused.
586
+ endpoints: zod_1.z
587
+ .object({
588
+ // `run.example.com`: an endpoint is https://<label>.run.example.com, all of them
589
+ // pointing at this server with one wildcard DNS record
590
+ domain: zod_1.z
591
+ .string()
592
+ .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'),
593
+ // a wildcard certificate for *.<domain>, served for the hosts of endpoints; left out
594
+ // behind a TLS proxy, which holds it
595
+ tls: zod_1.z
596
+ .object({ cert: zod_1.z.string().min(1), key: zod_1.z.string().min(1) })
597
+ .strict()
598
+ .optional(),
599
+ // how a person signs in with a browser: the authorization code flow of an issuer of
600
+ // `issuers`, with a client of its own; without it only tokens open endpoints
601
+ login: zod_1.z
602
+ .object({
603
+ issuer: zod_1.z.string().min(1),
604
+ clientId: zod_1.z.string().min(1),
605
+ // for providers whose web clients need one (Google): read as an account no
606
+ // entitlement may name, as the secret of a source is
607
+ clientSecret: zod_1.z
608
+ .object({
609
+ from: zod_1.z.string().regex(/^[A-Za-z][A-Za-z0-9_-]*:.+$/, 'expected provider:reference'),
610
+ account: accountNameSchema,
611
+ })
612
+ .strict()
613
+ .optional(),
614
+ scope: zod_1.z.string().min(1).default('openid'),
615
+ })
616
+ .strict()
617
+ .optional(),
618
+ // how long a sign-in at one endpoint lasts, at most until its run ends: the groups and
619
+ // claims of the sign-in hold that long, a change at the issuer counts at the next one
620
+ session: duration.default('1h'),
621
+ })
622
+ .strict()
623
+ .optional(),
491
624
  backend: zod_1.z.discriminatedUnion('type', [hostBackendSchema, kubernetesBackendSchema]).default({ type: 'host' }),
492
625
  })
493
626
  .strict()
@@ -555,7 +688,7 @@ exports.serverConfigSchema = zod_1.z
555
688
  });
556
689
  }
557
690
  });
558
- const readers = getSourceAccounts(config);
691
+ const readers = getServerAccounts(config);
559
692
  config.entitlements.forEach((entitlement, index) => {
560
693
  for (const account of Object.keys(entitlement.accounts)) {
561
694
  if (readers.includes(account)) {
@@ -567,6 +700,16 @@ exports.serverConfigSchema = zod_1.z
567
700
  }
568
701
  }
569
702
  });
703
+ const allowed = config.backend.capabilities?.allow ?? [];
704
+ config.sandboxes.capabilities.forEach((capability, index) => {
705
+ if (!allowed.includes(capability)) {
706
+ ctx.addIssue({
707
+ code: zod_1.z.ZodIssueCode.custom,
708
+ path: ['sandboxes', 'capabilities', index],
709
+ message: `the capability ${capability} is not in backend.capabilities.allow, no run may add it`,
710
+ });
711
+ }
712
+ });
570
713
  const sandboxNetwork = (0, work_network_1.parseWorkNetwork)(config.sandboxes.network);
571
714
  const runsNetwork = (0, work_network_1.parseWorkNetwork)(config.runs.network);
572
715
  if ((0, work_network_1.exceedsNetwork)(sandboxNetwork, runsNetwork)) {
@@ -586,7 +729,10 @@ exports.serverConfigSchema = zod_1.z
586
729
  message: `under runs.network, ${shared}`,
587
730
  });
588
731
  }
589
- if (config.sandboxes.runtimes?.includes('local') && config.sandboxes.network !== 'open') {
732
+ // on a cluster a local task runs in a pod of a pool's tool image, which can be limited
733
+ if (config.sandboxes.runtimes?.includes('local') &&
734
+ config.sandboxes.network !== 'open' &&
735
+ config.backend.type === 'host') {
590
736
  ctx.addIssue({
591
737
  code: zod_1.z.ZodIssueCode.custom,
592
738
  path: ['sandboxes', 'network'],
@@ -635,7 +781,7 @@ exports.serverConfigSchema = zod_1.z
635
781
  });
636
782
  // the secrets of tasks are read in their Job, with the image of the provider; the
637
783
  // ones of the sources are read by the server, which checks the code out itself
638
- const readers = getSourceAccounts(config);
784
+ const readers = getServerAccounts(config);
639
785
  for (const [name, account] of Object.entries(config.accounts)) {
640
786
  if (readers.includes(name)) {
641
787
  continue;
@@ -697,6 +843,13 @@ exports.serverConfigSchema = zod_1.z
697
843
  message: 'runs.network limits every task with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them',
698
844
  });
699
845
  }
846
+ if (config.runs.secretNetwork !== 'open' && backend.networkPolicy !== 'enforced') {
847
+ ctx.addIssue({
848
+ code: zod_1.z.ZodIssueCode.custom,
849
+ path: ['backend', 'networkPolicy'],
850
+ message: 'runs.secretNetwork limits the tasks that read secrets with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them',
851
+ });
852
+ }
700
853
  if (config.sandboxes.network !== 'open' &&
701
854
  backend.networkPolicy !== 'enforced' &&
702
855
  config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
@@ -707,13 +860,94 @@ exports.serverConfigSchema = zod_1.z
707
860
  });
708
861
  }
709
862
  }
710
- if (config.runs.network !== 'open' && config.runtimes.includes('local')) {
863
+ if (config.runs.network !== 'open' && config.runtimes.includes('local') && backend.type === 'host') {
711
864
  ctx.addIssue({
712
865
  code: zod_1.z.ZodIssueCode.custom,
713
866
  path: ['runs', 'network'],
714
867
  message: 'the network of a local task can not be limited, allow only the docker runtime with runs.network',
715
868
  });
716
869
  }
870
+ if ((0, units_1.parseDuration)(config.runs.up.ttl) > (0, units_1.parseDuration)(config.runs.up.maxTtl)) {
871
+ ctx.addIssue({
872
+ code: zod_1.z.ZodIssueCode.custom,
873
+ path: ['runs', 'up', 'ttl'],
874
+ message: `a ttl of ${config.runs.up.ttl} is more than maxTtl of ${config.runs.up.maxTtl}`,
875
+ });
876
+ }
877
+ const endpoints = config.endpoints;
878
+ if (endpoints) {
879
+ if (endpoints.tls && config.behindTlsProxy) {
880
+ ctx.addIssue({
881
+ code: zod_1.z.ZodIssueCode.custom,
882
+ path: ['endpoints', 'tls'],
883
+ message: 'behind a TLS proxy the proxy holds the certificate of the endpoints, leave out endpoints.tls',
884
+ });
885
+ }
886
+ if (endpoints.tls && !config.tls) {
887
+ ctx.addIssue({
888
+ code: zod_1.z.ZodIssueCode.custom,
889
+ path: ['endpoints', 'tls'],
890
+ message: 'the certificate of the endpoints is served next to the one of the server, set tls as well',
891
+ });
892
+ }
893
+ if (!endpoints.tls && !config.behindTlsProxy && (config.tls || !LOOPBACK_HOSTS.includes(config.listen.host))) {
894
+ ctx.addIssue({
895
+ code: zod_1.z.ZodIssueCode.custom,
896
+ path: ['endpoints', 'tls'],
897
+ message: `name the certificate of *.${endpoints.domain} in endpoints.tls, or set behindTlsProxy: true when a proxy in front holds it`,
898
+ });
899
+ }
900
+ const login = endpoints.login;
901
+ if (login && !config.issuers.some((issuer) => issuer.issuer === login.issuer)) {
902
+ ctx.addIssue({
903
+ code: zod_1.z.ZodIssueCode.custom,
904
+ path: ['endpoints', 'login', 'issuer'],
905
+ message: `issuer ${login.issuer} is not one of the accepted issuers`,
906
+ });
907
+ }
908
+ if (login?.clientSecret) {
909
+ const { from, account } = login.clientSecret;
910
+ const kind = from.substring(0, from.indexOf(':'));
911
+ const where = ['endpoints', 'login', 'clientSecret'];
912
+ if (!config.accounts[account]) {
913
+ ctx.addIssue({
914
+ code: zod_1.z.ZodIssueCode.custom,
915
+ path: [...where, 'account'],
916
+ message: `unknown account ${account}`,
917
+ });
918
+ }
919
+ else if (!config.accounts[account].providers[kind]) {
920
+ ctx.addIssue({
921
+ code: zod_1.z.ZodIssueCode.custom,
922
+ path: [...where, 'from'],
923
+ message: `account ${account} has no credentials for the provider ${kind}`,
924
+ });
925
+ }
926
+ if (!config.secretProviders[kind]) {
927
+ ctx.addIssue({
928
+ code: zod_1.z.ZodIssueCode.custom,
929
+ path: [...where, 'from'],
930
+ message: `secret provider ${kind} is not declared in secretProviders`,
931
+ });
932
+ }
933
+ }
934
+ // only the server is to reach the services behind endpoints, which the NetworkPolicy of
935
+ // a service run says: without it, any pod of the cluster would get past the sign-in
936
+ if (backend.type === 'kubernetes' && backend.networkPolicy !== 'enforced') {
937
+ ctx.addIssue({
938
+ code: zod_1.z.ZodIssueCode.custom,
939
+ path: ['backend', 'networkPolicy'],
940
+ 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',
941
+ });
942
+ }
943
+ if (backend.type === 'kubernetes' && backend.kubeconfig) {
944
+ ctx.addIssue({
945
+ code: zod_1.z.ZodIssueCode.custom,
946
+ path: ['endpoints'],
947
+ message: 'endpoints reach the services of runs at their Service in the cluster, run the server in the cluster rather than with a kubeconfig',
948
+ });
949
+ }
950
+ }
717
951
  if (!config.tls && !config.behindTlsProxy && !LOOPBACK_HOSTS.includes(config.listen.host)) {
718
952
  ctx.addIssue({
719
953
  code: zod_1.z.ZodIssueCode.custom,
@@ -729,11 +963,18 @@ function getSandboxRuntimes(config) {
729
963
  return config.sandboxes.runtimes ?? config.runtimes.filter((runtime) => runtime === 'docker');
730
964
  }
731
965
  exports.getSandboxRuntimes = getSandboxRuntimes;
732
- // The accounts that read repositories: no caller uses them and no build file names them.
733
- function getSourceAccounts(config) {
734
- return [...new Set(config.sources.flatMap((source) => (source.secret ? [source.secret.account] : [])))];
966
+ // The accounts only the server reads secrets as: those that read the repositories of
967
+ // `sources`, and the one of the client secret of `endpoints.login`. No caller uses them and
968
+ // no build file names them.
969
+ function getServerAccounts(config) {
970
+ return [
971
+ ...new Set([
972
+ ...config.sources.flatMap((source) => (source.secret ? [source.secret.account] : [])),
973
+ ...(config.endpoints?.login?.clientSecret ? [config.endpoints.login.clientSecret.account] : []),
974
+ ]),
975
+ ];
735
976
  }
736
- exports.getSourceAccounts = getSourceAccounts;
977
+ exports.getServerAccounts = getServerAccounts;
737
978
  async function readServerConfig(environment, path) {
738
979
  if (!(await environment.file.exists(path))) {
739
980
  throw new Error(`unable to read ${path}: no such file`);