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.
- package/build.schema.json +188 -0
- package/dist/cache/backends/local-cache-backend.js +6 -0
- package/dist/cache/backends/registry-cache-backend.js +2 -2
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +2 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/deferred-restore.js +36 -0
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/docker-task.js +21 -1
- package/dist/executer/event-cache.js +20 -2
- package/dist/executer/execute-work-service.js +2 -0
- package/dist/executer/execute-work-task.js +10 -3
- package/dist/executer/execution-steps.js +25 -4
- package/dist/executer/reset-work-tree.js +4 -0
- package/dist/executer/scheduler/enqueue-next.js +57 -6
- package/dist/executer/scheduler/state-key.js +42 -5
- package/dist/executer/watch-loop.js +4 -1
- package/dist/harness/connect.js +20 -6
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +63 -114
- package/dist/harness/opencode.js +158 -50
- package/dist/harness/pi.js +29 -0
- package/dist/harness/sandbox-client.js +168 -17
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +27 -16
- package/dist/kubernetes/await-running-state.js +59 -13
- package/dist/kubernetes/claim-layout.js +71 -0
- package/dist/kubernetes/cluster-state.js +245 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +35 -12
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
- package/dist/kubernetes/ensure-persistent-data.js +30 -20
- package/dist/kubernetes/follow-pod-logs.js +182 -0
- package/dist/kubernetes/get-service-ip.js +2 -2
- package/dist/kubernetes/job-placement.js +36 -12
- package/dist/kubernetes/job-secrets.js +33 -19
- package/dist/kubernetes/kept-claims.js +22 -10
- package/dist/kubernetes/network-policy.js +85 -27
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/resources.js +25 -3
- package/dist/kubernetes/restore-kubernetes-data.js +9 -3
- package/dist/kubernetes/sandbox-runner.js +628 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/volumes.js +6 -3
- package/dist/main.js +2 -0
- package/dist/network/egress-proxy.js +45 -6
- package/dist/optimizer/calculate-checksum.js +3 -2
- package/dist/optimizer/check-key.js +94 -0
- package/dist/optimizer/check-keys.js +64 -0
- package/dist/optimizer/get-work-cache-stats.js +17 -3
- package/dist/optimizer/work-task-cache-description.js +51 -1
- package/dist/planner/utils/append-work-dependencies.js +10 -5
- package/dist/planner/utils/append-work-service.js +19 -1
- package/dist/planner/utils/append-work-task.js +18 -3
- package/dist/planner/utils/get-work-runtime.js +6 -1
- package/dist/planner/utils/normalize-path.js +19 -1
- package/dist/planner/utils/parse-work-mount.js +7 -1
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/validate.js +22 -0
- package/dist/planner/work-network.js +3 -1
- package/dist/planner/work-runtime-aggregate.js +32 -0
- package/dist/planner/work-runtime-docker.js +2 -1
- package/dist/planner/work-runtime-kubernetes.js +124 -48
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +31 -10
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +69 -3
- package/dist/remote/remote-commands.js +30 -7
- package/dist/remote/remote-work-commands.js +426 -45
- package/dist/remote/server-client.js +15 -2
- package/dist/remote/token-source.js +64 -26
- package/dist/run-program.js +9 -0
- package/dist/schema/build-file-base-task-schema.js +8 -1
- package/dist/schema/build-file-container-service-schema.js +12 -0
- package/dist/schema/build-file-container-task-schema.js +16 -1
- package/dist/schema/build-file-include-schema.js +18 -4
- package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
- package/dist/schema/build-file-task-schema.js +5 -1
- package/dist/schema/capabilities-schema.js +56 -0
- package/dist/schema/reference-parser.js +120 -11
- package/dist/schema/schema-parser.js +92 -41
- package/dist/schema/work-scope-parser.js +33 -10
- package/dist/secrets/command-secret-provider.js +3 -1
- package/dist/secrets/github-app-token-command.js +3 -1
- package/dist/secrets/secret-catalog.js +43 -19
- package/dist/server/accounts-api.js +3 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/definitions-api.js +66 -3
- package/dist/server/endpoints.js +739 -0
- package/dist/server/executor.js +57 -5
- package/dist/server/kubernetes-backend.js +367 -65
- package/dist/server/kubernetes-sandbox-storage.js +48 -3
- package/dist/server/oauth.js +12 -1
- package/dist/server/plan.js +188 -103
- package/dist/server/policy.js +32 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +50 -19
- package/dist/server/run-request.js +58 -3
- package/dist/server/run-worker.js +245 -12
- package/dist/server/runs.js +410 -27
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes-api.js +10 -4
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-command.js +39 -20
- package/dist/server/server-config.js +255 -14
- package/dist/server/server.js +269 -15
- package/dist/server/service-runs.js +82 -0
- package/dist/utils/abort-on-signals.js +46 -6
- package/dist/utils/tar.js +36 -0
- package/dist/utils/terminal-prompt.js +53 -0
- 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.
|
|
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
|
-
|
|
190
|
-
|
|
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 {
|
package/dist/server/sandboxes.js
CHANGED
|
@@ -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
|
|
50
|
+
const running = await (0, server_1.startServer)(config, environment, {
|
|
22
51
|
log: (line) => environment.stdout.write(`${line}\n`),
|
|
23
52
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
49
|
-
await
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
733
|
-
|
|
734
|
-
|
|
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.
|
|
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`);
|