hammerkit 1.27.0-next.3 → 1.27.0-next.31
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/registry-client.js +1 -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/dashboard/assets/index-CjnKm5-3.css +2 -0
- package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
- package/dist/dashboard/favicon.svg +1 -0
- package/dist/dashboard/index.html +15 -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/kubernetes-instance.js +1 -0
- 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 +33 -7
- package/dist/remote/remote-status.js +112 -0
- package/dist/remote/remote-work-commands.js +545 -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 +4 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/capacity-format.js +36 -0
- package/dist/server/capacity.js +300 -0
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/dashboard.js +196 -0
- package/dist/server/definitions-api.js +66 -3
- package/dist/server/endpoints.js +793 -0
- package/dist/server/executor.js +57 -5
- package/dist/server/kubernetes-backend.js +392 -65
- package/dist/server/kubernetes-sandbox-storage.js +48 -3
- package/dist/server/oauth.js +12 -1
- package/dist/server/overview.js +271 -0
- package/dist/server/plan.js +188 -103
- package/dist/server/policy.js +34 -2
- package/dist/server/proposals-api.js +17 -8
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-logs.js +4 -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 +532 -35
- 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 +277 -14
- package/dist/server/server.js +402 -33
- package/dist/server/service-runs.js +82 -0
- package/dist/server/timings.js +132 -0
- package/dist/server/wait-reason.js +21 -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 +8 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ensurePersistentData = exports.clearClaim = exports.getPodForPersistence = void 0;
|
|
3
|
+
exports.ensurePersistentData = exports.packSource = exports.clearClaim = exports.getPodForPersistence = void 0;
|
|
4
4
|
const apply_1 = require("./apply");
|
|
5
5
|
const path_1 = require("path");
|
|
6
|
-
const tar_1 = require("tar");
|
|
6
|
+
const tar_1 = require("../utils/tar");
|
|
7
7
|
const resources_1 = require("./resources");
|
|
8
8
|
const await_running_state_1 = require("./await-running-state");
|
|
9
9
|
const sleep_1 = require("../utils/sleep");
|
|
@@ -67,17 +67,14 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
67
67
|
for (const volume of persistence.volumes) {
|
|
68
68
|
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
|
|
69
69
|
}
|
|
70
|
-
const name = (0, resources_1.getResourceName)(service, type === 'write' ? '-upload' : '-download');
|
|
70
|
+
const name = (0, resources_1.getResourceName)(service, env, type === 'write' ? '-upload' : '-download');
|
|
71
71
|
const podSpec = {
|
|
72
72
|
kind: 'Pod',
|
|
73
73
|
apiVersion: 'v1',
|
|
74
74
|
metadata: {
|
|
75
75
|
namespace: env.namespace,
|
|
76
76
|
name,
|
|
77
|
-
labels:
|
|
78
|
-
'hammerkit.dev/id': service.id(),
|
|
79
|
-
...(0, job_placement_1.getRunLabels)(env),
|
|
80
|
-
},
|
|
77
|
+
labels: (0, resources_1.getItemLabels)(service, env),
|
|
81
78
|
annotations: {
|
|
82
79
|
'hammerkit.dev/version': (0, version_1.getVersion)(),
|
|
83
80
|
},
|
|
@@ -104,18 +101,18 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
104
101
|
volumes: persistence.volumes,
|
|
105
102
|
},
|
|
106
103
|
};
|
|
107
|
-
service.status.
|
|
104
|
+
service.status.write('debug', `create pod ${name}`);
|
|
108
105
|
await removeStalePod(instance, env.namespace, name);
|
|
109
106
|
const pod = await (0, apply_1.apply)(instance, podSpec);
|
|
110
107
|
if (pod.status?.phase !== 'Running') {
|
|
111
|
-
service.status.
|
|
108
|
+
service.status.write('debug', `await running state ${name}`);
|
|
112
109
|
await (0, await_running_state_1.awaitRunningState)(instance, env, name, 'Running');
|
|
113
110
|
}
|
|
114
111
|
try {
|
|
115
112
|
await fn(name);
|
|
116
113
|
}
|
|
117
114
|
finally {
|
|
118
|
-
service.status.
|
|
115
|
+
service.status.write('debug', `delete pod ${name}`);
|
|
119
116
|
await instance.coreApi.deleteNamespacedPod({ name, namespace: env.namespace });
|
|
120
117
|
}
|
|
121
118
|
}
|
|
@@ -124,7 +121,7 @@ exports.getPodForPersistence = getPodForPersistence;
|
|
|
124
121
|
// the task runs on it or outputs are restored into it, so it holds what a new one would
|
|
125
122
|
async function clearClaim(instance, env, work, pod) {
|
|
126
123
|
const targetPath = `/dev/hammerkit/${work.id()}`;
|
|
127
|
-
work.status.
|
|
124
|
+
work.status.write('info', `clear what an earlier run left in ${targetPath}`);
|
|
128
125
|
let failure = null;
|
|
129
126
|
const res = await instance.exec.exec(env.namespace, pod, 'volume', ['find', targetPath, '-mindepth', '1', '-maxdepth', '1', '-exec', 'rm', '-rf', '{}', '+'], process.stdout, process.stderr, null, false, (status) => {
|
|
130
127
|
if (status.status !== 'Success') {
|
|
@@ -137,6 +134,25 @@ async function clearClaim(instance, env, work, pod) {
|
|
|
137
134
|
});
|
|
138
135
|
}
|
|
139
136
|
exports.clearClaim = clearClaim;
|
|
137
|
+
// The archive that uploads a source into the claim: its file or directory, at its place in
|
|
138
|
+
// the claim (see getClaimSubPath) instead of under its own name
|
|
139
|
+
function packSource(source) {
|
|
140
|
+
const fileItem = (0, path_1.basename)(source.localPath);
|
|
141
|
+
return stream_1.Readable.from((0, tar_1.packArchive)({
|
|
142
|
+
cwd: (0, path_1.dirname)(source.localPath),
|
|
143
|
+
filter(path) {
|
|
144
|
+
return source.matcher(path, source.localPath);
|
|
145
|
+
},
|
|
146
|
+
onWriteEntry(entry) {
|
|
147
|
+
entry.path = source.subPath + entry.path.slice(fileItem.length);
|
|
148
|
+
// the second of two hard-linked files links to the first by its path in the archive
|
|
149
|
+
if (entry.type === 'Link' && entry.linkpath?.startsWith(fileItem)) {
|
|
150
|
+
entry.linkpath = source.subPath + entry.linkpath.slice(fileItem.length);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}, [fileItem]));
|
|
154
|
+
}
|
|
155
|
+
exports.packSource = packSource;
|
|
140
156
|
async function ensurePersistentData(instance, env, environment, service, persistence,
|
|
141
157
|
// the kept claim of the work holds what an earlier run left, which goes first
|
|
142
158
|
options = {}) {
|
|
@@ -153,7 +169,7 @@ options = {}) {
|
|
|
153
169
|
if (persistence.sources.length === 0 && !options.reset) {
|
|
154
170
|
return;
|
|
155
171
|
}
|
|
156
|
-
service.status.
|
|
172
|
+
service.status.write('debug', 'start upload container');
|
|
157
173
|
await getPodForPersistence(instance, env, service, persistence, 'write', async (name) => {
|
|
158
174
|
if (options.reset) {
|
|
159
175
|
await clearClaim(instance, env, service, name);
|
|
@@ -163,14 +179,8 @@ options = {}) {
|
|
|
163
179
|
if (!(await environment.file.exists(source.localPath))) {
|
|
164
180
|
continue;
|
|
165
181
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
cwd: (0, path_1.dirname)(source.localPath),
|
|
169
|
-
filter(path) {
|
|
170
|
-
return source.matcher(path, source.localPath);
|
|
171
|
-
},
|
|
172
|
-
}, [fileItem]));
|
|
173
|
-
service.status.console('stdout', `upload ${source.localPath}`);
|
|
182
|
+
const data = packSource(source);
|
|
183
|
+
service.status.write('info', `upload ${source.localPath}`);
|
|
174
184
|
const targetPath = `/dev/hammerkit/${service.id()}`;
|
|
175
185
|
const res = await instance.exec.exec(env.namespace, name, 'volume', ['tar', 'xf', '-', '-C', targetPath], process.stdout, process.stderr, data, false, (status) => {
|
|
176
186
|
service.status.write('debug', `upload ${source.localPath} to ${name}: ${status}`);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.followPodLogs = void 0;
|
|
4
|
+
const stream_1 = require("stream");
|
|
5
|
+
const readline_1 = require("readline");
|
|
6
|
+
const log_1 = require("../log");
|
|
7
|
+
// what is read of an instance of a container that already ended
|
|
8
|
+
const MAX_ENDED_LOG_BYTES = 1024 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Follows the log of a container in the pods of a selector, as `docker attach` does for a
|
|
11
|
+
* service on Docker: the instance that runs as it writes, and an instance that ended before
|
|
12
|
+
* it was seen (one that crashed and was restarted) once, from its start. Each instance is
|
|
13
|
+
* read once, by its container id.
|
|
14
|
+
*/
|
|
15
|
+
function followPodLogs(instance, namespace, labelSelector, container, onLine, options = {}) {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const following = [];
|
|
18
|
+
const reading = new Set();
|
|
19
|
+
let stopped = false;
|
|
20
|
+
let failed = false;
|
|
21
|
+
let timer;
|
|
22
|
+
let polling = Promise.resolve();
|
|
23
|
+
let busy = false;
|
|
24
|
+
let again = false;
|
|
25
|
+
// the logs followed now, which end when their instance does
|
|
26
|
+
let streaming = 0;
|
|
27
|
+
const fail = (e) => {
|
|
28
|
+
if (!failed && !stopped) {
|
|
29
|
+
failed = true;
|
|
30
|
+
options.onError?.(`the log of ${container} could not be read: ${(0, log_1.getErrorMessage)(e)}`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const track = (promise) => {
|
|
34
|
+
const tracked = promise.catch(fail).finally(() => reading.delete(tracked));
|
|
35
|
+
reading.add(tracked);
|
|
36
|
+
};
|
|
37
|
+
const readEnded = async (pod, previous) => {
|
|
38
|
+
const log = await instance.coreApi.readNamespacedPodLog({
|
|
39
|
+
name: pod,
|
|
40
|
+
namespace,
|
|
41
|
+
container,
|
|
42
|
+
previous,
|
|
43
|
+
limitBytes: MAX_ENDED_LOG_BYTES,
|
|
44
|
+
});
|
|
45
|
+
for (const line of String(log ?? '').split(/\r?\n/)) {
|
|
46
|
+
if (line.length > 0) {
|
|
47
|
+
onLine(line);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const follow = async (pod) => {
|
|
52
|
+
const output = new stream_1.PassThrough();
|
|
53
|
+
let ended = false;
|
|
54
|
+
// the instance ended or the request broke: a restarted one is looked for at once
|
|
55
|
+
const end = () => {
|
|
56
|
+
if (!ended) {
|
|
57
|
+
ended = true;
|
|
58
|
+
streaming--;
|
|
59
|
+
wake();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
// stop() aborts the request, which errors the stream the client pipes in here
|
|
63
|
+
output.on('pipe', (source) => source.on('error', () => {
|
|
64
|
+
end();
|
|
65
|
+
}));
|
|
66
|
+
output.on('end', end);
|
|
67
|
+
(0, readline_1.createInterface)({ input: output }).on('line', (line) => onLine(line));
|
|
68
|
+
streaming++;
|
|
69
|
+
let controller;
|
|
70
|
+
try {
|
|
71
|
+
controller = await instance.log.log(namespace, pod, container, output, { follow: true });
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
end();
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
if (stopped) {
|
|
78
|
+
controller.abort();
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
following.push(controller);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const look = (pod) => {
|
|
85
|
+
const name = pod.metadata?.name;
|
|
86
|
+
const status = pod.status?.containerStatuses?.find((s) => s.name === container);
|
|
87
|
+
// a pod that goes is of a Deployment before this one, or of a rollout that replaced it
|
|
88
|
+
if (!name || !status || pod.metadata?.deletionTimestamp) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// an instance that ended before this one, not seen while it ran: its lines go first
|
|
92
|
+
let before = Promise.resolve();
|
|
93
|
+
const last = status.lastState?.terminated?.containerID;
|
|
94
|
+
if (last && !seen.has(last)) {
|
|
95
|
+
seen.add(last);
|
|
96
|
+
before = readEnded(name, true).catch(fail);
|
|
97
|
+
track(before);
|
|
98
|
+
}
|
|
99
|
+
const current = status.containerID;
|
|
100
|
+
if (!current || seen.has(current)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (status.state?.running) {
|
|
104
|
+
seen.add(current);
|
|
105
|
+
track(before.then(() => follow(name)));
|
|
106
|
+
}
|
|
107
|
+
else if (status.state?.terminated) {
|
|
108
|
+
seen.add(current);
|
|
109
|
+
track(before.then(() => readEnded(name, false)));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const poll = async () => {
|
|
113
|
+
try {
|
|
114
|
+
const pods = await instance.coreApi.listNamespacedPod({ namespace, labelSelector });
|
|
115
|
+
if (!stopped) {
|
|
116
|
+
pods.items.forEach(look);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
fail(e);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const loop = () => {
|
|
124
|
+
if (stopped) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
busy = true;
|
|
128
|
+
polling = poll().finally(() => {
|
|
129
|
+
busy = false;
|
|
130
|
+
if (stopped) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (again) {
|
|
134
|
+
again = false;
|
|
135
|
+
loop();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const interval = streaming > 0 ? options.followingInterval ?? 15000 : options.pollInterval ?? 1000;
|
|
139
|
+
timer = setTimeout(loop, interval);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
// looks now, not at the next interval
|
|
144
|
+
function wake() {
|
|
145
|
+
if (stopped) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (busy) {
|
|
149
|
+
again = true;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
loop();
|
|
154
|
+
}
|
|
155
|
+
loop();
|
|
156
|
+
return {
|
|
157
|
+
async settle() {
|
|
158
|
+
await polling;
|
|
159
|
+
await poll();
|
|
160
|
+
// what follows a running instance never ends by itself
|
|
161
|
+
const ended = [...reading];
|
|
162
|
+
let wait;
|
|
163
|
+
await Promise.race([
|
|
164
|
+
Promise.all(ended),
|
|
165
|
+
new Promise((resolve) => {
|
|
166
|
+
wait = setTimeout(resolve, 5000);
|
|
167
|
+
wait.unref?.();
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
clearTimeout(wait);
|
|
171
|
+
},
|
|
172
|
+
stop() {
|
|
173
|
+
stopped = true;
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
for (const controller of following.splice(0)) {
|
|
176
|
+
controller.abort();
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
exports.followPodLogs = followPodLogs;
|
|
182
|
+
//# sourceMappingURL=follow-pod-logs.js.map
|
|
@@ -5,7 +5,7 @@ const resources_1 = require("./resources");
|
|
|
5
5
|
// The address a task reaches the service on: its clusterIP, or for a headless
|
|
6
6
|
// Service (a service without ports) the address of its ready pod.
|
|
7
7
|
async function getServiceIp(instance, env, workService) {
|
|
8
|
-
const name = (0, resources_1.getResourceName)(workService);
|
|
8
|
+
const name = (0, resources_1.getResourceName)(workService, env);
|
|
9
9
|
const svc = await instance.coreApi.readNamespacedService({ name, namespace: env.namespace });
|
|
10
10
|
const clusterIP = svc.spec?.clusterIP ?? null;
|
|
11
11
|
if (clusterIP !== 'None') {
|
|
@@ -13,7 +13,7 @@ async function getServiceIp(instance, env, workService) {
|
|
|
13
13
|
}
|
|
14
14
|
const pods = await instance.coreApi.listNamespacedPod({
|
|
15
15
|
namespace: env.namespace,
|
|
16
|
-
labelSelector:
|
|
16
|
+
labelSelector: (0, resources_1.getItemSelector)(workService, env),
|
|
17
17
|
});
|
|
18
18
|
const ready = pods.items.find((pod) => !pod.metadata?.deletionTimestamp &&
|
|
19
19
|
pod.status?.podIP &&
|
|
@@ -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
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getJobSecretSpec = exports.removeJobCredentials = exports.assertJobAccounts = exports.ensureJobCredentials = exports.getCredentialsName = exports.
|
|
3
|
+
exports.getJobSecretSpec = exports.removeJobCredentials = exports.assertJobAccounts = exports.ensureJobCredentials = exports.getCredentialsName = exports.getSecretHosts = exports.splitJobSecrets = exports.ACCOUNT_PATH = exports.SECRET_VALUES_PATH = void 0;
|
|
4
4
|
const work_secret_1 = require("../planner/work-secret");
|
|
5
5
|
const command_secret_provider_1 = require("../secrets/command-secret-provider");
|
|
6
|
+
const task_arguments_1 = require("../planner/utils/task-arguments");
|
|
6
7
|
const version_1 = require("../version");
|
|
7
8
|
const apply_1 = require("./apply");
|
|
8
9
|
const resources_1 = require("./resources");
|
|
@@ -42,25 +43,30 @@ function splitJobSecrets(secrets, server) {
|
|
|
42
43
|
exports.splitJobSecrets = splitJobSecrets;
|
|
43
44
|
/**
|
|
44
45
|
* The init containers that read secrets share the pod, and so the NetworkPolicy, of
|
|
45
|
-
* the task: under a limited network they
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* the task: under a limited network they reach the secret manager only through the
|
|
47
|
+
* task's egress proxy, which lets them reach the endpoints the server names for the
|
|
48
|
+
* provider (`backend.providers.<name>.allow`). A task that reads a secret of a provider
|
|
49
|
+
* without them keeps an open network. Answers the endpoints the proxy lets through.
|
|
48
50
|
*/
|
|
49
|
-
function
|
|
50
|
-
if (secrets.length === 0 || network.type === 'open') {
|
|
51
|
-
return;
|
|
51
|
+
function getSecretHosts(task, secrets, server, network = (0, work_network_1.getEffectiveNetwork)(task.data)) {
|
|
52
|
+
if (!server || secrets.length === 0 || network.type === 'open') {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const unreachable = secrets.find((secret) => !server.providers[secret.source.provider]?.allow?.length);
|
|
56
|
+
if (unreachable) {
|
|
57
|
+
throw new Error(`task ${task.name}: secret ${(0, work_secret_1.getSecretName)(unreachable.secret)} is read from ${(0, work_secret_1.describeSecretSource)(unreachable.secret)} in the Job of the task, which network ${(0, work_network_1.formatNetwork)(network)} would cut off from it; ` +
|
|
58
|
+
`on this cluster a task that reads secrets of ${unreachable.source.provider} keeps an open network, ` +
|
|
59
|
+
`unless the server names its endpoints in backend.providers.${unreachable.source.provider}.allow`);
|
|
52
60
|
}
|
|
53
|
-
|
|
54
|
-
throw new Error(`task ${task.name}: secret ${(0, work_secret_1.getSecretName)(secret.secret)} is read from ${(0, work_secret_1.describeSecretSource)(secret.secret)} in the Job of the task, which network ${(0, work_network_1.formatNetwork)(network)} would cut off from it; ` +
|
|
55
|
-
'on this cluster a task that reads secrets of a provider keeps an open network');
|
|
61
|
+
return [...new Set(secrets.flatMap((secret) => server.providers[secret.source.provider].allow ?? []))].sort();
|
|
56
62
|
}
|
|
57
|
-
exports.
|
|
63
|
+
exports.getSecretHosts = getSecretHosts;
|
|
58
64
|
// The accounts of the secrets, in the order they are first read
|
|
59
65
|
function accountsOf(secrets) {
|
|
60
66
|
return [...new Set(secrets.map((s) => s.source.account))];
|
|
61
67
|
}
|
|
62
|
-
function getCredentialsName(item) {
|
|
63
|
-
return (0, resources_1.getResourceName)(item, '-credentials');
|
|
68
|
+
function getCredentialsName(item, kubernetes) {
|
|
69
|
+
return (0, resources_1.getResourceName)(item, kubernetes, '-credentials');
|
|
64
70
|
}
|
|
65
71
|
exports.getCredentialsName = getCredentialsName;
|
|
66
72
|
// The keys of the credentials Secret: `<account>.token` and `<account>.file.<key>`
|
|
@@ -130,9 +136,9 @@ async function ensureJobCredentials(instance, kubernetes, item, secrets, admitte
|
|
|
130
136
|
apiVersion: 'v1',
|
|
131
137
|
metadata: {
|
|
132
138
|
namespace: kubernetes.namespace,
|
|
133
|
-
name: getCredentialsName(item),
|
|
139
|
+
name: getCredentialsName(item, kubernetes),
|
|
134
140
|
annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
|
|
135
|
-
labels:
|
|
141
|
+
labels: (0, resources_1.getItemLabels)(item, kubernetes),
|
|
136
142
|
},
|
|
137
143
|
type: 'Opaque',
|
|
138
144
|
data,
|
|
@@ -163,7 +169,10 @@ async function removeJobCredentials(instance, kubernetes, item) {
|
|
|
163
169
|
return;
|
|
164
170
|
}
|
|
165
171
|
try {
|
|
166
|
-
await instance.coreApi.deleteNamespacedSecret({
|
|
172
|
+
await instance.coreApi.deleteNamespacedSecret({
|
|
173
|
+
name: getCredentialsName(item, kubernetes),
|
|
174
|
+
namespace: kubernetes.namespace,
|
|
175
|
+
});
|
|
167
176
|
}
|
|
168
177
|
catch (e) {
|
|
169
178
|
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
@@ -186,7 +195,9 @@ function quote(value) {
|
|
|
186
195
|
* from it, and the task's output is masked before it leaves the pod, since the
|
|
187
196
|
* server, which masks everything else, never sees these values.
|
|
188
197
|
*/
|
|
189
|
-
function getJobSecretSpec(item, secrets, server, credentials, environment
|
|
198
|
+
function getJobSecretSpec(item, secrets, server, credentials, environment,
|
|
199
|
+
// under a limited network, what sends their requests through the egress proxy
|
|
200
|
+
proxyEnv = []) {
|
|
190
201
|
if (!server || secrets.length === 0) {
|
|
191
202
|
return {
|
|
192
203
|
initContainers: [],
|
|
@@ -205,7 +216,7 @@ function getJobSecretSpec(item, secrets, server, credentials, environment) {
|
|
|
205
216
|
}
|
|
206
217
|
const name = `hammerkit-account-${index}`;
|
|
207
218
|
accountVolume.set(account, name);
|
|
208
|
-
volumes.push({ name, secret: { secretName: getCredentialsName(item), items, defaultMode: 0o444 } });
|
|
219
|
+
volumes.push({ name, secret: { secretName: getCredentialsName(item, { server }), items, defaultMode: 0o444 } });
|
|
209
220
|
});
|
|
210
221
|
const initContainers = secrets.map(({ index, secret, source }) => {
|
|
211
222
|
const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
|
|
@@ -229,8 +240,11 @@ function getJobSecretSpec(item, secrets, server, credentials, environment) {
|
|
|
229
240
|
'-c',
|
|
230
241
|
'umask 022 && "$@" > "$HAMMERKIT_VALUE.part" && mv "$HAMMERKIT_VALUE.part" "$HAMMERKIT_VALUE"',
|
|
231
242
|
],
|
|
232
|
-
|
|
243
|
+
// Kubernetes would expand a `$(NAME)` of the container's env in them, the proxy's token
|
|
244
|
+
// among them: the reference is text, as on any other backend
|
|
245
|
+
args: ['sh', command, ...args].map(task_arguments_1.escapeKubernetesText),
|
|
233
246
|
env: [
|
|
247
|
+
...proxyEnv,
|
|
234
248
|
...Object.entries(env).map(([name, value]) => ({ name, value })),
|
|
235
249
|
{ name: 'HAMMERKIT_VALUE', value: `${exports.SECRET_VALUES_PATH}/secret-${index}` },
|
|
236
250
|
],
|
|
@@ -5,6 +5,7 @@ const crypto_1 = require("crypto");
|
|
|
5
5
|
const apply_1 = require("./apply");
|
|
6
6
|
const sleep_1 = require("../utils/sleep");
|
|
7
7
|
const job_placement_1 = require("./job-placement");
|
|
8
|
+
const resources_1 = require("./resources");
|
|
8
9
|
// On a server with `backend.keepVolumes`, the claim of a task stays after the run, so the
|
|
9
10
|
// next run of the same source finds the outputs in it and mounts them without restoring
|
|
10
11
|
// them from the cache. A kept claim has no run label (the end of the run removes what has
|
|
@@ -18,11 +19,15 @@ exports.STATE_ANNOTATION = 'hammerkit.dev/state';
|
|
|
18
19
|
// when it goes, set by the end of the run that mounted it last
|
|
19
20
|
exports.EXPIRES_ANNOTATION = 'hammerkit.dev/expires';
|
|
20
21
|
// The claim of a work item: a task's on a server that keeps them carries the scope, so a
|
|
21
|
-
// run of another source never mounts it
|
|
22
|
+
// run of another source never mounts it, and the runs of one scope go one at a time
|
|
23
|
+
// (KubernetesExecutor.exclusive). Any other claim on a server is the run's own, as the
|
|
24
|
+
// runs of a caller share their namespace. 63 characters at most, a pod's volume name.
|
|
22
25
|
function getClaimName(work, kubernetes) {
|
|
23
|
-
const scope = kubernetes
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
const scope = kubernetes.server?.volumeScope;
|
|
27
|
+
if (scope && isTask(work)) {
|
|
28
|
+
return `hammerkit-${work.id()}-${(0, crypto_1.createHash)('sha256').update(scope).digest('hex').slice(0, 12)}`;
|
|
29
|
+
}
|
|
30
|
+
return 'hammerkit-' + (0, resources_1.getItemKey)(work, kubernetes);
|
|
26
31
|
}
|
|
27
32
|
exports.getClaimName = getClaimName;
|
|
28
33
|
// whether the claim of the work item stays after the run
|
|
@@ -156,14 +161,17 @@ async function expireKeptClaims(instance, namespace, runLabel, expires) {
|
|
|
156
161
|
}
|
|
157
162
|
}
|
|
158
163
|
exports.expireKeptClaims = expireKeptClaims;
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
// the others expires.
|
|
164
|
+
// The kept claims that expired, and those a run left without an expiry because the server
|
|
165
|
+
// stopped before its end (all of them when the server keeps none any more), but those of
|
|
166
|
+
// the scopes `inUse`: a run of the scope executes and mounts them, or may in a moment.
|
|
167
|
+
// The names of those removed, and when the next of the others expires.
|
|
163
168
|
async function sweepKeptClaims(instance, namespace, options) {
|
|
164
169
|
const expired = [];
|
|
165
170
|
let next = null;
|
|
166
171
|
for (const claim of await listKeptClaims(instance, namespace)) {
|
|
172
|
+
if (options.inUse?.has(claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] ?? '')) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
167
175
|
const expires = Date.parse(claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION] ?? '');
|
|
168
176
|
if (!options.keep || !(expires > options.now.getTime())) {
|
|
169
177
|
expired.push(claim);
|
|
@@ -178,14 +186,18 @@ async function sweepKeptClaims(instance, namespace, options) {
|
|
|
178
186
|
exports.sweepKeptClaims = sweepKeptClaims;
|
|
179
187
|
// When a run needs room under the quota of its namespace: the kept claims it does not use
|
|
180
188
|
// go, those of the other sources and those of its own a task of this run did not take (one
|
|
181
|
-
// renamed or changed since).
|
|
189
|
+
// renamed or changed since). Of another scope, only one its last run released, which has an
|
|
190
|
+
// expiry: one without is mounted by a run of the caller that executes now (or was left by
|
|
191
|
+
// a server that stopped, which the next sweep removes). The names of those removed.
|
|
182
192
|
async function evictKeptClaims(instance, kubernetes, timeout) {
|
|
183
193
|
const server = kubernetes.server;
|
|
184
194
|
if (!server?.volumeScope) {
|
|
185
195
|
return [];
|
|
186
196
|
}
|
|
187
197
|
const unused = await listKeptClaims(instance, kubernetes.namespace, `${exports.VOLUME_SCOPE_LABEL},${exports.KEPT_BY_LABEL}!=${(0, job_placement_1.getRunLabelValue)(server.run)}`);
|
|
188
|
-
|
|
198
|
+
const released = unused.filter((claim) => claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] === server.volumeScope ||
|
|
199
|
+
!!claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION]);
|
|
200
|
+
return removeClaims(instance, kubernetes.namespace, released, timeout);
|
|
189
201
|
}
|
|
190
202
|
exports.evictKeptClaims = evictKeptClaims;
|
|
191
203
|
//# sourceMappingURL=kept-claims.js.map
|