hammerkit 1.27.0-next.9 → 2.0.0-next.2

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.
Files changed (122) hide show
  1. package/build.schema.json +176 -6
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/cache-sync.js +1 -1
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +6 -3
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +49 -1
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +133 -9
  36. package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
  37. package/dist/kubernetes/ensure-persistent-data.js +211 -6
  38. package/dist/kubernetes/follow-pod-logs.js +182 -0
  39. package/dist/kubernetes/job-placement.js +36 -12
  40. package/dist/kubernetes/kubernetes-instance.js +1 -0
  41. package/dist/kubernetes/network-policy.js +2 -1
  42. package/dist/kubernetes/remove-stale-items.js +46 -0
  43. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  44. package/dist/kubernetes/sandbox-runner.js +628 -0
  45. package/dist/kubernetes/service-rollouts.js +74 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  47. package/dist/kubernetes/volumes.js +94 -9
  48. package/dist/main.js +2 -0
  49. package/dist/optimizer/calculate-checksum.js +3 -2
  50. package/dist/optimizer/check-key.js +94 -0
  51. package/dist/optimizer/check-keys.js +64 -0
  52. package/dist/optimizer/get-work-cache-stats.js +17 -3
  53. package/dist/optimizer/work-task-cache-description.js +49 -1
  54. package/dist/planner/service-definition.js +9 -4
  55. package/dist/planner/utils/append-work-dependencies.js +5 -3
  56. package/dist/planner/utils/append-work-service.js +18 -1
  57. package/dist/planner/utils/append-work-task.js +13 -3
  58. package/dist/planner/utils/get-work-runtime.js +6 -1
  59. package/dist/planner/utils/normalize-path.js +19 -1
  60. package/dist/planner/utils/parse-work-mount.js +7 -1
  61. package/dist/planner/utils/parse-work-port.js +4 -1
  62. package/dist/planner/validate.js +22 -0
  63. package/dist/planner/work-network.js +3 -1
  64. package/dist/planner/work-runtime-aggregate.js +32 -0
  65. package/dist/planner/work-runtime-kubernetes.js +136 -13
  66. package/dist/planner/work-task.js +6 -1
  67. package/dist/program.js +27 -9
  68. package/dist/remote/device-login.js +7 -2
  69. package/dist/remote/host-config.js +69 -3
  70. package/dist/remote/remote-commands.js +33 -7
  71. package/dist/remote/remote-status.js +112 -0
  72. package/dist/remote/remote-work-commands.js +562 -45
  73. package/dist/remote/server-client.js +15 -2
  74. package/dist/remote/token-source.js +64 -26
  75. package/dist/run-program.js +9 -0
  76. package/dist/schema/build-file-base-task-schema.js +3 -3
  77. package/dist/schema/build-file-container-service-schema.js +10 -0
  78. package/dist/schema/build-file-container-task-schema.js +16 -1
  79. package/dist/schema/build-file-include-schema.js +18 -4
  80. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  81. package/dist/schema/build-file-task-schema.js +5 -1
  82. package/dist/schema/capabilities-schema.js +56 -0
  83. package/dist/schema/reference-parser.js +119 -11
  84. package/dist/schema/removed-properties.js +20 -0
  85. package/dist/schema/schema-parser.js +94 -41
  86. package/dist/schema/work-scope-parser.js +33 -10
  87. package/dist/secrets/command-secret-provider.js +3 -1
  88. package/dist/secrets/github-app-token-command.js +3 -1
  89. package/dist/secrets/secret-catalog.js +43 -19
  90. package/dist/server/accounts-api.js +4 -1
  91. package/dist/server/approvals-api.js +1 -1
  92. package/dist/server/capacity-format.js +36 -0
  93. package/dist/server/capacity.js +300 -0
  94. package/dist/server/checks-api.js +83 -0
  95. package/dist/server/checks.js +116 -0
  96. package/dist/server/dashboard.js +196 -0
  97. package/dist/server/definitions-api.js +66 -3
  98. package/dist/server/endpoints.js +794 -0
  99. package/dist/server/executor.js +164 -9
  100. package/dist/server/kubernetes-backend.js +415 -48
  101. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  102. package/dist/server/overview.js +271 -0
  103. package/dist/server/plan.js +170 -121
  104. package/dist/server/policy.js +34 -2
  105. package/dist/server/proposals-api.js +17 -8
  106. package/dist/server/repo-access.js +5 -0
  107. package/dist/server/run-logs.js +4 -0
  108. package/dist/server/run-plan.js +38 -4
  109. package/dist/server/run-request.js +58 -3
  110. package/dist/server/run-worker.js +262 -13
  111. package/dist/server/runs.js +962 -36
  112. package/dist/server/sandbox-pod.js +28 -0
  113. package/dist/server/sandbox-storage.js +23 -0
  114. package/dist/server/sandboxes.js +8 -0
  115. package/dist/server/server-config.js +246 -10
  116. package/dist/server/server.js +429 -32
  117. package/dist/server/service-runs.js +82 -0
  118. package/dist/server/timings.js +132 -0
  119. package/dist/server/wait-reason.js +21 -0
  120. package/dist/utils/tar.js +28 -1
  121. package/dist/utils/terminal-prompt.js +53 -0
  122. 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");
@@ -68,18 +69,24 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
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
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: (0, resources_1.getItemLabels)(service, env),
83
+ labels: { ...(serving ? (0, job_placement_1.getRunLabels)(env) : (0, resources_1.getItemLabels)(service, env)), ...claims.labels },
78
84
  annotations: {
79
85
  'hammerkit.dev/version': (0, version_1.getVersion)(),
80
86
  },
81
87
  },
82
88
  spec: {
89
+ ...(claims.affinity ? { affinity: claims.affinity } : {}),
83
90
  // on a server the claims are bound where the pod of the task can go: the nodes
84
91
  // of its pool
85
92
  ...(env.server
@@ -122,18 +129,182 @@ exports.getPodForPersistence = getPodForPersistence;
122
129
  async function clearClaim(instance, env, work, pod) {
123
130
  const targetPath = `/dev/hammerkit/${work.id()}`;
124
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) {
125
152
  let failure = null;
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) => {
153
+ const res = await instance.exec.exec(env.namespace, pod, 'volume', command, process.stdout, process.stderr, null, false, (status) => {
127
154
  if (status.status !== 'Success') {
128
155
  failure = status.message ?? status.reason ?? 'unknown';
129
156
  }
130
157
  });
131
158
  await new Promise((resolve, reject) => {
132
159
  res.on('error', reject);
133
- res.on('close', () => failure ? reject(new Error(`unable to clear ${targetPath} in ${pod}: ${failure}`)) : resolve());
160
+ res.on('close', () => resolve());
134
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;
135
307
  }
136
- exports.clearClaim = clearClaim;
137
308
  // The archive that uploads a source into the claim: its file or directory, at its place in
138
309
  // the claim (see getClaimSubPath) instead of under its own name
139
310
  function packSource(source) {
@@ -166,14 +337,31 @@ options = {}) {
166
337
  for (const volume of persistence.volumes) {
167
338
  await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
168
339
  }
169
- if (persistence.sources.length === 0 && !options.reset) {
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) {
170
347
  return;
171
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
+ }
172
354
  service.status.write('debug', 'start upload container');
173
355
  await getPodForPersistence(instance, env, service, persistence, 'write', async (name) => {
174
356
  if (options.reset) {
175
357
  await clearClaim(instance, env, service, name);
176
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
+ }
177
365
  for (const source of persistence.sources) {
178
366
  // skip sources that don't exist on disk — nothing to upload
179
367
  if (!(await environment.file.exists(source.localPath))) {
@@ -204,6 +392,23 @@ options = {}) {
204
392
  }
205
393
  });
206
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
+ }
207
412
  });
208
413
  }
209
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
@@ -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
- return kubernetes.server
89
- ? {
90
- securityContext: {
91
- allowPrivilegeEscalation: false,
92
- capabilities: { drop: ['NET_RAW'] },
93
- seccompProfile: { type: 'RuntimeDefault' },
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
@@ -24,6 +24,7 @@ function createKubernetesInstances(kubernetes) {
24
24
  return {
25
25
  objectApi,
26
26
  log: new client_node_1.Log(kc),
27
+ metrics: new client_node_1.Metrics(kc),
27
28
  exec,
28
29
  appsApi,
29
30
  coreApi,
@@ -129,7 +129,8 @@ function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item,
129
129
  namespace: env.namespace,
130
130
  name: getNetworkPolicyName(item, scope),
131
131
  annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
132
- labels: { [NETWORK_OF_LABEL]: item.id(), ...(0, job_placement_1.getRunLabels)(env) },
132
+ labels: { [NETWORK_OF_LABEL]: item.id(), ...(scope.owners ? {} : (0, job_placement_1.getRunLabels)(env)) },
133
+ ...(scope.owners ? { ownerReferences: scope.owners } : {}),
133
134
  },
134
135
  spec: {
135
136
  podSelector: { matchLabels: getItemPodLabels(item, scope) },
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getStaleItemSelector = exports.removeStaleItems = void 0;
4
+ const job_placement_1 = require("./job-placement");
5
+ const resources_1 = require("./resources");
6
+ const apply_1 = require("./apply");
7
+ /**
8
+ * What a service run made for items it has no more, once it moved to another commit: the
9
+ * Deployment, Service, Secret, policies and Jobs of a service or task whose id changed (a new
10
+ * image, other volumes). Their claims stay, with the namespace of the run.
11
+ */
12
+ async function removeStaleItems(instance, env, ids) {
13
+ if (!env.server) {
14
+ return;
15
+ }
16
+ const namespace = env.namespace;
17
+ const labelSelector = getStaleItemSelector(env.server.run, ids);
18
+ const selector = { namespace, labelSelector };
19
+ await instance.appsApi.deleteCollectionNamespacedDeployment({ ...selector, propagationPolicy: 'Background' });
20
+ await instance.batchApi.deleteCollectionNamespacedJob({ ...selector, propagationPolicy: 'Background' });
21
+ await instance.coreApi.deleteCollectionNamespacedService(selector);
22
+ await instance.coreApi.deleteCollectionNamespacedSecret(selector);
23
+ const policies = await instance.objectApi.list('networking.k8s.io/v1', 'NetworkPolicy', namespace, undefined, undefined, undefined, undefined, labelSelector);
24
+ for (const policy of policies.items) {
25
+ const metadata = { name: policy.metadata?.name, namespace };
26
+ await instance.objectApi
27
+ .delete({ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy', metadata })
28
+ .catch((e) => {
29
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
30
+ throw e;
31
+ }
32
+ });
33
+ }
34
+ }
35
+ exports.removeStaleItems = removeStaleItems;
36
+ // the objects of the run's items but those of `ids`
37
+ function getStaleItemSelector(run, ids) {
38
+ const current = [...new Set(ids)].sort();
39
+ return [
40
+ `${job_placement_1.RUN_LABEL}=${(0, job_placement_1.getRunLabelValue)(run)}`,
41
+ resources_1.ID_LABEL,
42
+ ...(current.length > 0 ? [`${resources_1.ID_LABEL} notin (${current.join(',')})`] : []),
43
+ ].join(',');
44
+ }
45
+ exports.getStaleItemSelector = getStaleItemSelector;
46
+ //# sourceMappingURL=remove-stale-items.js.map
@@ -8,14 +8,18 @@ const ensure_persistent_data_1 = require("./ensure-persistent-data");
8
8
  const claim_layout_1 = require("./claim-layout");
9
9
  // Where the upload pod (which mounts the work's claim at /dev/hammerkit/<id>) unpacks the
10
10
  // archive of an output: the output's place in the claim, which its mount reads
11
- function getRestoreTarget(work, path) {
12
- return `/dev/hammerkit/${work.id()}/${(0, claim_layout_1.getClaimSubPath)(work, path)}`;
11
+ function getRestoreTarget(work, path,
12
+ // the folder of the state on a service run, see getStateFilesDir
13
+ stateDir) {
14
+ return `/dev/hammerkit/${work.id()}/${stateDir ? `${stateDir}/` : ''}${(0, claim_layout_1.getClaimSubPath)(work, path)}`;
13
15
  }
14
16
  exports.getRestoreTarget = getRestoreTarget;
15
17
  async function restoreKubernetesData(work, kubernetes, instance, environment, path,
16
- // the kept claim of the work holds what an earlier run left, which goes first
18
+ // the kept claim of the work holds what an earlier run left, which goes first; on a
19
+ // service run, the outputs go into the folder of `stateKey` (see getStateFilesDir)
17
20
  options = {}) {
18
- const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes);
21
+ const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes, options.stateKey);
22
+ const stateDir = kubernetes.server?.serviceRun ? (0, volumes_1.getItemFilesDir)(work, options.stateKey) : undefined;
19
23
  await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'write', async (name) => {
20
24
  if (options.reset) {
21
25
  await (0, ensure_persistent_data_1.clearClaim)(instance, kubernetes, work, name);
@@ -24,7 +28,7 @@ options = {}) {
24
28
  if (!(await environment.file.exists(generatedArchive.filename))) {
25
29
  continue;
26
30
  }
27
- const targetPath = getRestoreTarget(work, generatedArchive.path);
31
+ const targetPath = getRestoreTarget(work, generatedArchive.path, stateDir);
28
32
  // a claim made for this run (a server removes the claims of a run when it
29
33
  // ends) does not have the directory of the output yet
30
34
  let failure = null;