hammerkit 1.25.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.
- package/build.schema.json +53 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/explain.js +13 -11
- package/dist/cli.js +3 -0
- package/dist/docker/item-network.js +471 -0
- package/dist/docker/pull.js +4 -2
- package/dist/docker/remove-orphaned-containers.js +13 -6
- package/dist/environment/replace-env-variables.js +8 -6
- package/dist/executer/await-completed-dependencies.js +2 -1
- package/dist/executer/check-readiness.js +2 -2
- package/dist/executer/container-task-state.js +8 -6
- package/dist/executer/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- package/dist/executer/execute-command.js +18 -5
- package/dist/executer/execute-docker.js +4 -1
- package/dist/executer/execution-steps.js +33 -7
- package/dist/executer/get-docker-executor.js +14 -7
- package/dist/executer/scheduler/enqueue-next.js +17 -3
- package/dist/executer/watch-loop.js +2 -2
- package/dist/executer/watch-state-key.js +1 -1
- package/dist/file/within-root.js +42 -0
- package/dist/harness/connect.js +99 -0
- package/dist/harness/format.js +73 -0
- package/dist/harness/mcp-command.js +59 -0
- package/dist/harness/mcp.js +432 -0
- package/dist/harness/opencode.js +492 -0
- package/dist/harness/pi.js +217 -0
- package/dist/harness/sandbox-client.js +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -0
- package/dist/network/port-relay.js +35 -0
- package/dist/optimizer/get-work-cache-stats.js +16 -0
- package/dist/optimizer/work-task-cache-description.js +3 -2
- package/dist/planner/service-definition.js +8 -2
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/utils/task-arguments.js +155 -0
- package/dist/planner/work-cache-id.js +15 -1
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +43 -0
- package/dist/planner/work-runtime-kubernetes.js +336 -102
- package/dist/planner/work-runtime-local.js +16 -8
- package/dist/planner/work-service.js +7 -3
- package/dist/planner/work-state-record.js +30 -0
- package/dist/program.js +80 -7
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- package/dist/remote/server-client.js +18 -5
- package/dist/remote/source-manifest.js +15 -2
- package/dist/remote/token-source.js +67 -0
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -0
- package/dist/schema/build-file-container-task-schema.js +3 -0
- package/dist/schema/build-file-environment-schema.js +9 -0
- package/dist/schema/network-schema.js +78 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +13 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +23 -0
- package/dist/secrets/github-app-token.js +191 -0
- package/dist/server/accounts-api.js +9 -2
- package/dist/server/definitions-api.js +69 -47
- package/dist/server/definitions.js +76 -51
- package/dist/server/executor.js +156 -41
- package/dist/server/git-checkout.js +72 -8
- package/dist/server/kubernetes-backend.js +554 -0
- package/dist/server/kubernetes-sandbox-storage.js +996 -0
- package/dist/server/oauth.js +1 -0
- package/dist/server/plan.js +51 -1
- package/dist/server/policy.js +104 -15
- package/dist/server/proposals-api.js +24 -40
- package/dist/server/proposals.js +12 -25
- package/dist/server/repo-access.js +37 -6
- package/dist/server/run-logs.js +217 -0
- package/dist/server/run-plan.js +51 -2
- package/dist/server/run-request.js +23 -10
- package/dist/server/run-store.js +5 -0
- package/dist/server/run-worker-command.js +13 -1
- package/dist/server/run-worker.js +121 -24
- package/dist/server/runs.js +184 -10
- package/dist/server/sandbox-docker.js +62 -0
- package/dist/server/sandbox-files-api.js +269 -0
- package/dist/server/sandbox-files.js +1054 -0
- package/dist/server/sandbox-patch.js +481 -0
- package/dist/server/sandbox-pod-command.js +15 -0
- package/dist/server/sandbox-pod.js +146 -0
- package/dist/server/sandbox-storage.js +462 -0
- package/dist/server/sandboxes-api.js +210 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-config.js +412 -22
- package/dist/server/server.js +204 -39
- package/dist/utils/run-id.js +12 -0
- package/harness/opencode.ts +11 -0
- package/harness/pi.ts +7 -0
- package/package.json +8 -2
- 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.
|
|
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
|
|
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 &&
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
//
|
|
252
|
-
|
|
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({
|
|
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] : [])))];
|