hammerkit 1.26.0 → 1.27.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 (117) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/docker-service.js +24 -13
  12. package/dist/executer/docker-task.js +18 -29
  13. package/dist/executer/event-cache.js +4 -0
  14. package/dist/executer/execute-command.js +18 -5
  15. package/dist/executer/execute-docker.js +4 -1
  16. package/dist/executer/execution-steps.js +33 -7
  17. package/dist/executer/get-docker-executor.js +14 -7
  18. package/dist/executer/scheduler/enqueue-next.js +17 -3
  19. package/dist/executer/watch-loop.js +2 -2
  20. package/dist/executer/watch-state-key.js +1 -1
  21. package/dist/file/within-root.js +42 -0
  22. package/dist/harness/connect.js +99 -0
  23. package/dist/harness/format.js +73 -0
  24. package/dist/harness/mcp-command.js +59 -0
  25. package/dist/harness/mcp.js +432 -0
  26. package/dist/harness/opencode.js +492 -0
  27. package/dist/harness/pi.js +217 -0
  28. package/dist/harness/sandbox-client.js +373 -0
  29. package/dist/index.js +33 -36
  30. package/dist/kubernetes/apply.js +31 -1
  31. package/dist/kubernetes/await-running-state.js +33 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  35. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  36. package/dist/kubernetes/get-service-ip.js +15 -1
  37. package/dist/kubernetes/job-placement.js +99 -0
  38. package/dist/kubernetes/job-secrets.js +306 -0
  39. package/dist/kubernetes/kept-claims.js +191 -0
  40. package/dist/kubernetes/kubernetes-instance.js +10 -2
  41. package/dist/kubernetes/network-gate.js +71 -0
  42. package/dist/kubernetes/network-policy.js +526 -0
  43. package/dist/kubernetes/resources.js +13 -2
  44. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  45. package/dist/kubernetes/secrets.js +2 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  47. package/dist/kubernetes/volumes.js +100 -11
  48. package/dist/main.js +41 -0
  49. package/dist/network/egress-proxy.js +368 -0
  50. package/dist/network/port-relay.js +35 -0
  51. package/dist/optimizer/get-work-cache-stats.js +16 -0
  52. package/dist/optimizer/work-task-cache-description.js +3 -2
  53. package/dist/planner/service-definition.js +5 -0
  54. package/dist/planner/utils/append-work-environment.js +2 -0
  55. package/dist/planner/utils/append-work-service.js +4 -0
  56. package/dist/planner/utils/append-work-task.js +17 -1
  57. package/dist/planner/utils/assert-item-paths.js +50 -0
  58. package/dist/planner/utils/hosted-volumes.js +35 -0
  59. package/dist/planner/work-network.js +193 -0
  60. package/dist/planner/work-runtime-docker.js +41 -0
  61. package/dist/planner/work-runtime-kubernetes.js +335 -102
  62. package/dist/program.js +21 -0
  63. package/dist/remote/annotations.js +103 -0
  64. package/dist/remote/bundle-text.js +24 -0
  65. package/dist/remote/bundle.js +9 -1
  66. package/dist/remote/remote-commands.js +4 -1
  67. package/dist/remote/remote-work-commands.js +182 -69
  68. package/dist/remote/server-client.js +18 -5
  69. package/dist/remote/source-manifest.js +15 -2
  70. package/dist/remote/token-source.js +67 -0
  71. package/dist/schema/build-file-base-task-schema.js +3 -0
  72. package/dist/schema/build-file-container-service-schema.js +6 -0
  73. package/dist/schema/build-file-container-task-schema.js +3 -0
  74. package/dist/schema/build-file-environment-schema.js +9 -0
  75. package/dist/schema/network-schema.js +78 -0
  76. package/dist/schema/reference-parser.js +1 -0
  77. package/dist/schema/schema-parser.js +16 -3
  78. package/dist/schema/work-scope-parser.js +5 -0
  79. package/dist/secrets/command-secret-provider.js +73 -10
  80. package/dist/secrets/github-app-token-command.js +23 -0
  81. package/dist/secrets/github-app-token.js +191 -0
  82. package/dist/server/accounts-api.js +9 -2
  83. package/dist/server/definitions-api.js +69 -47
  84. package/dist/server/definitions.js +76 -51
  85. package/dist/server/executor.js +156 -41
  86. package/dist/server/git-checkout.js +72 -8
  87. package/dist/server/kubernetes-backend.js +554 -0
  88. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  89. package/dist/server/oauth.js +1 -0
  90. package/dist/server/plan.js +51 -1
  91. package/dist/server/policy.js +104 -15
  92. package/dist/server/proposals-api.js +24 -40
  93. package/dist/server/proposals.js +12 -25
  94. package/dist/server/repo-access.js +37 -6
  95. package/dist/server/run-logs.js +217 -0
  96. package/dist/server/run-plan.js +51 -2
  97. package/dist/server/run-request.js +23 -10
  98. package/dist/server/run-store.js +5 -0
  99. package/dist/server/run-worker-command.js +13 -1
  100. package/dist/server/run-worker.js +121 -24
  101. package/dist/server/runs.js +184 -10
  102. package/dist/server/sandbox-docker.js +62 -0
  103. package/dist/server/sandbox-files-api.js +269 -0
  104. package/dist/server/sandbox-files.js +1054 -0
  105. package/dist/server/sandbox-patch.js +481 -0
  106. package/dist/server/sandbox-pod-command.js +15 -0
  107. package/dist/server/sandbox-pod.js +146 -0
  108. package/dist/server/sandbox-storage.js +462 -0
  109. package/dist/server/sandboxes-api.js +210 -0
  110. package/dist/server/sandboxes.js +519 -0
  111. package/dist/server/server-config.js +412 -22
  112. package/dist/server/server.js +204 -39
  113. package/dist/utils/run-id.js +12 -0
  114. package/harness/opencode.ts +11 -0
  115. package/harness/pi.ts +7 -0
  116. package/package.json +8 -2
  117. package/dist/remote/text-diff.js +0 -89
@@ -1,12 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.detectPlatform = exports.getSecretCatalog = exports.readServerConfig = exports.getSourceAccounts = exports.serverConfigSchema = exports.sourceSchema = exports.entitlementSchema = exports.buildsLevelSchema = exports.accountLevelSchema = exports.accountSchema = exports.definitionName = exports.issuerSchema = exports.serverPlatformSchema = void 0;
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;
4
+ const net_1 = require("net");
4
5
  const zod_1 = require("zod");
5
6
  const yaml_1 = require("yaml");
6
7
  const log_1 = require("../log");
7
8
  const units_1 = require("../utils/units");
9
+ const version_1 = require("../version");
10
+ const network_schema_1 = require("../schema/network-schema");
8
11
  const build_file_secret_provider_schema_1 = require("../schema/build-file-secret-provider-schema");
9
12
  const repo_1 = require("./repo");
13
+ const work_network_1 = require("../planner/work-network");
10
14
  const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
11
15
  exports.serverPlatformSchema = zod_1.z
12
16
  .object({
@@ -40,10 +44,6 @@ exports.issuerSchema = zod_1.z
40
44
  scope: zod_1.z.string().min(1).default('openid'),
41
45
  })
42
46
  .strict();
43
- // What a published build file is called: the project, as a build file author names it.
44
- exports.definitionName = zod_1.z
45
- .string()
46
- .regex(/^[a-z0-9][a-z0-9._-]{0,63}$/i, 'use letters, digits, ".", "_" and "-", starting with a letter or digit');
47
47
  const subjectSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), sub: zod_1.z.string().min(1) }).strict();
48
48
  const groupSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), name: zod_1.z.string().min(1) }).strict();
49
49
  const claimsSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), match: zod_1.z.record(zod_1.z.string()) }).strict();
@@ -70,20 +70,42 @@ function checkWho(who, ctx) {
70
70
  const accountNameSchema = zod_1.z
71
71
  .string()
72
72
  .regex(/^[A-Za-z][A-Za-z0-9_-]*$/, 'a name starts with a letter and holds letters, digits, - and _');
73
+ const kubernetesNameSchema = zod_1.z
74
+ .string()
75
+ .regex(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, 'a Kubernetes name: lower case letters, digits and -');
73
76
  exports.accountSchema = zod_1.z
74
77
  .object({
75
78
  description: zod_1.z.string().min(1).max(200).optional(),
76
79
  // per secret provider, the environment that makes its command act as this
77
80
  // account; the credentials are files and variables of the operator's machine
78
81
  providers: build_file_secret_provider_schema_1.buildFileSecretAccountSchema.shape.providers.default({}),
79
- // where the code of a run using this account may come from; both when left out
82
+ // where the code of a run using this account may come from: `upload` and `ref` when
83
+ // left out; a sandbox, which holds unreviewed changes, only when it is listed
80
84
  source: zod_1.z
81
- .array(zod_1.z.enum(['upload', 'ref']))
85
+ .array(zod_1.z.enum(['upload', 'ref', 'sandbox']))
82
86
  .min(1)
83
87
  .optional(),
84
88
  // the refs (branches or tags, `release/*` for a level) a run using this account
85
89
  // may be started from; a commit is not accepted then, it names no branch
86
90
  refs: zod_1.z.array(zod_1.z.string().min(1).max(200)).min(1).optional(),
91
+ // On a Kubernetes backend: the credential the commands of the account's secrets
92
+ // get in the Job of a task, under /var/run/hammerkit/account. The server holds
93
+ // none of the secret manager's credentials.
94
+ kubernetes: zod_1.z
95
+ .object({
96
+ // a ServiceAccount of the server's namespace: a token of it (an hour, made for
97
+ // each Job, for `audience`) is put at /var/run/hammerkit/account/token, for a cloud that
98
+ // trusts the cluster's issuer (workload identity federation)
99
+ serviceAccount: kubernetesNameSchema.optional(),
100
+ audience: zod_1.z.string().min(1).optional(),
101
+ // a Secret of the server's namespace, each key a file of the directory, such
102
+ // as a key file or the federation config that reads the token
103
+ secret: kubernetesNameSchema.optional(),
104
+ })
105
+ .strict()
106
+ .refine((value) => value.serviceAccount || value.secret, 'name a serviceAccount, a secret or both')
107
+ .refine((value) => !value.audience || value.serviceAccount, 'audience is the one of the serviceAccount token')
108
+ .optional(),
87
109
  })
88
110
  .strict()
89
111
  .superRefine((account, ctx) => {
@@ -95,11 +117,198 @@ exports.accountSchema = zod_1.z
95
117
  });
96
118
  }
97
119
  });
120
+ // The tasks of runs execute on this machine, with its docker daemon and local tools.
121
+ const hostBackendSchema = zod_1.z
122
+ .object({
123
+ type: zod_1.z.literal('host'),
124
+ // what this machine offers; detected when left out
125
+ platform: exports.serverPlatformSchema.optional(),
126
+ })
127
+ .strict();
128
+ const tolerationSchema = zod_1.z
129
+ .object({
130
+ key: zod_1.z.string().min(1).optional(),
131
+ operator: zod_1.z.enum(['Exists', 'Equal']).optional(),
132
+ value: zod_1.z.string().optional(),
133
+ effect: zod_1.z.enum(['NoSchedule', 'PreferNoSchedule', 'NoExecute']).optional(),
134
+ tolerationSeconds: zod_1.z.number().int().min(0).optional(),
135
+ })
136
+ .strict();
137
+ // an IPv4 or IPv6 range, such as 10.0.0.0/8
138
+ const cidrSchema = zod_1.z.string().refine((value) => {
139
+ const [address, prefix, ...rest] = value.split('/');
140
+ const family = (0, net_1.isIP)(address);
141
+ return (rest.length === 0 && family !== 0 && /^\d{1,3}$/.test(prefix ?? '') && Number(prefix) <= (family === 4 ? 32 : 128));
142
+ }, 'expected an address range, such as 10.0.0.0/8 or fd00::/8');
143
+ const quantitiesSchema = zod_1.z.object({ cpu: zod_1.z.string().min(1).optional(), memory: zod_1.z.string().min(1).optional() }).strict();
144
+ // Nodes the tasks of runs are placed on. Container tasks go to the first pool; a
145
+ // local task runs in the `toolImage` of the pool that offers the platform it declares.
146
+ exports.kubernetesPoolSchema = zod_1.z
147
+ .object({
148
+ name: kubernetesNameSchema,
149
+ // Linux only: macOS is not a node platform, Windows nodes are not supported yet
150
+ platform: zod_1.z
151
+ .object({ os: zod_1.z.literal('linux'), arch: zod_1.z.enum(['amd64', 'arm64']) })
152
+ .strict()
153
+ .default({ os: 'linux', arch: 'amd64' }),
154
+ // labels the nodes of the pool carry, besides kubernetes.io/os and kubernetes.io/arch
155
+ nodeSelector: zod_1.z.record(zod_1.z.string()).default({}),
156
+ tolerations: zod_1.z.array(tolerationSchema).default([]),
157
+ // the image local tasks run in on this pool
158
+ toolImage: zod_1.z.string().min(1).optional(),
159
+ })
160
+ .strict();
161
+ // The tasks of runs execute as Jobs on a cluster, in a namespace per caller.
162
+ const duration = zod_1.z.string().refine((value) => {
163
+ try {
164
+ return (0, units_1.parseDuration)(value) > 0;
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ }, 'expected a duration like 30m, 1h or 1d');
170
+ const size = zod_1.z.string().refine((value) => {
171
+ try {
172
+ return (0, units_1.parseSize)(value) > 0;
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ }, 'expected a size like 500Mi, 20Gi or 1Ti');
178
+ // Where a cluster keeps sandboxes: a claim per sandbox in its caller's namespace
179
+ const kubernetesSandboxesSchema = zod_1.z
180
+ .object({
181
+ // the image of the short pods that fetch, read and change the files of a sandbox:
182
+ // hammerkit with git and ripgrep, as the image of this version is
183
+ image: zod_1.z.string().min(1).default(`no0dles/hammerkit:${(0, version_1.getVersion)()}`),
184
+ // what each of the two claims of a sandbox requests: one holds its files, which tasks
185
+ // mount, the other its git data, which only the pod of its files mounts
186
+ size: size.default('4Gi'),
187
+ // of the claims of sandboxes; the class of tasks' claims, else the cluster's default,
188
+ // when left out. With a class of their own, a ResourceQuota on that class in each
189
+ // caller's namespace holds `sandboxes.limits`.
190
+ storageClass: zod_1.z.string().min(1).optional(),
191
+ // how long the pod of a sandbox's files stays after the last request
192
+ idle: duration.default('1m'),
193
+ // of the pod of a sandbox's files, which runs git and ripgrep over all of them
194
+ resources: zod_1.z
195
+ .object({ requests: quantitiesSchema.optional(), limits: quantitiesSchema.optional() })
196
+ .strict()
197
+ .default({ requests: { cpu: '100m', memory: '256Mi' }, limits: { memory: '2Gi' } }),
198
+ })
199
+ .strict()
200
+ .default({});
201
+ exports.DEFAULT_DENIED_EGRESS = [
202
+ '169.254.0.0/16',
203
+ '10.0.0.0/8',
204
+ '172.16.0.0/12',
205
+ '192.168.0.0/16',
206
+ '100.64.0.0/10',
207
+ 'fe80::/10',
208
+ 'fc00::/7',
209
+ ];
210
+ const kubernetesBackendSchema = zod_1.z
211
+ .object({
212
+ type: zod_1.z.literal('kubernetes'),
213
+ // the cluster: a context of a kubeconfig, or the cluster the server runs in when
214
+ // both are left out
215
+ kubeconfig: zod_1.z.string().min(1).optional(),
216
+ context: zod_1.z.string().min(1).optional(),
217
+ // the server's own namespace, where the Secrets and ServiceAccounts of the accounts
218
+ // are; the namespace of the server's pod when left out
219
+ namespace: kubernetesNameSchema.optional(),
220
+ // a caller's namespace is this and a hash of their identity, so two servers on
221
+ // one cluster keep their callers apart with a prefix each
222
+ namespacePrefix: zod_1.z
223
+ .string()
224
+ .regex(/^[a-z0-9][a-z0-9-]{0,30}$/, 'lower case letters, digits and -, at most 31')
225
+ .default('hammerkit-'),
226
+ // of the claims tasks get; the cluster's default when left out
227
+ storageClass: zod_1.z.string().min(1).optional(),
228
+ // what the claim of a task or service requests when it states no `volumeSize`, and
229
+ // the most one may state (see getMaxVolumeSize when left out)
230
+ volumeSize: size.default('1Gi'),
231
+ maxVolumeSize: size.optional(),
232
+ // how long the claim of a task stays after the run that mounted it last, for the next
233
+ // run of the same source to mount its outputs as they are; false removes it at the end
234
+ // of the run
235
+ keepVolumes: zod_1.z.union([duration, zod_1.z.literal(false)]).default('15m'),
236
+ pools: zod_1.z
237
+ .array(exports.kubernetesPoolSchema)
238
+ .min(1)
239
+ .default([{ name: 'default', platform: { os: 'linux', arch: 'amd64' }, nodeSelector: {}, tolerations: [] }]),
240
+ // 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({}),
243
+ // the ResourceQuota of every caller's namespace, such as `requests.cpu: '8'`
244
+ quota: zod_1.z.record(zod_1.z.union([zod_1.z.string().min(1), zod_1.z.number()]).transform(String)).optional(),
245
+ // what a container without `resources` requests (a LimitRange), so it is
246
+ // admitted under a quota that limits compute
247
+ defaultResources: zod_1.z
248
+ .object({ requests: quantitiesSchema.optional(), limits: quantitiesSchema.optional() })
249
+ .strict()
250
+ .optional(),
251
+ sandboxes: kubernetesSandboxesSchema,
252
+ // the ClusterRole the server binds to itself in each caller's namespace, so it needs
253
+ // no rights in the namespaces of others (see the docs on RBAC)
254
+ runRole: kubernetesNameSchema.default('hammerkit-runs'),
255
+ // where the pods of runs with an open network may not connect to: by default the
256
+ // link-local ranges with the metadata endpoints of the clouds (and the node's identity)
257
+ // and the private ones, where the cluster's nodes, pods and services and the networks
258
+ // behind it are, so `open` means the internet
259
+ deniedEgress: zod_1.z.array(cidrSchema).default(exports.DEFAULT_DENIED_EGRESS),
260
+ // the addresses in deniedEgress they may reach anyway, such as an internal registry
261
+ // or a database a TCP forward of `allow` goes to
262
+ allowedEgress: zod_1.z.array(cidrSchema).default([]),
263
+ // the operator states that the cluster's network plugin enforces NetworkPolicy, as a
264
+ // build file's environment does: hammerkit can't tell from the API, and without it a
265
+ // task with a limited network fails rather than runs open
266
+ networkPolicy: zod_1.z.literal('enforced').optional(),
267
+ })
268
+ .strict();
269
+ // the most a task's claim may request on a server whose operator states no maxVolumeSize
270
+ exports.DEFAULT_MAX_VOLUME_SIZE = '10Gi';
271
+ // The most a task's or service's claim may request: the operator's maxVolumeSize, else
272
+ // DEFAULT_MAX_VOLUME_SIZE or the backend's volumeSize when that is more. A build file
273
+ // states its volumeSize, so without a bound any caller could make the operator pay for a
274
+ // disk of any size.
275
+ function getMaxVolumeSize(backend) {
276
+ if (backend.maxVolumeSize) {
277
+ return backend.maxVolumeSize;
278
+ }
279
+ return (0, units_1.parseSize)(backend.volumeSize) > (0, units_1.parseSize)(exports.DEFAULT_MAX_VOLUME_SIZE)
280
+ ? backend.volumeSize
281
+ : exports.DEFAULT_MAX_VOLUME_SIZE;
282
+ }
283
+ exports.getMaxVolumeSize = getMaxVolumeSize;
98
284
  exports.accountLevelSchema = zod_1.z.enum(['use', 'request']);
99
285
  // What a caller may do with the build files: run what is published, propose new
100
286
  // versions that someone else approves, or publish. Each level includes the lower.
101
287
  exports.buildsLevelSchema = zod_1.z.enum(['run', 'propose', 'publish']);
102
288
  const repoPatternSchema = zod_1.z.string().refine(repo_1.isRepoPattern, 'expected host/path, such as github.com/corp/*');
289
+ // A branch of a sandbox grant, as written: `agent/**`, `agent/*` (one level) or a name.
290
+ const branchPatternSchema = zod_1.z
291
+ .string()
292
+ .min(1)
293
+ .max(200)
294
+ .refine((value) => /^[A-Za-z0-9._*/-]+$/.test(value) &&
295
+ !value.startsWith('-') &&
296
+ !value.startsWith('/') &&
297
+ !value.endsWith('/') &&
298
+ !value.includes('//') &&
299
+ !value.includes('..'), 'expected a branch or a pattern of branches, such as agent/**');
300
+ // `pull`: a sandbox may start from (and fetch) a matching branch; `push` also pushes to it.
301
+ exports.sandboxBranchTypeSchema = zod_1.z.enum(['pull', 'push']);
302
+ // One repository (`host/path` pattern) an entitlement may keep sandboxes of, with a type
303
+ // per branch pattern. Its branches count for this repository only.
304
+ exports.sandboxGrantSchema = zod_1.z
305
+ .object({
306
+ repo: repoPatternSchema,
307
+ branches: zod_1.z
308
+ .record(branchPatternSchema, exports.sandboxBranchTypeSchema)
309
+ .refine((branches) => Object.keys(branches).length > 0, 'name the branches a sandbox may start from'),
310
+ })
311
+ .strict();
103
312
  exports.entitlementSchema = zod_1.z
104
313
  .object({
105
314
  ...whoShape,
@@ -109,14 +318,19 @@ exports.entitlementSchema = zod_1.z
109
318
  // the repositories (`host/path` patterns) the caller may run from a git ref
110
319
  repos: zod_1.z.array(repoPatternSchema).default([]),
111
320
  builds: exports.buildsLevelSchema.optional(),
321
+ // the repositories the caller may keep sandboxes of, checkouts on the server that runs
322
+ // work on: a list of its own, the `repos` above are for runs from a ref
323
+ sandboxes: zod_1.z.array(exports.sandboxGrantSchema).optional(),
112
324
  })
113
325
  .strict()
114
326
  .superRefine((entitlement, ctx) => {
115
327
  checkWho(entitlement, ctx);
116
- if (Object.keys(entitlement.accounts).length === 0 && entitlement.builds === undefined) {
328
+ if (Object.keys(entitlement.accounts).length === 0 &&
329
+ entitlement.builds === undefined &&
330
+ !entitlement.sandboxes?.length) {
117
331
  ctx.addIssue({
118
332
  code: zod_1.z.ZodIssueCode.custom,
119
- message: 'name accounts to use or request, or a builds level',
333
+ message: 'name accounts to use or request, a builds level or sandboxes',
120
334
  });
121
335
  }
122
336
  });
@@ -137,14 +351,6 @@ exports.sourceSchema = zod_1.z
137
351
  .optional(),
138
352
  })
139
353
  .strict();
140
- const duration = zod_1.z.string().refine((value) => {
141
- try {
142
- return (0, units_1.parseDuration)(value) > 0;
143
- }
144
- catch {
145
- return false;
146
- }
147
- }, 'expected a duration like 30m, 1h or 1d');
148
354
  // The server config is written by an operator and reviewed like code, so a typo
149
355
  // is an error and not a silently ignored key.
150
356
  exports.serverConfigSchema = zod_1.z
@@ -242,17 +448,47 @@ exports.serverConfigSchema = zod_1.z
242
448
  })
243
449
  .strict()
244
450
  .default({}),
451
+ // the most any task or service of a run reaches besides its needs: none, open
452
+ // or { allow: [hosts] }. A version that asks for more is refused at publish.
453
+ network: network_schema_1.networkSchema.default('open'),
245
454
  })
246
455
  .strict()
247
456
  .default({}),
248
- backend: zod_1.z
457
+ // checkouts of callers on the server that runs work on (the `sandboxes` of an
458
+ // entitlement). They stay until they are deleted, within these limits.
459
+ sandboxes: zod_1.z
249
460
  .object({
250
- type: zod_1.z.literal('host'),
251
- // what this machine offers; detected when left out
252
- platform: exports.serverPlatformSchema.optional(),
461
+ // what the tasks of a run on a sandbox may run on, out of `runtimes`. A local task
462
+ // would run as the user of the server and could read every other sandbox, so
463
+ // `local` has to be named here too. Left out, `docker` when the server runs
464
+ // containers, see getSandboxRuntimes
465
+ runtimes: zod_1.z
466
+ .array(zod_1.z.enum(['docker', 'local']))
467
+ .min(1)
468
+ .optional(),
469
+ // the most open network a task or service of a run on a sandbox gets, written as
470
+ // a task's `network`: only the services it needs by default
471
+ network: network_schema_1.networkSchema.default('none'),
472
+ // the variables of `runs.env.allow` an `env:` secret of a run on a sandbox may
473
+ // read: none by default, a sandbox holds code nobody reviewed
474
+ env: zod_1.z
475
+ .object({ allow: zod_1.z.array(zod_1.z.string().regex(/^[A-Za-z_][A-Za-z0-9_()]*$/)).default([]) })
476
+ .strict()
477
+ .default({}),
478
+ // what one caller may keep; a sandbox over it can't be created
479
+ limits: zod_1.z
480
+ .object({
481
+ count: zod_1.z.number().int().min(1).default(5),
482
+ bytes: size.default('20Gi'),
483
+ })
484
+ .strict()
485
+ .default({}),
486
+ // what all sandboxes together may take
487
+ total: size.default('500Gi'),
253
488
  })
254
489
  .strict()
255
- .default({ type: 'host' }),
490
+ .default({}),
491
+ backend: zod_1.z.discriminatedUnion('type', [hostBackendSchema, kubernetesBackendSchema]).default({ type: 'host' }),
256
492
  })
257
493
  .strict()
258
494
  .superRefine((config, ctx) => {
@@ -331,6 +567,153 @@ exports.serverConfigSchema = zod_1.z
331
567
  }
332
568
  }
333
569
  });
570
+ const sandboxNetwork = (0, work_network_1.parseWorkNetwork)(config.sandboxes.network);
571
+ const runsNetwork = (0, work_network_1.parseWorkNetwork)(config.runs.network);
572
+ if ((0, work_network_1.exceedsNetwork)(sandboxNetwork, runsNetwork)) {
573
+ ctx.addIssue({
574
+ code: zod_1.z.ZodIssueCode.custom,
575
+ path: ['sandboxes', 'network'],
576
+ message: `runs on sandboxes get at most runs.network (${(0, work_network_1.formatNetwork)(runsNetwork)}), not ${(0, work_network_1.formatNetwork)(sandboxNetwork)}`,
577
+ });
578
+ }
579
+ // what a sandbox run gets, the lower of the two: their TCP forwards together need a port each
580
+ const sandboxCeiling = (0, work_network_1.limitNetwork)(sandboxNetwork, runsNetwork);
581
+ const shared = sandboxCeiling.type === 'allow' ? (0, network_schema_1.getAllowListError)(sandboxCeiling.hosts) : null;
582
+ if (shared) {
583
+ ctx.addIssue({
584
+ code: zod_1.z.ZodIssueCode.custom,
585
+ path: ['sandboxes', 'network'],
586
+ message: `under runs.network, ${shared}`,
587
+ });
588
+ }
589
+ if (config.sandboxes.runtimes?.includes('local') && config.sandboxes.network !== 'open') {
590
+ ctx.addIssue({
591
+ code: zod_1.z.ZodIssueCode.custom,
592
+ path: ['sandboxes', 'network'],
593
+ message: 'hammerkit can not limit the network of a local task, set network: open to allow local in sandboxes',
594
+ });
595
+ }
596
+ config.sandboxes.env.allow.forEach((name, index) => {
597
+ if (!config.runs.env.allow.includes(name)) {
598
+ ctx.addIssue({
599
+ code: zod_1.z.ZodIssueCode.custom,
600
+ path: ['sandboxes', 'env', 'allow', index],
601
+ message: `${name} is not in runs.env.allow, no run can read it`,
602
+ });
603
+ }
604
+ });
605
+ config.sandboxes.runtimes?.forEach((runtime, index) => {
606
+ if (!config.runtimes.includes(runtime)) {
607
+ ctx.addIssue({
608
+ code: zod_1.z.ZodIssueCode.custom,
609
+ path: ['sandboxes', 'runtimes', index],
610
+ message: `the runtime ${runtime} is not in runtimes, the server does not run on it at all`,
611
+ });
612
+ }
613
+ });
614
+ const backend = config.backend;
615
+ for (const [name, account] of Object.entries(config.accounts)) {
616
+ if (account.kubernetes && backend.type !== 'kubernetes') {
617
+ ctx.addIssue({
618
+ code: zod_1.z.ZodIssueCode.custom,
619
+ path: ['accounts', name, 'kubernetes'],
620
+ message: 'kubernetes credentials are for backend.type: kubernetes',
621
+ });
622
+ }
623
+ }
624
+ if (backend.type === 'kubernetes') {
625
+ const pools = new Set();
626
+ backend.pools.forEach((pool, index) => {
627
+ if (pools.has(pool.name)) {
628
+ ctx.addIssue({
629
+ code: zod_1.z.ZodIssueCode.custom,
630
+ path: ['backend', 'pools', index, 'name'],
631
+ message: `the pool ${pool.name} is named twice`,
632
+ });
633
+ }
634
+ pools.add(pool.name);
635
+ });
636
+ // the secrets of tasks are read in their Job, with the image of the provider; the
637
+ // ones of the sources are read by the server, which checks the code out itself
638
+ const readers = getSourceAccounts(config);
639
+ for (const [name, account] of Object.entries(config.accounts)) {
640
+ if (readers.includes(name)) {
641
+ continue;
642
+ }
643
+ // the commands run as the account's own identity, never the one of the node
644
+ // or of the provider's image, which every account would share
645
+ if (Object.keys(account.providers).length > 0 && !account.kubernetes) {
646
+ ctx.addIssue({
647
+ code: zod_1.z.ZodIssueCode.custom,
648
+ path: ['accounts', name, 'kubernetes'],
649
+ message: `the account ${name} reads secrets in the Jobs of tasks, name the serviceAccount or secret it reads them as`,
650
+ });
651
+ }
652
+ for (const provider of Object.keys(account.providers)) {
653
+ if (!backend.providers[provider]) {
654
+ ctx.addIssue({
655
+ code: zod_1.z.ZodIssueCode.custom,
656
+ path: ['backend', 'providers', provider],
657
+ message: `the account ${name} reads ${provider} in the Jobs of tasks, name the image to run it in`,
658
+ });
659
+ }
660
+ }
661
+ }
662
+ if (backend.maxVolumeSize && (0, units_1.parseSize)(backend.volumeSize) > (0, units_1.parseSize)(backend.maxVolumeSize)) {
663
+ ctx.addIssue({
664
+ code: zod_1.z.ZodIssueCode.custom,
665
+ path: ['backend', 'volumeSize'],
666
+ message: `a claim of ${backend.volumeSize} is more than maxVolumeSize of ${backend.maxVolumeSize}`,
667
+ });
668
+ }
669
+ const sandboxes = backend.sandboxes;
670
+ if (sandboxes.storageClass && sandboxes.storageClass === backend.storageClass) {
671
+ // the quota on the class would count the claims of tasks too
672
+ ctx.addIssue({
673
+ code: zod_1.z.ZodIssueCode.custom,
674
+ path: ['backend', 'sandboxes', 'storageClass'],
675
+ message: 'give sandboxes a storage class of their own, or leave it out to use the one of tasks',
676
+ });
677
+ }
678
+ if ((0, units_1.parseSize)(sandboxes.size) > (0, units_1.parseSize)(config.sandboxes.limits.bytes)) {
679
+ ctx.addIssue({
680
+ code: zod_1.z.ZodIssueCode.custom,
681
+ path: ['backend', 'sandboxes', 'size'],
682
+ message: `a sandbox of ${sandboxes.size} is more than sandboxes.limits.bytes of ${config.sandboxes.limits.bytes}`,
683
+ });
684
+ }
685
+ if (config.runtimes.includes('local') && !backend.pools.some((pool) => pool.toolImage)) {
686
+ ctx.addIssue({
687
+ code: zod_1.z.ZodIssueCode.custom,
688
+ path: ['backend', 'pools'],
689
+ message: 'local tasks run in the toolImage of a pool, give a pool one or remove local from runtimes',
690
+ });
691
+ }
692
+ // a limited network is a NetworkPolicy there, which only some network plugins enforce
693
+ if (config.runs.network !== 'open' && backend.networkPolicy !== 'enforced') {
694
+ ctx.addIssue({
695
+ code: zod_1.z.ZodIssueCode.custom,
696
+ path: ['backend', 'networkPolicy'],
697
+ message: 'runs.network limits every task with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them',
698
+ });
699
+ }
700
+ if (config.sandboxes.network !== 'open' &&
701
+ backend.networkPolicy !== 'enforced' &&
702
+ config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
703
+ ctx.addIssue({
704
+ code: zod_1.z.ZodIssueCode.custom,
705
+ path: ['backend', 'networkPolicy'],
706
+ message: 'sandboxes.network limits the tasks of sandboxes with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them, or sandboxes.network: open',
707
+ });
708
+ }
709
+ }
710
+ if (config.runs.network !== 'open' && config.runtimes.includes('local')) {
711
+ ctx.addIssue({
712
+ code: zod_1.z.ZodIssueCode.custom,
713
+ path: ['runs', 'network'],
714
+ message: 'the network of a local task can not be limited, allow only the docker runtime with runs.network',
715
+ });
716
+ }
334
717
  if (!config.tls && !config.behindTlsProxy && !LOOPBACK_HOSTS.includes(config.listen.host)) {
335
718
  ctx.addIssue({
336
719
  code: zod_1.z.ZodIssueCode.custom,
@@ -339,6 +722,13 @@ exports.serverConfigSchema = zod_1.z
339
722
  });
340
723
  }
341
724
  });
725
+ // What the tasks of a run on a sandbox may run on: `sandboxes.runtimes`, else containers
726
+ // when the server runs them. Empty on a server with only `runtimes: [local]`, which then
727
+ // runs nothing on sandboxes.
728
+ function getSandboxRuntimes(config) {
729
+ return config.sandboxes.runtimes ?? config.runtimes.filter((runtime) => runtime === 'docker');
730
+ }
731
+ exports.getSandboxRuntimes = getSandboxRuntimes;
342
732
  // The accounts that read repositories: no caller uses them and no build file names them.
343
733
  function getSourceAccounts(config) {
344
734
  return [...new Set(config.sources.flatMap((source) => (source.secret ? [source.secret.account] : [])))];