hammerkit 1.27.0-next.8 → 2.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.schema.json +176 -6
- package/dist/cache/backends/local-cache-backend.js +6 -0
- package/dist/cache/backends/registry/registry-client.js +1 -0
- package/dist/cache/cache-sync.js +1 -1
- 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/archive-task-entry.js +4 -2
- 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/execute-work-service.js +2 -0
- package/dist/executer/execute-work-task.js +11 -4
- 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 +6 -3
- 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 +194 -23
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +76 -17
- package/dist/kubernetes/await-running-state.js +172 -13
- 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 +138 -18
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
- package/dist/kubernetes/ensure-persistent-data.js +212 -10
- 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 +9 -6
- package/dist/kubernetes/kept-claims.js +22 -10
- package/dist/kubernetes/kubernetes-instance.js +1 -0
- package/dist/kubernetes/network-policy.js +16 -8
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/remove-stale-items.js +46 -0
- package/dist/kubernetes/resources.js +25 -3
- package/dist/kubernetes/restore-kubernetes-data.js +9 -5
- package/dist/kubernetes/sandbox-runner.js +628 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/service-rollouts.js +74 -0
- package/dist/kubernetes/store-kubernetes-data.js +5 -2
- package/dist/kubernetes/volumes.js +96 -11
- package/dist/main.js +2 -0
- 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 +49 -1
- package/dist/planner/service-definition.js +9 -4
- package/dist/planner/utils/append-work-dependencies.js +5 -3
- package/dist/planner/utils/append-work-service.js +18 -1
- package/dist/planner/utils/append-work-task.js +13 -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/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-kubernetes.js +153 -26
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +27 -9
- 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 +562 -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 +3 -3
- package/dist/schema/build-file-container-service-schema.js +10 -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 +119 -11
- package/dist/schema/removed-properties.js +20 -0
- package/dist/schema/schema-parser.js +94 -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 +794 -0
- package/dist/server/executor.js +164 -9
- package/dist/server/kubernetes-backend.js +478 -70
- package/dist/server/kubernetes-sandbox-storage.js +46 -2
- package/dist/server/overview.js +271 -0
- package/dist/server/plan.js +170 -121
- 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 +38 -4
- package/dist/server/run-request.js +58 -3
- package/dist/server/run-worker.js +262 -13
- package/dist/server/runs.js +969 -36
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-config.js +246 -10
- package/dist/server/server.js +429 -32
- 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/tar.js +28 -1
- package/dist/utils/terminal-prompt.js +53 -0
- package/package.json +8 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ensurePersistentData = exports.packSource = exports.clearClaim = exports.getPodForPersistence = void 0;
|
|
3
|
+
exports.ensurePersistentData = exports.packSource = exports.restoreLiveFiles = exports.syncLiveFiles = exports.SYNC_FILES = exports.getStateDirsInUse = exports.REMOVE_OTHER_STATES = exports.execInPod = exports.clearClaim = exports.getPodForPersistence = void 0;
|
|
4
|
+
const volumes_1 = require("./volumes");
|
|
4
5
|
const apply_1 = require("./apply");
|
|
5
6
|
const path_1 = require("path");
|
|
6
7
|
const tar_1 = require("../utils/tar");
|
|
@@ -67,22 +68,25 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
67
68
|
for (const volume of persistence.volumes) {
|
|
68
69
|
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
|
|
69
70
|
}
|
|
70
|
-
const name = (0, resources_1.getResourceName)(service, type === 'write' ? '-upload' : '-download');
|
|
71
|
+
const name = (0, resources_1.getResourceName)(service, env, type === 'write' ? '-upload' : '-download');
|
|
72
|
+
// a service of a service run is up while its claim takes an upload (a move to another
|
|
73
|
+
// commit): the pod is not one its Service sends to
|
|
74
|
+
const serving = !!env.server?.serviceRun && service.data.type === 'container-service';
|
|
75
|
+
// and on a service run, it goes where the pods that mount the same claims run
|
|
76
|
+
const claims = env.server?.serviceRun ? (0, volumes_1.getClaimPodSpec)(persistence.volumes) : { labels: {} };
|
|
71
77
|
const podSpec = {
|
|
72
78
|
kind: 'Pod',
|
|
73
79
|
apiVersion: 'v1',
|
|
74
80
|
metadata: {
|
|
75
81
|
namespace: env.namespace,
|
|
76
82
|
name,
|
|
77
|
-
labels: {
|
|
78
|
-
'hammerkit.dev/id': service.id(),
|
|
79
|
-
...(0, job_placement_1.getRunLabels)(env),
|
|
80
|
-
},
|
|
83
|
+
labels: { ...(serving ? (0, job_placement_1.getRunLabels)(env) : (0, resources_1.getItemLabels)(service, env)), ...claims.labels },
|
|
81
84
|
annotations: {
|
|
82
85
|
'hammerkit.dev/version': (0, version_1.getVersion)(),
|
|
83
86
|
},
|
|
84
87
|
},
|
|
85
88
|
spec: {
|
|
89
|
+
...(claims.affinity ? { affinity: claims.affinity } : {}),
|
|
86
90
|
// on a server the claims are bound where the pod of the task can go: the nodes
|
|
87
91
|
// of its pool
|
|
88
92
|
...(env.server
|
|
@@ -125,18 +129,182 @@ exports.getPodForPersistence = getPodForPersistence;
|
|
|
125
129
|
async function clearClaim(instance, env, work, pod) {
|
|
126
130
|
const targetPath = `/dev/hammerkit/${work.id()}`;
|
|
127
131
|
work.status.write('info', `clear what an earlier run left in ${targetPath}`);
|
|
132
|
+
const failure = await execInPod(instance, env, pod, [
|
|
133
|
+
'find',
|
|
134
|
+
targetPath,
|
|
135
|
+
'-mindepth',
|
|
136
|
+
'1',
|
|
137
|
+
'-maxdepth',
|
|
138
|
+
'1',
|
|
139
|
+
'-exec',
|
|
140
|
+
'rm',
|
|
141
|
+
'-rf',
|
|
142
|
+
'{}',
|
|
143
|
+
'+',
|
|
144
|
+
]);
|
|
145
|
+
if (failure) {
|
|
146
|
+
throw new Error(`unable to clear ${targetPath} in ${pod}: ${failure}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
exports.clearClaim = clearClaim;
|
|
150
|
+
// Runs `command` in the upload pod `pod`: what failed, or null when it went well
|
|
151
|
+
async function execInPod(instance, env, pod, command) {
|
|
128
152
|
let failure = null;
|
|
129
|
-
const res = await instance.exec.exec(env.namespace, pod, 'volume',
|
|
153
|
+
const res = await instance.exec.exec(env.namespace, pod, 'volume', command, process.stdout, process.stderr, null, false, (status) => {
|
|
130
154
|
if (status.status !== 'Success') {
|
|
131
155
|
failure = status.message ?? status.reason ?? 'unknown';
|
|
132
156
|
}
|
|
133
157
|
});
|
|
134
158
|
await new Promise((resolve, reject) => {
|
|
135
159
|
res.on('error', reject);
|
|
136
|
-
res.on('close', () =>
|
|
160
|
+
res.on('close', () => resolve());
|
|
137
161
|
});
|
|
162
|
+
return failure;
|
|
163
|
+
}
|
|
164
|
+
exports.execInPod = execInPod;
|
|
165
|
+
// marks the folder of a state whose upload went through
|
|
166
|
+
const UPLOADED = '.uploaded';
|
|
167
|
+
// Removes the folders of the states (see getStateFilesDir) in the claim at `$1` but those
|
|
168
|
+
// named after it: the one the work runs at, and the ones pods of the run mount
|
|
169
|
+
// (it says how it went itself: a loop ends with the status of its last test in some shells)
|
|
170
|
+
exports.REMOVE_OTHER_STATES = `cd "$1" || exit 1
|
|
171
|
+
shift
|
|
172
|
+
for d in ${volumes_1.STATE_FILES_PREFIX}*; do
|
|
173
|
+
if [ ! -d "$d" ]; then continue; fi
|
|
174
|
+
keep=
|
|
175
|
+
for k in "$@"; do
|
|
176
|
+
if [ "$d" = "$k" ]; then keep=1; fi
|
|
177
|
+
done
|
|
178
|
+
if [ -z "$keep" ]; then rm -rf "$d" || exit 1; fi
|
|
179
|
+
done
|
|
180
|
+
exit 0`;
|
|
181
|
+
/**
|
|
182
|
+
* The folders of the states (see getStateFilesDir) of the claim `claim` that the services of
|
|
183
|
+
* the run mount: the pods of its Deployments, which serve or start. A pod of a service mounts
|
|
184
|
+
* the outputs of a task only after the task completed, so what it mounts is whole.
|
|
185
|
+
*/
|
|
186
|
+
async function getStateDirsInUse(instance, namespace, claim) {
|
|
187
|
+
const pods = await instance.coreApi.listNamespacedPod({ namespace });
|
|
188
|
+
const dirs = new Set();
|
|
189
|
+
for (const pod of pods.items) {
|
|
190
|
+
const phase = pod.status?.phase;
|
|
191
|
+
if ((phase !== 'Running' && phase !== 'Pending') ||
|
|
192
|
+
!pod.metadata?.ownerReferences?.some((owner) => owner.kind === 'ReplicaSet')) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const volumes = new Set((pod.spec?.volumes ?? [])
|
|
196
|
+
.filter((volume) => volume.persistentVolumeClaim?.claimName === claim)
|
|
197
|
+
.map((volume) => volume.name));
|
|
198
|
+
for (const container of [...(pod.spec?.initContainers ?? []), ...(pod.spec?.containers ?? [])]) {
|
|
199
|
+
for (const mount of container.volumeMounts ?? []) {
|
|
200
|
+
const dir = mount.subPath?.split('/')[0];
|
|
201
|
+
if (volumes.has(mount.name) && dir?.startsWith(volumes_1.STATE_FILES_PREFIX)) {
|
|
202
|
+
dirs.add(dir);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return dirs;
|
|
208
|
+
}
|
|
209
|
+
exports.getStateDirsInUse = getStateDirsInUse;
|
|
210
|
+
// Syncs the files in the claim from the folder at `$1` into the folder at `$2` (see
|
|
211
|
+
// LIVE_FILES_DIR), which the pod of a service mounts while it runs: what is new or changed
|
|
212
|
+
// is written, what is gone is removed, and nothing else is touched, so the service sees only
|
|
213
|
+
// the changes. A file is written in place, and the paths the pod mounts (the others) are never
|
|
214
|
+
// removed: the pod holds them by their inode, and would not see one made again.
|
|
215
|
+
exports.SYNC_FILES = `src="$1"
|
|
216
|
+
dst="$2"
|
|
217
|
+
shift 2
|
|
218
|
+
if [ ! -d "$src" ]; then echo "no files at $src" >&2; exit 1; fi
|
|
219
|
+
mkdir -p "$dst" || exit 1
|
|
220
|
+
cd "$src" || exit 1
|
|
221
|
+
find . -mindepth 1 ! -path ./${UPLOADED} | while IFS= read -r f; do
|
|
222
|
+
t="$dst/$f"
|
|
223
|
+
if [ -L "$f" ]; then
|
|
224
|
+
l=$(readlink "$f")
|
|
225
|
+
if [ -L "$t" ] && [ "$(readlink "$t")" = "$l" ]; then continue; fi
|
|
226
|
+
rm -rf "$t" && ln -s "$l" "$t" || exit 1
|
|
227
|
+
elif [ -d "$f" ]; then
|
|
228
|
+
if [ -L "$t" ] || { [ -e "$t" ] && [ ! -d "$t" ]; }; then rm -rf "$t" || exit 1; fi
|
|
229
|
+
mkdir -p "$t" || exit 1
|
|
230
|
+
else
|
|
231
|
+
if [ -L "$t" ] || [ -d "$t" ]; then rm -rf "$t" || exit 1; fi
|
|
232
|
+
if [ -e "$t" ]; then
|
|
233
|
+
if cmp -s "$f" "$t"; then continue; fi
|
|
234
|
+
cat "$f" > "$t" && chmod "$(stat -c %a "$f")" "$t" || exit 1
|
|
235
|
+
else
|
|
236
|
+
cp -p "$f" "$t" || exit 1
|
|
237
|
+
fi
|
|
238
|
+
fi
|
|
239
|
+
done || exit 1
|
|
240
|
+
cd "$dst" || exit 1
|
|
241
|
+
find . -mindepth 1 -depth | while IFS= read -r f; do
|
|
242
|
+
if [ -e "$src/$f" ] || [ -L "$src/$f" ]; then continue; fi
|
|
243
|
+
mounted=
|
|
244
|
+
for m in "$@"; do
|
|
245
|
+
if [ "$f" = "./$m" ]; then mounted=1; fi
|
|
246
|
+
done
|
|
247
|
+
if [ -z "$mounted" ]; then rm -rf "$f" || exit 1; fi
|
|
248
|
+
done || exit 1
|
|
249
|
+
exit 0`;
|
|
250
|
+
/**
|
|
251
|
+
* Syncs the files of the state `from` (see getStateFilesDir) into the folder the pod of a
|
|
252
|
+
* service that watches its files mounts them from (LIVE_FILES_DIR).
|
|
253
|
+
*/
|
|
254
|
+
async function syncLiveFiles(instance, env, work, persistence, pod, from) {
|
|
255
|
+
const root = `/dev/hammerkit/${work.id()}`;
|
|
256
|
+
// the paths of the folder its pod mounts
|
|
257
|
+
const mounted = persistence.mounts.flatMap(({ mount }) => mount.subPath?.startsWith(`${volumes_1.LIVE_FILES_DIR}/`) ? [mount.subPath.slice(volumes_1.LIVE_FILES_DIR.length + 1)] : []);
|
|
258
|
+
const failure = await execInPod(instance, env, pod, [
|
|
259
|
+
'sh',
|
|
260
|
+
'-c',
|
|
261
|
+
exports.SYNC_FILES,
|
|
262
|
+
'sh',
|
|
263
|
+
`${root}/${from}`,
|
|
264
|
+
`${root}/${volumes_1.LIVE_FILES_DIR}`,
|
|
265
|
+
...mounted,
|
|
266
|
+
]);
|
|
267
|
+
if (failure) {
|
|
268
|
+
throw new Error(`unable to sync the files of ${work.name} into ${root}/${volumes_1.LIVE_FILES_DIR}: ${failure}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
exports.syncLiveFiles = syncLiveFiles;
|
|
272
|
+
// Puts the files of the state `from` back into the folder the pod of a service that watches
|
|
273
|
+
// its files mounts them from: a move that did not get up leaves the service at the commit
|
|
274
|
+
// it was at
|
|
275
|
+
async function restoreLiveFiles(instance, env, work, persistence, from) {
|
|
276
|
+
await getPodForPersistence(instance, env, work, persistence, 'write', (pod) => syncLiveFiles(instance, env, work, persistence, pod, from));
|
|
277
|
+
}
|
|
278
|
+
exports.restoreLiveFiles = restoreLiveFiles;
|
|
279
|
+
// Prepares the folder of a state in the claim of a work item of a service run before its
|
|
280
|
+
// upload: the folders of other states go, but the ones pods of the run mount. A folder a pod
|
|
281
|
+
// mounts is not touched: when it is the one of this state, its files are there already.
|
|
282
|
+
// Returns whether the upload is needed.
|
|
283
|
+
async function prepareStateFiles(instance, env, work, pod, dir, inUse) {
|
|
284
|
+
const targetPath = `/dev/hammerkit/${work.id()}`;
|
|
285
|
+
const removed = await execInPod(instance, env, pod, [
|
|
286
|
+
'sh',
|
|
287
|
+
'-c',
|
|
288
|
+
exports.REMOVE_OTHER_STATES,
|
|
289
|
+
'sh',
|
|
290
|
+
targetPath,
|
|
291
|
+
dir,
|
|
292
|
+
...[...inUse].sort(),
|
|
293
|
+
]);
|
|
294
|
+
if (removed) {
|
|
295
|
+
throw new Error(`unable to remove the files of other commits in ${targetPath}: ${removed}`);
|
|
296
|
+
}
|
|
297
|
+
if (inUse.has(dir) && !(await execInPod(instance, env, pod, ['test', '-e', `${targetPath}/${dir}/${UPLOADED}`]))) {
|
|
298
|
+
work.status.write('info', `the files of ${work.name} are there already`);
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
// what a failed upload or task left: tar does not remove files the commit does not have
|
|
302
|
+
const cleared = await execInPod(instance, env, pod, ['rm', '-rf', `${targetPath}/${dir}`]);
|
|
303
|
+
if (cleared) {
|
|
304
|
+
throw new Error(`unable to clear ${targetPath}/${dir}: ${cleared}`);
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
138
307
|
}
|
|
139
|
-
exports.clearClaim = clearClaim;
|
|
140
308
|
// The archive that uploads a source into the claim: its file or directory, at its place in
|
|
141
309
|
// the claim (see getClaimSubPath) instead of under its own name
|
|
142
310
|
function packSource(source) {
|
|
@@ -169,14 +337,31 @@ options = {}) {
|
|
|
169
337
|
for (const volume of persistence.volumes) {
|
|
170
338
|
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
|
|
171
339
|
}
|
|
172
|
-
|
|
340
|
+
// the folder of the state is in the work's own claim: one that has none (a service that
|
|
341
|
+
// only mounts what its deps made) has nothing to prepare
|
|
342
|
+
const claim = (0, volumes_1.getVolumeName)(service, env);
|
|
343
|
+
const files = persistence.volumes.some((volume) => volume.persistentVolumeClaim?.claimName === claim)
|
|
344
|
+
? options.files
|
|
345
|
+
: undefined;
|
|
346
|
+
if (persistence.sources.length === 0 && !options.reset && !files) {
|
|
173
347
|
return;
|
|
174
348
|
}
|
|
349
|
+
// the folders of the states the pods of the run mount, before the upload pod mounts the claim
|
|
350
|
+
const inUse = files ? await getStateDirsInUse(instance, env.namespace, claim) : new Set();
|
|
351
|
+
for (const dir of files?.keep ?? []) {
|
|
352
|
+
inUse.add(dir);
|
|
353
|
+
}
|
|
175
354
|
service.status.write('debug', 'start upload container');
|
|
176
355
|
await getPodForPersistence(instance, env, service, persistence, 'write', async (name) => {
|
|
177
356
|
if (options.reset) {
|
|
178
357
|
await clearClaim(instance, env, service, name);
|
|
179
358
|
}
|
|
359
|
+
if (files && !(await prepareStateFiles(instance, env, service, name, files.dir, inUse))) {
|
|
360
|
+
if (files.live && service.data.type === 'container-service') {
|
|
361
|
+
await syncLiveFiles(instance, env, service, persistence, name, files.dir);
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
180
365
|
for (const source of persistence.sources) {
|
|
181
366
|
// skip sources that don't exist on disk — nothing to upload
|
|
182
367
|
if (!(await environment.file.exists(source.localPath))) {
|
|
@@ -207,6 +392,23 @@ options = {}) {
|
|
|
207
392
|
}
|
|
208
393
|
});
|
|
209
394
|
}
|
|
395
|
+
if (files) {
|
|
396
|
+
const dir = `/dev/hammerkit/${service.id()}/${files.dir}`;
|
|
397
|
+
const marked = await execInPod(instance, env, name, [
|
|
398
|
+
'sh',
|
|
399
|
+
'-c',
|
|
400
|
+
'mkdir -p "$1" && touch "$1/$2"',
|
|
401
|
+
'sh',
|
|
402
|
+
dir,
|
|
403
|
+
UPLOADED,
|
|
404
|
+
]);
|
|
405
|
+
if (marked) {
|
|
406
|
+
throw new Error(`unable to mark the upload of ${dir}: ${marked}`);
|
|
407
|
+
}
|
|
408
|
+
if (files.live && service.data.type === 'container-service') {
|
|
409
|
+
await syncLiveFiles(instance, env, service, persistence, name, files.dir);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
210
412
|
});
|
|
211
413
|
}
|
|
212
414
|
exports.ensurePersistentData = ensurePersistentData;
|
|
@@ -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
|
|
@@ -65,8 +65,8 @@ exports.getSecretHosts = getSecretHosts;
|
|
|
65
65
|
function accountsOf(secrets) {
|
|
66
66
|
return [...new Set(secrets.map((s) => s.source.account))];
|
|
67
67
|
}
|
|
68
|
-
function getCredentialsName(item) {
|
|
69
|
-
return (0, resources_1.getResourceName)(item, '-credentials');
|
|
68
|
+
function getCredentialsName(item, kubernetes) {
|
|
69
|
+
return (0, resources_1.getResourceName)(item, kubernetes, '-credentials');
|
|
70
70
|
}
|
|
71
71
|
exports.getCredentialsName = getCredentialsName;
|
|
72
72
|
// The keys of the credentials Secret: `<account>.token` and `<account>.file.<key>`
|
|
@@ -136,9 +136,9 @@ async function ensureJobCredentials(instance, kubernetes, item, secrets, admitte
|
|
|
136
136
|
apiVersion: 'v1',
|
|
137
137
|
metadata: {
|
|
138
138
|
namespace: kubernetes.namespace,
|
|
139
|
-
name: getCredentialsName(item),
|
|
139
|
+
name: getCredentialsName(item, kubernetes),
|
|
140
140
|
annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
|
|
141
|
-
labels:
|
|
141
|
+
labels: (0, resources_1.getItemLabels)(item, kubernetes),
|
|
142
142
|
},
|
|
143
143
|
type: 'Opaque',
|
|
144
144
|
data,
|
|
@@ -169,7 +169,10 @@ async function removeJobCredentials(instance, kubernetes, item) {
|
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
171
|
try {
|
|
172
|
-
await instance.coreApi.deleteNamespacedSecret({
|
|
172
|
+
await instance.coreApi.deleteNamespacedSecret({
|
|
173
|
+
name: getCredentialsName(item, kubernetes),
|
|
174
|
+
namespace: kubernetes.namespace,
|
|
175
|
+
});
|
|
173
176
|
}
|
|
174
177
|
catch (e) {
|
|
175
178
|
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
@@ -213,7 +216,7 @@ proxyEnv = []) {
|
|
|
213
216
|
}
|
|
214
217
|
const name = `hammerkit-account-${index}`;
|
|
215
218
|
accountVolume.set(account, name);
|
|
216
|
-
volumes.push({ name, secret: { secretName: getCredentialsName(item), items, defaultMode: 0o444 } });
|
|
219
|
+
volumes.push({ name, secret: { secretName: getCredentialsName(item, { server }), items, defaultMode: 0o444 } });
|
|
217
220
|
});
|
|
218
221
|
const initContainers = secrets.map(({ index, secret, source }) => {
|
|
219
222
|
const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
|
|
@@ -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
|