hammerkit 1.27.0-next.22 → 1.27.0-next.24
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 +50 -0
- package/dist/cache/backends/local-cache-backend.js +6 -0
- package/dist/cli.js +1 -0
- package/dist/executer/deferred-restore.js +36 -0
- package/dist/executer/docker-task.js +21 -1
- package/dist/executer/execute-work-service.js +2 -0
- package/dist/executer/execute-work-task.js +5 -0
- package/dist/executer/scheduler/enqueue-next.js +50 -6
- package/dist/executer/watch-loop.js +4 -1
- package/dist/kubernetes/job-placement.js +36 -12
- package/dist/kubernetes/sandbox-runner.js +3 -1
- package/dist/planner/utils/append-work-task.js +3 -1
- package/dist/planner/work-runtime-kubernetes.js +4 -2
- package/dist/program.js +2 -0
- package/dist/remote/remote-work-commands.js +1 -0
- package/dist/schema/build-file-container-task-schema.js +4 -0
- package/dist/schema/capabilities-schema.js +56 -0
- package/dist/server/executor.js +1 -0
- package/dist/server/kubernetes-backend.js +71 -8
- package/dist/server/plan.js +17 -0
- package/dist/server/run-worker.js +9 -1
- package/dist/server/server-config.js +29 -0
- package/dist/server/server.js +6 -0
- package/package.json +1 -1
package/build.schema.json
CHANGED
|
@@ -311,6 +311,56 @@
|
|
|
311
311
|
"volumeSize": {
|
|
312
312
|
"type": "string",
|
|
313
313
|
"description": "size of its claim on Kubernetes, e.g. 10Gi"
|
|
314
|
+
},
|
|
315
|
+
"capAdd": {
|
|
316
|
+
"type": "array",
|
|
317
|
+
"items": {
|
|
318
|
+
"type": "string",
|
|
319
|
+
"enum": [
|
|
320
|
+
"AUDIT_CONTROL",
|
|
321
|
+
"AUDIT_READ",
|
|
322
|
+
"AUDIT_WRITE",
|
|
323
|
+
"BLOCK_SUSPEND",
|
|
324
|
+
"BPF",
|
|
325
|
+
"CHECKPOINT_RESTORE",
|
|
326
|
+
"CHOWN",
|
|
327
|
+
"DAC_OVERRIDE",
|
|
328
|
+
"DAC_READ_SEARCH",
|
|
329
|
+
"FOWNER",
|
|
330
|
+
"FSETID",
|
|
331
|
+
"IPC_LOCK",
|
|
332
|
+
"IPC_OWNER",
|
|
333
|
+
"KILL",
|
|
334
|
+
"LEASE",
|
|
335
|
+
"LINUX_IMMUTABLE",
|
|
336
|
+
"MAC_ADMIN",
|
|
337
|
+
"MAC_OVERRIDE",
|
|
338
|
+
"MKNOD",
|
|
339
|
+
"NET_ADMIN",
|
|
340
|
+
"NET_BIND_SERVICE",
|
|
341
|
+
"NET_BROADCAST",
|
|
342
|
+
"NET_RAW",
|
|
343
|
+
"PERFMON",
|
|
344
|
+
"SETFCAP",
|
|
345
|
+
"SETGID",
|
|
346
|
+
"SETPCAP",
|
|
347
|
+
"SETUID",
|
|
348
|
+
"SYS_ADMIN",
|
|
349
|
+
"SYS_BOOT",
|
|
350
|
+
"SYS_CHROOT",
|
|
351
|
+
"SYS_MODULE",
|
|
352
|
+
"SYS_NICE",
|
|
353
|
+
"SYS_PACCT",
|
|
354
|
+
"SYS_PTRACE",
|
|
355
|
+
"SYS_RAWIO",
|
|
356
|
+
"SYS_RESOURCE",
|
|
357
|
+
"SYS_TIME",
|
|
358
|
+
"SYS_TTY_CONFIG",
|
|
359
|
+
"SYSLOG",
|
|
360
|
+
"WAKE_ALARM"
|
|
361
|
+
]
|
|
362
|
+
},
|
|
363
|
+
"description": "Linux capabilities added to the container, e.g. [SYS_ADMIN]"
|
|
314
364
|
}
|
|
315
365
|
},
|
|
316
366
|
"required": [
|
|
@@ -80,6 +80,12 @@ function createLocalCacheBackend(spec) {
|
|
|
80
80
|
return false;
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
|
+
async touch(taskId, stateKey, environment) {
|
|
84
|
+
const remoteDir = (0, path_1.join)(root, taskId, stateKey);
|
|
85
|
+
if (await environment.file.exists((0, path_1.join)(remoteDir, 'stats.json'))) {
|
|
86
|
+
await (0, promises_1.writeFile)((0, path_1.join)(remoteDir, LAST_ACCESS_FILE), `${Date.now()}`);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
83
89
|
async push(taskId, stateKey, from, environment) {
|
|
84
90
|
const remoteDir = (0, path_1.join)(root, taskId, stateKey);
|
|
85
91
|
await environment.file.createDirectory(remoteDir);
|
package/dist/cli.js
CHANGED
|
@@ -81,6 +81,7 @@ class Cli {
|
|
|
81
81
|
timeout: options?.timeout ?? null,
|
|
82
82
|
maxTimeout: options?.maxTimeout ?? null,
|
|
83
83
|
skipDeps: options?.skipDeps ?? true,
|
|
84
|
+
restore: options?.restore ?? true,
|
|
84
85
|
wait: options?.wait ?? 'ready',
|
|
85
86
|
resourceCheck: options?.resourceCheck ?? true,
|
|
86
87
|
network: options?.network ?? null,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.restoreDeferred = exports.clearDeferredRestore = exports.deferRestore = void 0;
|
|
4
|
+
const abort_1 = require("./abort");
|
|
5
|
+
// Keyed by the item's state, which every run replaces (resetWorkTree): a hit
|
|
6
|
+
// deferred in one run of a Cli is never restored in a later one, over outputs
|
|
7
|
+
// that run built.
|
|
8
|
+
const deferred = new WeakMap();
|
|
9
|
+
function deferRestore(item, run) {
|
|
10
|
+
deferred.set(item.state, { run, promise: null });
|
|
11
|
+
}
|
|
12
|
+
exports.deferRestore = deferRestore;
|
|
13
|
+
// Every cache check starts from scratch: a key that changed since a hit was
|
|
14
|
+
// deferred must not restore the old entry.
|
|
15
|
+
function clearDeferredRestore(item) {
|
|
16
|
+
deferred.delete(item.state);
|
|
17
|
+
}
|
|
18
|
+
exports.clearDeferredRestore = clearDeferredRestore;
|
|
19
|
+
// Restore every given item that was a hit without its outputs, in parallel,
|
|
20
|
+
// each at most once however many items wait on it.
|
|
21
|
+
async function restoreDeferred(work, items, abort) {
|
|
22
|
+
await Promise.all(items.map((item) => {
|
|
23
|
+
const entry = deferred.get(item.state);
|
|
24
|
+
if (!entry) {
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
}
|
|
27
|
+
if (!entry.promise) {
|
|
28
|
+
work.status.write('debug', `restore ${item.name} from cache, ${work.name} needs its outputs`);
|
|
29
|
+
entry.promise = entry.run();
|
|
30
|
+
}
|
|
31
|
+
return entry.promise;
|
|
32
|
+
}));
|
|
33
|
+
(0, abort_1.checkForAbort)(abort);
|
|
34
|
+
}
|
|
35
|
+
exports.restoreDeferred = restoreDeferred;
|
|
36
|
+
//# sourceMappingURL=deferred-restore.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.dockerTask = exports.buildCreateOptions = void 0;
|
|
3
|
+
exports.dockerTask = exports.getCapabilityHostConfig = exports.buildCreateOptions = void 0;
|
|
4
4
|
const run_labels_1 = require("../docker/run-labels");
|
|
5
5
|
const execute_docker_1 = require("./execute-docker");
|
|
6
6
|
const abort_1 = require("./abort");
|
|
@@ -47,10 +47,30 @@ function buildCreateOptions(item, stateKey, network, environment, secrets, resou
|
|
|
47
47
|
...(0, item_network_1.getNetworkHostConfig)(network),
|
|
48
48
|
AutoRemove: true,
|
|
49
49
|
...resources,
|
|
50
|
+
...getCapabilityHostConfig(item, environment),
|
|
50
51
|
},
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
exports.buildCreateOptions = buildCreateOptions;
|
|
55
|
+
/**
|
|
56
|
+
* The capabilities a task adds (`capAdd`). On a server they are only granted in the
|
|
57
|
+
* sandboxed runtime of `backend.capabilities`, never by the kernel of the docker host.
|
|
58
|
+
*/
|
|
59
|
+
function getCapabilityHostConfig(item, environment) {
|
|
60
|
+
const capAdd = item.data.capAdd;
|
|
61
|
+
if (capAdd.length === 0) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
if (!environment.hosted) {
|
|
65
|
+
return { CapAdd: capAdd };
|
|
66
|
+
}
|
|
67
|
+
const runtime = environment.hosted.capabilityRuntime;
|
|
68
|
+
if (!runtime) {
|
|
69
|
+
throw new Error(`${item.name}: adds the capabilities ${capAdd.join(', ')}, which this server grants in no sandboxed runtime`);
|
|
70
|
+
}
|
|
71
|
+
return { CapAdd: capAdd, Runtime: runtime };
|
|
72
|
+
}
|
|
73
|
+
exports.getCapabilityHostConfig = getCapabilityHostConfig;
|
|
54
74
|
async function dockerTask(docker, item, environment, options) {
|
|
55
75
|
item.status.write('info', `execute ${item.name} in container`);
|
|
56
76
|
let network = null;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.executeWorkService = exports.stopService = void 0;
|
|
4
4
|
const work_item_1 = require("../planner/work-item");
|
|
5
5
|
const await_completed_dependencies_1 = require("./await-completed-dependencies");
|
|
6
|
+
const deferred_restore_1 = require("./deferred-restore");
|
|
6
7
|
const abort_1 = require("./abort");
|
|
7
8
|
const log_1 = require("../log");
|
|
8
9
|
const watch_loop_1 = require("./watch-loop");
|
|
@@ -35,6 +36,7 @@ async function executeWorkService(work, environment, options) {
|
|
|
35
36
|
});
|
|
36
37
|
await (0, watch_loop_1.watchLoop)(work, environment, options, async (cacheState, abort, stop) => {
|
|
37
38
|
await (0, await_completed_dependencies_1.awaitCompletedDependencies)(work, work.deps, abort);
|
|
39
|
+
await (0, deferred_restore_1.restoreDeferred)(work, (0, await_completed_dependencies_1.transitiveDeps)(work.deps), abort);
|
|
38
40
|
work.state.set({
|
|
39
41
|
type: 'ready',
|
|
40
42
|
stateKey: cacheState.stateKey,
|
|
@@ -11,6 +11,7 @@ const deadline_1 = require("./deadline");
|
|
|
11
11
|
const explain_1 = require("../cache/explain");
|
|
12
12
|
const archive_task_entry_1 = require("./archive-task-entry");
|
|
13
13
|
const units_1 = require("../utils/units");
|
|
14
|
+
const deferred_restore_1 = require("./deferred-restore");
|
|
14
15
|
async function pushToBackend(work, environment, cacheState, options) {
|
|
15
16
|
const { resolved, stateKey, provable } = cacheState;
|
|
16
17
|
// an aggregate has nothing to store, see checkCacheState
|
|
@@ -89,6 +90,10 @@ dependents = null) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
await (0, await_completed_dependencies_1.awaitCompletedDependencies)(work, work.deps, abort);
|
|
93
|
+
// an aggregate runs nothing, so it needs no outputs on disk
|
|
94
|
+
if (!(0, work_task_1.isAggregateTask)(work.data)) {
|
|
95
|
+
await (0, deferred_restore_1.restoreDeferred)(work, (0, await_completed_dependencies_1.transitiveDeps)(work.deps), abort);
|
|
96
|
+
}
|
|
92
97
|
await (0, await_completed_dependencies_1.awaitRunningNeeds)(work, work.needs.map((n) => n.service), abort);
|
|
93
98
|
await options.processManager.task(work, async () => {
|
|
94
99
|
work.state.set({
|
|
@@ -9,13 +9,17 @@ const work_task_cache_description_1 = require("../../optimizer/work-task-cache-d
|
|
|
9
9
|
const log_1 = require("../../log");
|
|
10
10
|
const state_key_1 = require("./state-key");
|
|
11
11
|
const work_instance_id_1 = require("../../planner/work-instance-id");
|
|
12
|
+
const deferred_restore_1 = require("../deferred-restore");
|
|
12
13
|
// The key a task whose cache is off runs and records its outputs under. It is
|
|
13
14
|
// no hash, so a later run with its cache on never takes those outputs as its own.
|
|
14
15
|
exports.UNCACHED_STATE_KEY = 'uncached';
|
|
15
16
|
async function checkCacheState(item, defaultCacheMethod, environment,
|
|
16
17
|
// watch mode compares keys to rerun on a change, so it needs one even when
|
|
17
18
|
// the cache is off
|
|
19
|
+
// restore: false only looks a backend hit up, its outputs are downloaded once
|
|
20
|
+
// something that runs depends on them (`run --no-restore`, see deferred-restore)
|
|
18
21
|
options = {}) {
|
|
22
|
+
(0, deferred_restore_1.clearDeferredRestore)(item);
|
|
19
23
|
// A task whose cache is off is never looked up, so hashing its sources buys
|
|
20
24
|
// nothing, and a `**` over a node_modules takes most of a minute (every run
|
|
21
25
|
// with arguments). Its dependents still hash its sources for their own key.
|
|
@@ -47,13 +51,30 @@ options = {}) {
|
|
|
47
51
|
if (runtimeStateKey === stateKey) {
|
|
48
52
|
return { cached: true, stateKey, resolved, provable };
|
|
49
53
|
}
|
|
54
|
+
if (options.restore === false && options.watch) {
|
|
55
|
+
item.status.write('debug', `${item.name} restores cache hits, --no-restore has no effect in watch mode`);
|
|
56
|
+
}
|
|
57
|
+
if (options.restore === false && !options.watch) {
|
|
58
|
+
try {
|
|
59
|
+
if (await resolved.backend.has(item.id(), stateKey, environment)) {
|
|
60
|
+
item.status.write('info', `${item.name} found in cache "${resolved.name}" (${resolved.backend.type}), not restored`);
|
|
61
|
+
await touchEntry(item, resolved, stateKey, environment);
|
|
62
|
+
(0, deferred_restore_1.deferRestore)(item, async () => {
|
|
63
|
+
if (!(await restoreFromBackend(item, resolved, stateKey, currentStats, environment))) {
|
|
64
|
+
throw new Error(`${item.name} is no longer in cache "${resolved.name}", its outputs can not be restored. ` +
|
|
65
|
+
'Run again without --no-restore.');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return { cached: true, stateKey, resolved, provable };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
item.status.write('warn', `${item.name} failed to look up cache "${resolved.name}": ${(0, log_1.getErrorMessage)(e)} — continuing with local execution`);
|
|
73
|
+
}
|
|
74
|
+
return { cached: false, stateKey, resolved, provable };
|
|
75
|
+
}
|
|
50
76
|
try {
|
|
51
|
-
|
|
52
|
-
const pulled = await resolved.backend.pull(item.id(), stateKey, cacheDir, environment);
|
|
53
|
-
if (pulled) {
|
|
54
|
-
item.status.write('info', `${item.name} pulled from cache "${resolved.name}" (${resolved.backend.type})`);
|
|
55
|
-
await item.runtime.restore(environment, cacheDir, stateKey);
|
|
56
|
-
await (0, cache_metadata_1.writeCacheMetadata)(environment, item, currentStats, (0, work_task_cache_description_1.getWorkTaskCacheDescription)(item));
|
|
77
|
+
if (await restoreFromBackend(item, resolved, stateKey, currentStats, environment)) {
|
|
57
78
|
return { cached: true, stateKey, resolved, provable };
|
|
58
79
|
}
|
|
59
80
|
}
|
|
@@ -64,4 +85,27 @@ options = {}) {
|
|
|
64
85
|
return { cached: false, stateKey, resolved, provable };
|
|
65
86
|
}
|
|
66
87
|
exports.checkCacheState = checkCacheState;
|
|
88
|
+
// Pull a task's entry and restore its outputs into the checkout. False when the
|
|
89
|
+
// backend has no such entry.
|
|
90
|
+
async function restoreFromBackend(item, resolved, stateKey, currentStats, environment) {
|
|
91
|
+
const cacheDir = (0, get_cache_directory_1.getCacheDirectory)((0, work_instance_id_1.getWorkInstanceId)(item));
|
|
92
|
+
const pulled = await resolved.backend.pull(item.id(), stateKey, cacheDir, environment);
|
|
93
|
+
if (!pulled) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
item.status.write('info', `${item.name} pulled from cache "${resolved.name}" (${resolved.backend.type})`);
|
|
97
|
+
await item.runtime.restore(environment, cacheDir, stateKey);
|
|
98
|
+
await (0, cache_metadata_1.writeCacheMetadata)(environment, item, currentStats, (0, work_task_cache_description_1.getWorkTaskCacheDescription)(item));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
// A hit that is only looked up still counts as used, so retention keeps it.
|
|
102
|
+
// Best effort: the hit stands whether or not the marker could be written.
|
|
103
|
+
async function touchEntry(item, resolved, stateKey, environment) {
|
|
104
|
+
try {
|
|
105
|
+
await resolved.backend.touch?.(item.id(), stateKey, environment);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
item.status.write('debug', `${item.name} could not mark its cache entry as used: ${(0, log_1.getErrorMessage)(e)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
67
111
|
//# sourceMappingURL=enqueue-next.js.map
|
|
@@ -12,7 +12,10 @@ async function watchLoop(work, environment, options, factory) {
|
|
|
12
12
|
// kubernetes-service has no such field. Read it wherever it exists.
|
|
13
13
|
const continuous = 'continuous' in work.data ? work.data.continuous : false;
|
|
14
14
|
const watchMode = options.watch && !options.daemon && !continuous;
|
|
15
|
-
const currentStats = await (0, enqueue_next_1.checkCacheState)(work, options.cacheDefault, environment, {
|
|
15
|
+
const currentStats = await (0, enqueue_next_1.checkCacheState)(work, options.cacheDefault, environment, {
|
|
16
|
+
watch: watchMode,
|
|
17
|
+
restore: options.restore,
|
|
18
|
+
});
|
|
16
19
|
(0, abort_1.checkForAbort)(environment.abortCtrl.signal);
|
|
17
20
|
const watchState = watchMode
|
|
18
21
|
? (0, watch_state_key_1.watchStateKey)(work, currentStats, environment, options)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getContainerSecurity = exports.getRunLabels = exports.getRunLabelValue = exports.RUN_LABEL = exports.getPodPlacement = exports.placeOnPool = exports.getNodeArch = exports.runInToolImage = exports.getLocalTaskPool = void 0;
|
|
3
|
+
exports.getPodRuntimeClass = exports.getContainerSecurity = exports.getRunLabels = exports.getRunLabelValue = exports.RUN_LABEL = exports.getPodPlacement = exports.placeOnPool = exports.getNodeArch = exports.runInToolImage = exports.getLocalTaskPool = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
/**
|
|
6
6
|
* The pool a local task runs on: the first that offers its platform. A pool says
|
|
@@ -34,6 +34,7 @@ function runInToolImage(task, server, platform) {
|
|
|
34
34
|
user: null,
|
|
35
35
|
mounts: [],
|
|
36
36
|
volumeSize: null,
|
|
37
|
+
capAdd: [],
|
|
37
38
|
pool: pool.name,
|
|
38
39
|
arch: getNodeArch(pool),
|
|
39
40
|
};
|
|
@@ -82,18 +83,41 @@ exports.getRunLabels = getRunLabels;
|
|
|
82
83
|
/**
|
|
83
84
|
* What a container of a run may do on a server, where it shares nodes with the pods of
|
|
84
85
|
* other callers: no privilege escalation (setuid binaries such as sudo), no raw sockets
|
|
85
|
-
* (spoofing on a bridge network) and the runtime's seccomp profile.
|
|
86
|
+
* (spoofing on a bridge network) and the runtime's seccomp profile. The capabilities a
|
|
87
|
+
* task adds (`capAdd`) come on top, in the sandbox of getPodRuntimeClass on a server.
|
|
86
88
|
*/
|
|
87
|
-
function getContainerSecurity(kubernetes) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
function getContainerSecurity(kubernetes, capAdd = []) {
|
|
90
|
+
const add = capAdd.length > 0 ? { add: [...capAdd] } : {};
|
|
91
|
+
if (!kubernetes.server) {
|
|
92
|
+
return capAdd.length > 0 ? { securityContext: { capabilities: add } } : {};
|
|
93
|
+
}
|
|
94
|
+
const drop = capAdd.includes('NET_RAW') ? {} : { drop: ['NET_RAW'] };
|
|
95
|
+
return {
|
|
96
|
+
securityContext: {
|
|
97
|
+
allowPrivilegeEscalation: false,
|
|
98
|
+
capabilities: { ...drop, ...add },
|
|
99
|
+
seccompProfile: { type: 'RuntimeDefault' },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
97
102
|
}
|
|
98
103
|
exports.getContainerSecurity = getContainerSecurity;
|
|
104
|
+
/**
|
|
105
|
+
* The RuntimeClass of the pod of a task that adds capabilities on a server: the sandbox the
|
|
106
|
+
* operator named (`backend.capabilities.runtimeClass`), so the capabilities hold in its own
|
|
107
|
+
* kernel and never in the node's. Elsewhere the cluster is the build file's own.
|
|
108
|
+
*/
|
|
109
|
+
function getPodRuntimeClass(task, kubernetes) {
|
|
110
|
+
if (task.capAdd.length === 0 || !kubernetes.server) {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
const sandbox = kubernetes.server.capabilities;
|
|
114
|
+
if (!sandbox) {
|
|
115
|
+
throw new Error(`${task.name}: adds the capabilities ${task.capAdd.join(', ')}, which this server grants in no sandboxed runtime`);
|
|
116
|
+
}
|
|
117
|
+
if (sandbox.unavailable) {
|
|
118
|
+
throw new Error(`${task.name}: adds the capabilities ${task.capAdd.join(', ')}, but ${sandbox.unavailable}`);
|
|
119
|
+
}
|
|
120
|
+
return { runtimeClassName: sandbox.runtimeClass };
|
|
121
|
+
}
|
|
122
|
+
exports.getPodRuntimeClass = getPodRuntimeClass;
|
|
99
123
|
//# sourceMappingURL=job-placement.js.map
|
|
@@ -151,7 +151,9 @@ function canUseRunner(task, kubernetes, persistence) {
|
|
|
151
151
|
persistence.mounts.some((mount) => (0, path_1.relative)(workspace.root, mount.mount.mountPath) === '') &&
|
|
152
152
|
task.needs.length === 0 &&
|
|
153
153
|
task.data.secrets.length === 0 &&
|
|
154
|
-
task.data.generates.length === 0
|
|
154
|
+
task.data.generates.length === 0 &&
|
|
155
|
+
// its pod would need the sandboxed runtime of a Job
|
|
156
|
+
task.data.capAdd.length === 0);
|
|
155
157
|
}
|
|
156
158
|
exports.canUseRunner = canUseRunner;
|
|
157
159
|
// What the runner is made of: the same key for the same pod
|
|
@@ -92,10 +92,12 @@ function parseTask(cwd, task, environment, context) {
|
|
|
92
92
|
return {
|
|
93
93
|
...baseWorkNode,
|
|
94
94
|
type: 'container-task',
|
|
95
|
-
|
|
95
|
+
// a capability holds for root only: a task that adds some runs as its image's user
|
|
96
|
+
user: task.schema.capAdd?.length ? null : (0, get_container_user_1.getContainerUser)(),
|
|
96
97
|
image: (0, template_value_1.templateValue)(task.schema.image, envs),
|
|
97
98
|
mounts: (0, parse_work_mounts_1.parseWorkMounts)(cwd, task.schema, envs),
|
|
98
99
|
volumeSize: task.schema.volumeSize ?? null,
|
|
100
|
+
capAdd: task.schema.capAdd ?? [],
|
|
99
101
|
};
|
|
100
102
|
}
|
|
101
103
|
else {
|
|
@@ -130,7 +130,8 @@ exports.describeFailedJob = describeFailedJob;
|
|
|
130
130
|
* The containers of the Job of a command: the network gate first, nothing runs before the
|
|
131
131
|
* item's policy holds, then the init containers that read its secrets, then the command. The
|
|
132
132
|
* proxy's token (`network.secretEnv`) reaches the init containers through `secretSpec` only:
|
|
133
|
-
* the command's container gets the proxy variables of a limited network without it.
|
|
133
|
+
* the command's container gets the proxy variables of a limited network without it. A task that
|
|
134
|
+
* adds capabilities gets them in the sandbox's RuntimeClass only.
|
|
134
135
|
*/
|
|
135
136
|
function getTaskJobContainers(options) {
|
|
136
137
|
const { task, cmd, envs, secrets, network, secretSpec, kubernetes } = options;
|
|
@@ -155,9 +156,10 @@ function getTaskJobContainers(options) {
|
|
|
155
156
|
name: options.container,
|
|
156
157
|
volumeMounts: [...options.mounts, ...secrets.mounts, ...secretSpec.mounts],
|
|
157
158
|
...(0, container_resources_1.getKubernetesResources)(task.data.resources),
|
|
158
|
-
...(0, job_placement_1.getContainerSecurity)(kubernetes),
|
|
159
|
+
...(0, job_placement_1.getContainerSecurity)(kubernetes, task.data.capAdd),
|
|
159
160
|
},
|
|
160
161
|
],
|
|
162
|
+
...(0, job_placement_1.getPodRuntimeClass)({ name: task.name, capAdd: task.data.capAdd }, kubernetes),
|
|
161
163
|
};
|
|
162
164
|
}
|
|
163
165
|
exports.getTaskJobContainers = getTaskJobContainers;
|
package/dist/program.js
CHANGED
|
@@ -705,6 +705,7 @@ async function getProgram(environment, argv) {
|
|
|
705
705
|
.addOption(new commander_1.Option('--max-timeout <duration>', 'fail any task after this long, also one with a longer timeout of its own (e.g. 2m)').argParser(units_1.parseDuration))
|
|
706
706
|
.addOption(new commander_1.Option('--network <level>', 'the most any task or service reaches besides its needs: none, open or allow:<host>,<host>').argParser(parseNetworkArgument))
|
|
707
707
|
.addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when nothing needing them has to run'))
|
|
708
|
+
.addOption(new commander_1.Option('--no-restore', 'only look cache hits up, download their outputs only when a task that runs needs them'))
|
|
708
709
|
.action(async (task, options, command) => {
|
|
709
710
|
try {
|
|
710
711
|
// For machine-readable JSON, suppress the human progress logger (which
|
|
@@ -740,6 +741,7 @@ async function getProgram(environment, argv) {
|
|
|
740
741
|
maxTimeout: options.maxTimeout ?? null,
|
|
741
742
|
network: options.network ?? null,
|
|
742
743
|
skipDeps: options.skipDeps !== false,
|
|
744
|
+
restore: options.restore !== false,
|
|
743
745
|
});
|
|
744
746
|
// Reporting only: the summary never changes the exit code or behavior.
|
|
745
747
|
if (!options.watch) {
|
|
@@ -122,6 +122,7 @@ function describePlan(plan) {
|
|
|
122
122
|
...(entry.mounts.length > 0 ? [` mounts: ${entry.mounts.map(server_client_1.printable).join(', ')}`] : []),
|
|
123
123
|
...(entry.ports.length > 0 ? [` ports: ${entry.ports.map(server_client_1.printable).join(', ')}`] : []),
|
|
124
124
|
...(entry.generates.length > 0 ? [` generates: ${entry.generates.map(server_client_1.printable).join(', ')}`] : []),
|
|
125
|
+
...(entry.capAdd?.length ? [` capAdd: ${entry.capAdd.map(server_client_1.printable).join(', ')}`] : []),
|
|
125
126
|
]);
|
|
126
127
|
}
|
|
127
128
|
exports.describePlan = describePlan;
|
|
@@ -4,12 +4,16 @@ exports.buildFileContainerTaskSchema = void 0;
|
|
|
4
4
|
const build_file_base_task_schema_1 = require("./build-file-base-task-schema");
|
|
5
5
|
const zod_1 = require("zod");
|
|
6
6
|
const size_schema_1 = require("./size-schema");
|
|
7
|
+
const capabilities_schema_1 = require("./capabilities-schema");
|
|
7
8
|
exports.buildFileContainerTaskSchema = build_file_base_task_schema_1.buildFileBaseTaskSchema
|
|
8
9
|
.extend({
|
|
9
10
|
image: (0, zod_1.string)(),
|
|
10
11
|
mounts: (0, zod_1.array)((0, zod_1.string)()).optional(),
|
|
11
12
|
// on Kubernetes, the size of the claim that holds its sources, mounts and outputs
|
|
12
13
|
volumeSize: size_schema_1.sizeSchema.describe('size of its claim on Kubernetes, e.g. 10Gi').optional(),
|
|
14
|
+
// Linux capabilities added to the container, such as SYS_ADMIN for a daemonless image
|
|
15
|
+
// build. A server allows them only in a sandboxed runtime (`backend.capabilities`).
|
|
16
|
+
capAdd: capabilities_schema_1.capabilitiesSchema.describe('Linux capabilities added to the container, e.g. [SYS_ADMIN]').optional(),
|
|
13
17
|
})
|
|
14
18
|
.strict()
|
|
15
19
|
.describe('container task');
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.capabilitiesSchema = exports.LINUX_CAPABILITIES = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
// The Linux capabilities, without the CAP_ prefix (capabilities(7))
|
|
6
|
+
exports.LINUX_CAPABILITIES = [
|
|
7
|
+
'AUDIT_CONTROL',
|
|
8
|
+
'AUDIT_READ',
|
|
9
|
+
'AUDIT_WRITE',
|
|
10
|
+
'BLOCK_SUSPEND',
|
|
11
|
+
'BPF',
|
|
12
|
+
'CHECKPOINT_RESTORE',
|
|
13
|
+
'CHOWN',
|
|
14
|
+
'DAC_OVERRIDE',
|
|
15
|
+
'DAC_READ_SEARCH',
|
|
16
|
+
'FOWNER',
|
|
17
|
+
'FSETID',
|
|
18
|
+
'IPC_LOCK',
|
|
19
|
+
'IPC_OWNER',
|
|
20
|
+
'KILL',
|
|
21
|
+
'LEASE',
|
|
22
|
+
'LINUX_IMMUTABLE',
|
|
23
|
+
'MAC_ADMIN',
|
|
24
|
+
'MAC_OVERRIDE',
|
|
25
|
+
'MKNOD',
|
|
26
|
+
'NET_ADMIN',
|
|
27
|
+
'NET_BIND_SERVICE',
|
|
28
|
+
'NET_BROADCAST',
|
|
29
|
+
'NET_RAW',
|
|
30
|
+
'PERFMON',
|
|
31
|
+
'SETFCAP',
|
|
32
|
+
'SETGID',
|
|
33
|
+
'SETPCAP',
|
|
34
|
+
'SETUID',
|
|
35
|
+
'SYS_ADMIN',
|
|
36
|
+
'SYS_BOOT',
|
|
37
|
+
'SYS_CHROOT',
|
|
38
|
+
'SYS_MODULE',
|
|
39
|
+
'SYS_NICE',
|
|
40
|
+
'SYS_PACCT',
|
|
41
|
+
'SYS_PTRACE',
|
|
42
|
+
'SYS_RAWIO',
|
|
43
|
+
'SYS_RESOURCE',
|
|
44
|
+
'SYS_TIME',
|
|
45
|
+
'SYS_TTY_CONFIG',
|
|
46
|
+
'SYSLOG',
|
|
47
|
+
'WAKE_ALARM',
|
|
48
|
+
];
|
|
49
|
+
// Capabilities a container gets on top of its runtime's default set, by name such as
|
|
50
|
+
// SYS_ADMIN. `ALL` is not one: a task names what it needs.
|
|
51
|
+
exports.capabilitiesSchema = (0, zod_1.array)((0, zod_1.enum)(exports.LINUX_CAPABILITIES, {
|
|
52
|
+
errorMap: (issue, ctx) => issue.code === 'invalid_enum_value'
|
|
53
|
+
? { message: `unknown capability ${JSON.stringify(issue.received)}, expected a name such as SYS_ADMIN` }
|
|
54
|
+
: { message: ctx.defaultError },
|
|
55
|
+
})).refine((names) => new Set(names).size === names.length, 'a capability is listed twice');
|
|
56
|
+
//# sourceMappingURL=capabilities-schema.js.map
|
package/dist/server/executor.js
CHANGED
|
@@ -238,6 +238,7 @@ class HostExecutor {
|
|
|
238
238
|
}
|
|
239
239
|
: {}),
|
|
240
240
|
...(placed ? { kubernetes: placed.kubernetes } : {}),
|
|
241
|
+
...(!placed && this.options.capabilityRuntime ? { capabilityRuntime: this.options.capabilityRuntime } : {}),
|
|
241
242
|
}, cwd, { ...this.processEnvironment(home, cache), ...placed?.env }, context, abortCtrl.signal);
|
|
242
243
|
}
|
|
243
244
|
finally {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.awaitRunObjectsGone = exports.removeRunObjects = exports.CallerNamespaces = exports.getVolumeScope = exports.KubernetesExecutor = exports.getRunRoleBinding = exports.removeServiceRunNamespace = exports.getServerIngressPolicy = exports.getNamespacePolicies = exports.getClusterAccess = exports.isCallerNamespace = exports.SERVICE_RUN_LABEL = exports.getServiceRunNamespace = exports.getCallerNamespace = void 0;
|
|
3
|
+
exports.awaitRunObjectsGone = exports.removeRunObjects = exports.CallerNamespaces = exports.getVolumeScope = exports.getPrivilegedNamespaceProblem = exports.getCapabilitiesProblem = exports.getPodSecurityLabels = exports.KubernetesExecutor = exports.getRunRoleBinding = exports.removeServiceRunNamespace = exports.getServerIngressPolicy = exports.getNamespacePolicies = exports.getClusterAccess = exports.isCallerNamespace = exports.SERVICE_RUN_LABEL = exports.getServiceRunNamespace = exports.getCallerNamespace = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
const fs_1 = require("fs");
|
|
6
6
|
const os_1 = require("os");
|
|
@@ -368,11 +368,12 @@ class KubernetesExecutor {
|
|
|
368
368
|
return { kubernetes: this.environmentOf(run, namespace), env: this.access.env };
|
|
369
369
|
}
|
|
370
370
|
const label = (0, job_placement_1.getRunLabelValue)(run.id);
|
|
371
|
+
let capabilitiesProblem;
|
|
371
372
|
await this.enqueue(namespace, async () => {
|
|
372
373
|
if (context.signal.aborted) {
|
|
373
374
|
throw new Error('the run was cancelled');
|
|
374
375
|
}
|
|
375
|
-
await this.namespaces.prepare(namespace);
|
|
376
|
+
capabilitiesProblem = await this.namespaces.prepare(namespace);
|
|
376
377
|
// what an earlier run left when the server stopped before its end, or when its
|
|
377
378
|
// release failed: the objects of a run that does not execute. One that starts
|
|
378
379
|
// after this waits for it here before it makes any.
|
|
@@ -394,7 +395,10 @@ class KubernetesExecutor {
|
|
|
394
395
|
});
|
|
395
396
|
this.sweepLeftClaims();
|
|
396
397
|
context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
|
|
397
|
-
return {
|
|
398
|
+
return {
|
|
399
|
+
kubernetes: this.environmentOf(run, namespace, work, capabilitiesProblem),
|
|
400
|
+
env: this.access.env,
|
|
401
|
+
};
|
|
398
402
|
},
|
|
399
403
|
release: async (run, context) => {
|
|
400
404
|
const namespace = this.namespaceOf(run);
|
|
@@ -462,7 +466,7 @@ class KubernetesExecutor {
|
|
|
462
466
|
};
|
|
463
467
|
}
|
|
464
468
|
// The cluster as the process of a run sees it: the namespace its tasks and services go in.
|
|
465
|
-
environmentOf(run, namespace, work) {
|
|
469
|
+
environmentOf(run, namespace, work, capabilitiesProblem) {
|
|
466
470
|
const backend = this.config.backend;
|
|
467
471
|
return {
|
|
468
472
|
type: 'kubernetes',
|
|
@@ -483,6 +487,14 @@ class KubernetesExecutor {
|
|
|
483
487
|
...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace, backend.sandboxes.runner) } : {}),
|
|
484
488
|
// the claims of a service run go with its namespace
|
|
485
489
|
...(this.keep > 0 && !run.up ? { volumeScope: getVolumeScope(run) } : {}),
|
|
490
|
+
...(backend.capabilities
|
|
491
|
+
? {
|
|
492
|
+
capabilities: {
|
|
493
|
+
runtimeClass: backend.capabilities.runtimeClass,
|
|
494
|
+
...(capabilitiesProblem ? { unavailable: capabilitiesProblem } : {}),
|
|
495
|
+
},
|
|
496
|
+
}
|
|
497
|
+
: {}),
|
|
486
498
|
},
|
|
487
499
|
};
|
|
488
500
|
}
|
|
@@ -565,6 +577,50 @@ class KubernetesExecutor {
|
|
|
565
577
|
}
|
|
566
578
|
}
|
|
567
579
|
exports.KubernetesExecutor = KubernetesExecutor;
|
|
580
|
+
const ENFORCE_LABEL = 'pod-security.kubernetes.io/enforce';
|
|
581
|
+
const AUDIT_LABEL = 'pod-security.kubernetes.io/audit';
|
|
582
|
+
/**
|
|
583
|
+
* The Pod Security level a caller's namespace is made with: `baseline`, no privileged pods,
|
|
584
|
+
* host paths or host network, whatever a build asks. `baseline` also refuses the capabilities
|
|
585
|
+
* of `backend.capabilities`, sandboxed or not: with them the namespace enforces `privileged`
|
|
586
|
+
* and audits `baseline`, and the operator's admission policy (see the docs on capabilities)
|
|
587
|
+
* refuses what `baseline` would to a pod outside the sandbox's RuntimeClass.
|
|
588
|
+
*/
|
|
589
|
+
function getPodSecurityLabels(backend) {
|
|
590
|
+
return backend.capabilities
|
|
591
|
+
? { [ENFORCE_LABEL]: 'privileged', [AUDIT_LABEL]: 'baseline' }
|
|
592
|
+
: { [ENFORCE_LABEL]: 'baseline' };
|
|
593
|
+
}
|
|
594
|
+
exports.getPodSecurityLabels = getPodSecurityLabels;
|
|
595
|
+
/**
|
|
596
|
+
* The server never changes the level of a namespace it made (that would take `patch` on every
|
|
597
|
+
* namespace of the cluster): one made before `backend.capabilities` is relabelled by the
|
|
598
|
+
* operator. Until then its tasks that add capabilities fail with this, the others run.
|
|
599
|
+
*/
|
|
600
|
+
function getCapabilitiesProblem(namespace, labels, backend) {
|
|
601
|
+
const enforce = labels?.[ENFORCE_LABEL];
|
|
602
|
+
if (!backend.capabilities || enforce === 'privileged') {
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
return (`the namespace ${namespace} was made before backend.capabilities and enforces ` +
|
|
606
|
+
`${enforce ? `Pod Security ${enforce}` : 'no Pod Security level of its own'}, not privileged: kubectl label namespace ${namespace} ` +
|
|
607
|
+
`${ENFORCE_LABEL}=privileged ${AUDIT_LABEL}=baseline --overwrite`);
|
|
608
|
+
}
|
|
609
|
+
exports.getCapabilitiesProblem = getCapabilitiesProblem;
|
|
610
|
+
/**
|
|
611
|
+
* A namespace made while the server had `backend.capabilities` admits privileged pods but for
|
|
612
|
+
* the operator's admission policy. Without capabilities the server runs nothing in it until the
|
|
613
|
+
* operator sets it back.
|
|
614
|
+
*/
|
|
615
|
+
function getPrivilegedNamespaceProblem(namespace, labels, backend) {
|
|
616
|
+
if (backend.capabilities || labels?.[ENFORCE_LABEL] !== 'privileged') {
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
return (`the namespace ${namespace} enforces Pod Security privileged, made while the server had ` +
|
|
620
|
+
`backend.capabilities, and the server runs nothing in it without them: kubectl label namespace ` +
|
|
621
|
+
`${namespace} ${ENFORCE_LABEL}=baseline ${AUDIT_LABEL}- --overwrite`);
|
|
622
|
+
}
|
|
623
|
+
exports.getPrivilegedNamespaceProblem = getPrivilegedNamespaceProblem;
|
|
568
624
|
// What may mount a claim a run kept: the runs of the same repository, as they share the
|
|
569
625
|
// cache, and on a sandbox the runs of that sandbox only, whose claim is on the same node or
|
|
570
626
|
// zone as the kept ones. The owner is the namespace's.
|
|
@@ -595,7 +651,8 @@ class CallerNamespaces {
|
|
|
595
651
|
// are admitted under a quota on compute. All of it is replaced when the server sets up
|
|
596
652
|
// a namespace it did not set up in the last `NAMESPACE_SETUP_REUSE_MS` (it reads the
|
|
597
653
|
// config once, at its start), so the namespace follows the config; a namespace made anew
|
|
598
|
-
// since (deleted and made again) is set up again at once.
|
|
654
|
+
// since (deleted and made again) is set up again at once. Returns what keeps the tasks that
|
|
655
|
+
// add capabilities from running in it (getCapabilitiesProblem), if anything.
|
|
599
656
|
async prepare(namespace,
|
|
600
657
|
// a service run's own namespace: its label, the namespace of the server that reaches its
|
|
601
658
|
// endpoints, and its quota
|
|
@@ -613,6 +670,12 @@ class CallerNamespaces {
|
|
|
613
670
|
if (existing && existing.metadata?.labels?.[MANAGED_LABEL] !== 'true') {
|
|
614
671
|
throw new Error(`the namespace ${namespace} was not made by hammerkit, the server does not run tasks in it`);
|
|
615
672
|
}
|
|
673
|
+
const privileged = getPrivilegedNamespaceProblem(namespace, existing?.metadata?.labels, backend);
|
|
674
|
+
if (privileged) {
|
|
675
|
+
throw new Error(privileged);
|
|
676
|
+
}
|
|
677
|
+
// one made by another run of this server at the same time has the labels of its config
|
|
678
|
+
const problem = () => getCapabilitiesProblem(namespace, existing?.metadata?.labels ?? getPodSecurityLabels(backend), backend);
|
|
616
679
|
const started = Date.now();
|
|
617
680
|
const applied = this.applied.get(namespace);
|
|
618
681
|
if (existing &&
|
|
@@ -620,7 +683,7 @@ class CallerNamespaces {
|
|
|
620
683
|
applied &&
|
|
621
684
|
applied.uid === existing.metadata?.uid &&
|
|
622
685
|
started - applied.at < NAMESPACE_SETUP_REUSE_MS) {
|
|
623
|
-
return;
|
|
686
|
+
return problem();
|
|
624
687
|
}
|
|
625
688
|
this.applied.delete(namespace);
|
|
626
689
|
if (!existing) {
|
|
@@ -633,8 +696,7 @@ class CallerNamespaces {
|
|
|
633
696
|
[MANAGED_LABEL]: 'true',
|
|
634
697
|
'hammerkit.dev/caller': namespace,
|
|
635
698
|
...(serviceRun ? { [exports.SERVICE_RUN_LABEL]: serviceRun.serviceRun } : {}),
|
|
636
|
-
|
|
637
|
-
'pod-security.kubernetes.io/enforce': 'baseline',
|
|
699
|
+
...getPodSecurityLabels(backend),
|
|
638
700
|
},
|
|
639
701
|
},
|
|
640
702
|
},
|
|
@@ -703,6 +765,7 @@ class CallerNamespaces {
|
|
|
703
765
|
if (uid) {
|
|
704
766
|
this.applied.set(namespace, { uid, at: started });
|
|
705
767
|
}
|
|
768
|
+
return problem();
|
|
706
769
|
}
|
|
707
770
|
whoAmI() {
|
|
708
771
|
if (!this.subject) {
|
package/dist/server/plan.js
CHANGED
|
@@ -188,6 +188,18 @@ function findItemPolicyProblems(item, policy, root) {
|
|
|
188
188
|
problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
|
|
189
189
|
}
|
|
190
190
|
const data = item.data;
|
|
191
|
+
// a capability holds in the sandboxed runtime the operator named for it, so only those
|
|
192
|
+
// the operator allows are added
|
|
193
|
+
const refused = policy.capabilities
|
|
194
|
+
? ('capAdd' in data ? data.capAdd : []).filter((capability) => !policy.capabilities?.includes(capability))
|
|
195
|
+
: [];
|
|
196
|
+
if (refused.length > 0) {
|
|
197
|
+
const from = policy.capabilitiesFrom ?? 'backend.capabilities.allow';
|
|
198
|
+
problems.push(`${item.name}: adds the ${refused.length > 1 ? 'capabilities' : 'capability'} ${refused.join(', ')}, ` +
|
|
199
|
+
(policy.capabilities && policy.capabilities.length > 0
|
|
200
|
+
? `which this server does not allow (${from}: ${policy.capabilities.join(', ')})`
|
|
201
|
+
: `and this server allows none (${from})`));
|
|
202
|
+
}
|
|
191
203
|
// what a task reads and writes is the bundle's: `src: ../..` would have the server
|
|
192
204
|
// copy its own files (its credentials, other callers' caches) into the task
|
|
193
205
|
for (const source of 'src' in data ? data.src : []) {
|
|
@@ -303,6 +315,10 @@ function toPlan(root, item) {
|
|
|
303
315
|
if (data.scope.remote) {
|
|
304
316
|
flags.push('remote-include');
|
|
305
317
|
}
|
|
318
|
+
const capAdd = (0, work_task_1.isContainerWorkTask)(data) ? data.capAdd : [];
|
|
319
|
+
if (capAdd.length > 0) {
|
|
320
|
+
flags.push('capabilities');
|
|
321
|
+
}
|
|
306
322
|
const isTask = data.type === 'local-task' || data.type === 'container-task';
|
|
307
323
|
return {
|
|
308
324
|
kind: isTask ? 'task' : 'service',
|
|
@@ -321,6 +337,7 @@ function toPlan(root, item) {
|
|
|
321
337
|
mounts,
|
|
322
338
|
ports,
|
|
323
339
|
generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
|
|
340
|
+
...(capAdd.length > 0 ? { capAdd: [...capAdd] } : {}),
|
|
324
341
|
flags,
|
|
325
342
|
};
|
|
326
343
|
}
|
|
@@ -51,6 +51,9 @@ exports.workerInputSchema = zod_1.z
|
|
|
51
51
|
kubernetes: zod_1.z
|
|
52
52
|
.custom((value) => typeof value === 'object' && value !== null && value.type === 'kubernetes')
|
|
53
53
|
.optional(),
|
|
54
|
+
// on the server's docker host, the runtime a container that adds capabilities runs in
|
|
55
|
+
// (`backend.capabilities.runtime`)
|
|
56
|
+
capabilityRuntime: zod_1.z.string().min(1).optional(),
|
|
54
57
|
// the most any task or service reaches besides its needs, as `--network`: `runs.network`
|
|
55
58
|
network: network_schema_1.networkSchema.optional(),
|
|
56
59
|
// for a run on a sandbox, `sandboxes.network`: the run gets the lower of the two
|
|
@@ -153,7 +156,12 @@ up) {
|
|
|
153
156
|
// is what is streamed: it is redacted with the secrets of this run
|
|
154
157
|
const environment = {
|
|
155
158
|
processEnvs,
|
|
156
|
-
hosted: {
|
|
159
|
+
hosted: {
|
|
160
|
+
catalog: input.catalog,
|
|
161
|
+
accounts: input.accounts,
|
|
162
|
+
kubernetes: input.kubernetes,
|
|
163
|
+
capabilityRuntime: input.capabilityRuntime,
|
|
164
|
+
},
|
|
157
165
|
abortCtrl,
|
|
158
166
|
cwd: work,
|
|
159
167
|
file: (0, get_file_context_1.getFileContext)(work),
|
|
@@ -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
|
|
@@ -282,6 +295,9 @@ const kubernetesBackendSchema = zod_1.z
|
|
|
282
295
|
// build file's environment does: hammerkit can't tell from the API, and without it a
|
|
283
296
|
// task with a limited network fails rather than runs open
|
|
284
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(),
|
|
285
301
|
})
|
|
286
302
|
.strict();
|
|
287
303
|
// the most a task's claim may request on a server whose operator states no maxVolumeSize
|
|
@@ -546,6 +562,9 @@ exports.serverConfigSchema = zod_1.z
|
|
|
546
562
|
.object({ allow: zod_1.z.array(zod_1.z.string().regex(/^[A-Za-z_][A-Za-z0-9_()]*$/)).default([]) })
|
|
547
563
|
.strict()
|
|
548
564
|
.default({}),
|
|
565
|
+
// the capabilities of `backend.capabilities.allow` a task of a run on a sandbox may
|
|
566
|
+
// add: none by default, a sandbox holds code nobody reviewed
|
|
567
|
+
capabilities: zod_1.z.array(zod_1.z.enum(capabilities_schema_1.LINUX_CAPABILITIES)).default([]),
|
|
549
568
|
// what one caller may keep; a sandbox over it can't be created
|
|
550
569
|
limits: zod_1.z
|
|
551
570
|
.object({
|
|
@@ -677,6 +696,16 @@ exports.serverConfigSchema = zod_1.z
|
|
|
677
696
|
}
|
|
678
697
|
}
|
|
679
698
|
});
|
|
699
|
+
const allowed = config.backend.capabilities?.allow ?? [];
|
|
700
|
+
config.sandboxes.capabilities.forEach((capability, index) => {
|
|
701
|
+
if (!allowed.includes(capability)) {
|
|
702
|
+
ctx.addIssue({
|
|
703
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
704
|
+
path: ['sandboxes', 'capabilities', index],
|
|
705
|
+
message: `the capability ${capability} is not in backend.capabilities.allow, no run may add it`,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
});
|
|
680
709
|
const sandboxNetwork = (0, work_network_1.parseWorkNetwork)(config.sandboxes.network);
|
|
681
710
|
const runsNetwork = (0, work_network_1.parseWorkNetwork)(config.runs.network);
|
|
682
711
|
if ((0, work_network_1.exceedsNetwork)(sandboxNetwork, runsNetwork)) {
|
package/dist/server/server.js
CHANGED
|
@@ -617,6 +617,7 @@ async function startServer(config, environment, options = {}) {
|
|
|
617
617
|
network: (0, work_network_1.parseWorkNetwork)(config.runs.network),
|
|
618
618
|
secretNetwork: (0, work_network_1.parseWorkNetwork)(config.runs.secretNetwork),
|
|
619
619
|
args: config.runs.args,
|
|
620
|
+
capabilities: config.backend.capabilities?.allow ?? [],
|
|
620
621
|
proxiedProviders: config.backend.type === 'kubernetes'
|
|
621
622
|
? Object.entries(config.backend.providers)
|
|
622
623
|
.filter(([, provider]) => provider.allow)
|
|
@@ -657,6 +658,8 @@ async function startServer(config, environment, options = {}) {
|
|
|
657
658
|
envs: config.sandboxes.env.allow,
|
|
658
659
|
envsFrom: 'sandboxes.env.allow',
|
|
659
660
|
runtimes: sandboxRuntimes,
|
|
661
|
+
capabilities: config.sandboxes.capabilities,
|
|
662
|
+
capabilitiesFrom: 'sandboxes.capabilities',
|
|
660
663
|
},
|
|
661
664
|
};
|
|
662
665
|
if (sandboxRuntimes.length === 0 && config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
|
|
@@ -676,6 +679,9 @@ async function startServer(config, environment, options = {}) {
|
|
|
676
679
|
cache: (0, path_1.join)(storage, 'cache'),
|
|
677
680
|
worker: options.worker,
|
|
678
681
|
sandboxes: { ...sandboxes, network: config.sandboxes.network },
|
|
682
|
+
...(config.backend.type === 'host' && config.backend.capabilities
|
|
683
|
+
? { capabilityRuntime: config.backend.capabilities.runtime }
|
|
684
|
+
: {}),
|
|
679
685
|
log,
|
|
680
686
|
};
|
|
681
687
|
const backend = config.backend;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hammerkit",
|
|
3
3
|
"description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
|
|
4
|
-
"version": "1.27.0-next.
|
|
4
|
+
"version": "1.27.0-next.24",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"build",
|
|
7
7
|
"build-tool",
|