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.
Files changed (129) 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 +76 -17
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-http-route.js +1 -1
  36. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +138 -18
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  38. package/dist/kubernetes/ensure-persistent-data.js +212 -10
  39. package/dist/kubernetes/follow-pod-logs.js +182 -0
  40. package/dist/kubernetes/get-service-ip.js +2 -2
  41. package/dist/kubernetes/job-placement.js +36 -12
  42. package/dist/kubernetes/job-secrets.js +9 -6
  43. package/dist/kubernetes/kept-claims.js +22 -10
  44. package/dist/kubernetes/kubernetes-instance.js +1 -0
  45. package/dist/kubernetes/network-policy.js +16 -8
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/remove-stale-items.js +46 -0
  48. package/dist/kubernetes/resources.js +25 -3
  49. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  50. package/dist/kubernetes/sandbox-runner.js +628 -0
  51. package/dist/kubernetes/secrets.js +7 -11
  52. package/dist/kubernetes/service-rollouts.js +74 -0
  53. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  54. package/dist/kubernetes/volumes.js +96 -11
  55. package/dist/main.js +2 -0
  56. package/dist/optimizer/calculate-checksum.js +3 -2
  57. package/dist/optimizer/check-key.js +94 -0
  58. package/dist/optimizer/check-keys.js +64 -0
  59. package/dist/optimizer/get-work-cache-stats.js +17 -3
  60. package/dist/optimizer/work-task-cache-description.js +49 -1
  61. package/dist/planner/service-definition.js +9 -4
  62. package/dist/planner/utils/append-work-dependencies.js +5 -3
  63. package/dist/planner/utils/append-work-service.js +18 -1
  64. package/dist/planner/utils/append-work-task.js +13 -3
  65. package/dist/planner/utils/get-work-runtime.js +6 -1
  66. package/dist/planner/utils/normalize-path.js +19 -1
  67. package/dist/planner/utils/parse-work-mount.js +7 -1
  68. package/dist/planner/utils/parse-work-port.js +4 -1
  69. package/dist/planner/validate.js +22 -0
  70. package/dist/planner/work-network.js +3 -1
  71. package/dist/planner/work-runtime-aggregate.js +32 -0
  72. package/dist/planner/work-runtime-kubernetes.js +153 -26
  73. package/dist/planner/work-task.js +6 -1
  74. package/dist/program.js +27 -9
  75. package/dist/remote/device-login.js +7 -2
  76. package/dist/remote/host-config.js +69 -3
  77. package/dist/remote/remote-commands.js +33 -7
  78. package/dist/remote/remote-status.js +112 -0
  79. package/dist/remote/remote-work-commands.js +562 -45
  80. package/dist/remote/server-client.js +15 -2
  81. package/dist/remote/token-source.js +64 -26
  82. package/dist/run-program.js +9 -0
  83. package/dist/schema/build-file-base-task-schema.js +3 -3
  84. package/dist/schema/build-file-container-service-schema.js +10 -0
  85. package/dist/schema/build-file-container-task-schema.js +16 -1
  86. package/dist/schema/build-file-include-schema.js +18 -4
  87. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  88. package/dist/schema/build-file-task-schema.js +5 -1
  89. package/dist/schema/capabilities-schema.js +56 -0
  90. package/dist/schema/reference-parser.js +119 -11
  91. package/dist/schema/removed-properties.js +20 -0
  92. package/dist/schema/schema-parser.js +94 -41
  93. package/dist/schema/work-scope-parser.js +33 -10
  94. package/dist/secrets/command-secret-provider.js +3 -1
  95. package/dist/secrets/github-app-token-command.js +3 -1
  96. package/dist/secrets/secret-catalog.js +43 -19
  97. package/dist/server/accounts-api.js +4 -1
  98. package/dist/server/approvals-api.js +1 -1
  99. package/dist/server/capacity-format.js +36 -0
  100. package/dist/server/capacity.js +300 -0
  101. package/dist/server/checks-api.js +83 -0
  102. package/dist/server/checks.js +116 -0
  103. package/dist/server/dashboard.js +196 -0
  104. package/dist/server/definitions-api.js +66 -3
  105. package/dist/server/endpoints.js +794 -0
  106. package/dist/server/executor.js +164 -9
  107. package/dist/server/kubernetes-backend.js +478 -70
  108. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  109. package/dist/server/overview.js +271 -0
  110. package/dist/server/plan.js +170 -121
  111. package/dist/server/policy.js +34 -2
  112. package/dist/server/proposals-api.js +17 -8
  113. package/dist/server/repo-access.js +5 -0
  114. package/dist/server/run-logs.js +4 -0
  115. package/dist/server/run-plan.js +38 -4
  116. package/dist/server/run-request.js +58 -3
  117. package/dist/server/run-worker.js +262 -13
  118. package/dist/server/runs.js +969 -36
  119. package/dist/server/sandbox-pod.js +28 -0
  120. package/dist/server/sandbox-storage.js +23 -0
  121. package/dist/server/sandboxes.js +8 -0
  122. package/dist/server/server-config.js +246 -10
  123. package/dist/server/server.js +429 -32
  124. package/dist/server/service-runs.js +82 -0
  125. package/dist/server/timings.js +132 -0
  126. package/dist/server/wait-reason.js +21 -0
  127. package/dist/utils/tar.js +28 -1
  128. package/dist/utils/terminal-prompt.js +53 -0
  129. package/package.json +8 -2
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.awaitRunObjectsGone = exports.removeRunObjects = exports.CallerNamespaces = exports.getVolumeScope = exports.KubernetesExecutor = exports.getRunRoleBinding = exports.getNamespacePolicies = exports.getClusterAccess = exports.isCallerNamespace = exports.getCallerNamespace = void 0;
3
+ exports.awaitRunObjectsGone = exports.removeRunObjects = exports.CallerNamespaces = exports.getVolumeScope = exports.getPrivilegedNamespaceProblem = exports.getCapabilitiesProblem = exports.getPodSecurityLabels = exports.KubernetesExecutor = exports.getRunRoleBinding = exports.removeServiceRunNamespace = exports.getServerIngressPolicy = exports.getNamespacePolicies = exports.getClusterAccess = exports.isCallerNamespace = exports.SERVICE_RUN_LABEL = exports.getServiceRunNamespace = exports.getCallerNamespace = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const fs_1 = require("fs");
6
6
  const os_1 = require("os");
@@ -8,6 +8,7 @@ const path_1 = require("path");
8
8
  const net_1 = require("net");
9
9
  const apply_1 = require("../kubernetes/apply");
10
10
  const job_placement_1 = require("../kubernetes/job-placement");
11
+ const capacity_1 = require("./capacity");
11
12
  const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
12
13
  const log_1 = require("../log");
13
14
  const network_policy_1 = require("../kubernetes/network-policy");
@@ -22,6 +23,8 @@ const POLICY_NAME = 'hammerkit';
22
23
  const MANAGED_LABEL = 'hammerkit.dev/managed';
23
24
  // how long the end of a run waits for its pods and claims to be gone
24
25
  const RELEASE_TIMEOUT = 120000;
26
+ // how long the setup of a caller's namespace is taken as it is, before it is applied again
27
+ const NAMESPACE_SETUP_REUSE_MS = 5 * 60000;
25
28
  // where a pod of the cluster finds the token and certificate of its ServiceAccount
26
29
  const IN_CLUSTER_NAMESPACE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace';
27
30
  /**
@@ -32,6 +35,20 @@ function getCallerNamespace(prefix, owner) {
32
35
  return `${prefix}${(0, crypto_1.createHash)('sha256').update(`${owner.iss}\0${owner.sub}`).digest('hex').slice(0, 16)}`;
33
36
  }
34
37
  exports.getCallerNamespace = getCallerNamespace;
38
+ /**
39
+ * The namespace of a service run: one of its own, as the services stay up for hours while
40
+ * the caller's other runs go on in theirs (one at a time, each removing what the one
41
+ * before left).
42
+ */
43
+ function getServiceRunNamespace(prefix, run, wakes = 0) {
44
+ // a woken run starts in a namespace of its own, the one it slept in may still be removed;
45
+ // a run whose volumes are kept while it sleeps has one namespace (wakes 0) from start to end
46
+ const name = wakes > 0 ? `up\0${run}\0${wakes}` : `up\0${run}`;
47
+ return `${prefix}${(0, crypto_1.createHash)('sha256').update(name).digest('hex').slice(0, 16)}`;
48
+ }
49
+ exports.getServiceRunNamespace = getServiceRunNamespace;
50
+ // the label of the namespace of a service run: the run label value of its run
51
+ exports.SERVICE_RUN_LABEL = 'hammerkit.dev/service-run';
35
52
  // Whether a namespace is one getCallerNamespace makes with this prefix: a server named `hk`
36
53
  // passes over the namespaces of a server named `hk-test` on the same cluster.
37
54
  function isCallerNamespace(prefix, name) {
@@ -143,6 +160,64 @@ function getNamespacePolicies(namespace, deniedEgress, allowedEgress = []) {
143
160
  ];
144
161
  }
145
162
  exports.getNamespacePolicies = getNamespacePolicies;
163
+ /**
164
+ * What lets the server into the namespace of a service run, to the ports of its endpoints:
165
+ * policies add up, so this one opens the pods to the server's namespace besides what the
166
+ * others let in, limited ones too.
167
+ */
168
+ // The server reaches the ports of the endpoints, from its own namespace, and no other port:
169
+ // what else the run starts (a database) stays for the pods of the run.
170
+ function getServerIngressPolicy(namespace, server, ports) {
171
+ return {
172
+ apiVersion: 'networking.k8s.io/v1',
173
+ kind: 'NetworkPolicy',
174
+ metadata: {
175
+ name: 'hammerkit-server-ingress',
176
+ namespace,
177
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
178
+ labels: { [MANAGED_LABEL]: 'true' },
179
+ },
180
+ spec: {
181
+ podSelector: {},
182
+ policyTypes: ['Ingress'],
183
+ ingress: ports.length > 0
184
+ ? [
185
+ {
186
+ _from: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': server } } }],
187
+ ports: [...new Set(ports)].map((port) => ({ protocol: 'TCP', port })),
188
+ },
189
+ ]
190
+ : [],
191
+ },
192
+ };
193
+ }
194
+ exports.getServerIngressPolicy = getServerIngressPolicy;
195
+ // The namespace of a service run goes with all it holds; waits up to `timeout` for it.
196
+ async function removeServiceRunNamespace(instance, namespace, timeout) {
197
+ try {
198
+ await instance.coreApi.deleteNamespace({ name: namespace, propagationPolicy: 'Background' });
199
+ }
200
+ catch (e) {
201
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
202
+ return;
203
+ }
204
+ throw e;
205
+ }
206
+ const deadline = Date.now() + timeout;
207
+ while (Date.now() < deadline) {
208
+ try {
209
+ await instance.coreApi.readNamespace({ name: namespace });
210
+ }
211
+ catch (e) {
212
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
213
+ return;
214
+ }
215
+ throw e;
216
+ }
217
+ await (0, sleep_1.sleep)(1000);
218
+ }
219
+ }
220
+ exports.removeServiceRunNamespace = removeServiceRunNamespace;
146
221
  /**
147
222
  * The server's rights in a caller's namespace: the operator's ClusterRole, bound there
148
223
  * only, so the server needs no rights on Secrets or pods anywhere else.
@@ -167,22 +242,33 @@ exports.getRunRoleBinding = getRunRoleBinding;
167
242
  * process of the server as on a host (see HostExecutor); what changes is where its
168
243
  * container tasks and services go: into the caller's namespace, on the nodes of the
169
244
  * operator's pools, with the secrets of providers read in the Job itself. The runs
170
- * of one caller execute one after the other, since the objects of a task are named
171
- * by its cache id in the caller's namespace; different callers run side by side.
172
- * When a run ends, everything made for it is removed, but for the claims of its tasks
173
- * the server keeps (`backend.keepVolumes`), which go once they expired.
245
+ * of a caller execute side by side in their namespace, up to `runs.limits.running`:
246
+ * the objects of a run are named and found by its run label (getItemKey), so two runs
247
+ * of the same task have their own. What two runs share are the claims the server keeps
248
+ * (`backend.keepVolumes`) of a scope (getVolumeScope): the runs of one scope go one at
249
+ * a time (exclusive). When a run ends, everything made for it is removed, but for the
250
+ * kept claims, which go once they expired.
174
251
  */
175
252
  class KubernetesExecutor {
176
253
  constructor(config, processEnvs, options) {
177
254
  this.config = config;
178
- // the registry keeps a caller's next run in its queue, not running and timed here
179
- this.runningPerCaller = 1;
180
- // the run that executes, or the last one waiting, per caller namespace
255
+ // per caller namespace, what changes objects of the whole namespace: its setup, the
256
+ // removal of what a stopped server left, the sweeps of kept claims. The last one waiting.
181
257
  this.queues = new Map();
258
+ // per caller namespace, the runs that execute in it: by run label, their volume scope
259
+ this.active = new Map();
182
260
  // per caller namespace, when its kept claims are next looked at
183
261
  this.sweeps = new Map();
184
262
  // whether the kept claims a server left when it stopped were looked for
185
263
  this.swept = false;
264
+ // the namespaces where no run of this process left anything: what a run makes goes at its
265
+ // end, unless its release failed
266
+ this.tidy = new Set();
267
+ // the namespaces of the service runs that execute, with how many executions each (a run
268
+ // that moves executes beside itself), and whether those of earlier ones went
269
+ this.serviceRuns = new Map();
270
+ this.serviceRunsSwept = false;
271
+ this.asleep = new Set();
186
272
  this.access = getClusterAccess(config.backend, processEnvs);
187
273
  this.instance = (0, kubernetes_instance_1.createKubernetesInstances)({
188
274
  type: 'kubernetes',
@@ -200,20 +286,143 @@ class KubernetesExecutor {
200
286
  this.namespace = namespace;
201
287
  this.namespaces = new CallerNamespaces(this.instance, config.backend);
202
288
  this.keep = config.backend.keepVolumes === false ? 0 : (0, units_1.parseDuration)(config.backend.keepVolumes);
289
+ this.keepServiceRuns = config.runs.up.keepVolumes;
203
290
  this.log = options.log;
204
291
  this.host = new executor_1.HostExecutor({ ...options, placement: this.placement() });
205
292
  }
206
- execute(run, context) {
207
- const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
208
- return this.enqueue(namespace, () => {
209
- if (context.signal.aborted) {
210
- throw new Error('the run was cancelled');
293
+ // what the namespaces of the server's callers and service runs request and use
294
+ capacity() {
295
+ const prefix = this.config.backend.namespacePrefix;
296
+ return new capacity_1.KubernetesCapacity(this.instance, {
297
+ quotaName: POLICY_NAME,
298
+ namespaces: async () => (await this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true` })).items
299
+ .filter((namespace) => isCallerNamespace(prefix, namespace.metadata?.name ?? ''))
300
+ .map((namespace) => ({
301
+ name: namespace.metadata?.name,
302
+ kind: namespace.metadata?.labels?.[exports.SERVICE_RUN_LABEL] !== undefined ? 'service-run' : 'caller',
303
+ })),
304
+ ownerOf: (namespace, runs) => {
305
+ for (const run of runs) {
306
+ if (run.up && getServiceRunNamespace(prefix, run.id, run.wakes) === namespace) {
307
+ return { owner: { iss: run.owner.iss, sub: run.owner.sub }, run: run.id };
308
+ }
309
+ if (getCallerNamespace(prefix, run.owner) === namespace) {
310
+ return { owner: { iss: run.owner.iss, sub: run.owner.sub } };
311
+ }
312
+ }
313
+ return {};
314
+ },
315
+ });
316
+ }
317
+ async execute(run, context) {
318
+ if (run.up) {
319
+ if (run.source.type === 'sandbox') {
320
+ throw new Error('on a cluster a service run starts from a branch, push the sandbox and start it from there');
211
321
  }
322
+ // in a namespace of its own, apart from the caller's runs and their claims
212
323
  return this.host.execute(run, context);
213
- });
324
+ }
325
+ const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
326
+ const label = (0, job_placement_1.getRunLabelValue)(run.id);
327
+ let runs = this.active.get(namespace);
328
+ if (!runs) {
329
+ runs = new Map();
330
+ this.active.set(namespace, runs);
331
+ }
332
+ runs.set(label, getVolumeScope(run));
333
+ try {
334
+ await this.host.execute(run, context);
335
+ }
336
+ finally {
337
+ runs.delete(label);
338
+ if (runs.size === 0 && this.active.get(namespace) === runs) {
339
+ this.active.delete(namespace);
340
+ }
341
+ }
342
+ }
343
+ // A service run moves to another commit in its namespace: unchanged services stay, the
344
+ // others roll to their new pods (see kubernetesServiceRuntime)
345
+ movesBeside(run) {
346
+ return !!run.up;
347
+ }
348
+ namespaceOf(run) {
349
+ const prefix = this.config.backend.namespacePrefix;
350
+ return run.up
351
+ ? getServiceRunNamespace(prefix, run.id, this.keepServiceRuns ? 0 : run.wakes)
352
+ : getCallerNamespace(prefix, run.owner);
353
+ }
354
+ /**
355
+ * A service run that slept with its volumes ended without waking: its namespace goes, with
356
+ * the claims in it.
357
+ */
358
+ async forget(run) {
359
+ if (!run.up) {
360
+ return;
361
+ }
362
+ // a namespace is kept only for a run that has one from start to end
363
+ const namespace = getServiceRunNamespace(this.config.backend.namespacePrefix, run.id);
364
+ this.asleep.delete(namespace);
365
+ await this.enqueue(namespace, () => this.removeServiceRun(namespace, (text) => this.log?.(`run=${run.id} ${text}`)));
366
+ }
367
+ /**
368
+ * The service runs of a previous start that sleep with their volumes: their namespaces stay,
369
+ * without what runs in them (a server that stopped without putting the run to sleep left
370
+ * it). The namespaces of any other service run go.
371
+ */
372
+ restored(runs) {
373
+ if (this.keepServiceRuns) {
374
+ for (const run of runs.filter((candidate) => candidate.up)) {
375
+ const namespace = getServiceRunNamespace(this.config.backend.namespacePrefix, run.id);
376
+ this.asleep.add(namespace);
377
+ this.enqueue(namespace, () => removeRunObjects(this.instance, namespace, job_placement_1.RUN_LABEL, RELEASE_TIMEOUT, { claims: false })).catch((e) => this.log?.(`what runs in the namespace ${namespace} of run ${run.id} could not be removed: ${(0, log_1.getErrorMessage)(e)}`));
378
+ }
379
+ }
380
+ this.sweepServiceRuns();
381
+ }
382
+ // The end of a service run: its namespace goes with all it holds. A server that may not
383
+ // delete it removes what runs in it, and looks again at the next service run.
384
+ async removeServiceRun(namespace, say) {
385
+ try {
386
+ await removeServiceRunNamespace(this.instance, namespace, RELEASE_TIMEOUT);
387
+ }
388
+ catch (e) {
389
+ // at least nothing of it runs on: its objects are the server's to remove (runRole)
390
+ const forbidden = (0, apply_1.statusCodeOf)(e) === 403;
391
+ const text = forbidden
392
+ ? `the server may not delete the namespace ${namespace}, its ClusterRole needs delete on namespaces (see the backend docs); removing what runs in it`
393
+ : `the namespace ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(e)}`;
394
+ this.log?.(text);
395
+ say(text);
396
+ try {
397
+ await removeRunObjects(this.instance, namespace, job_placement_1.RUN_LABEL, RELEASE_TIMEOUT);
398
+ }
399
+ catch (inner) {
400
+ say(`what the run made in ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(inner)}`);
401
+ }
402
+ // the next look removes the namespace
403
+ this.serviceRunsSwept = false;
404
+ }
214
405
  }
215
- // What happens in a caller's namespace happens one at a time: their runs, and the
216
- // removal of the claims their runs kept
406
+ // The runs of a scope share its kept claims, which one of them may change at a time:
407
+ // what a task finds in one is what the last run left there. Without kept claims, runs
408
+ // share no object and nothing is held.
409
+ exclusive(run) {
410
+ if (this.keep === 0 || run.up) {
411
+ return undefined;
412
+ }
413
+ return `${getCallerNamespace(this.config.backend.namespacePrefix, run.owner)}/${getVolumeScope(run)}`;
414
+ }
415
+ // The scopes of the runs that execute in the namespace, but `except`'s: their kept claims
416
+ // are theirs to mount and stay. A server that keeps none mounts none.
417
+ scopesInUse(namespace, except) {
418
+ if (this.keep === 0) {
419
+ return new Set();
420
+ }
421
+ return new Set([...(this.active.get(namespace) ?? new Map()).entries()]
422
+ .filter(([label]) => label !== except)
423
+ .map(([, scope]) => scope));
424
+ }
425
+ // What changes objects of a whole namespace happens one at a time, not what its runs do
217
426
  enqueue(namespace, fn) {
218
427
  const previous = this.queues.get(namespace) ?? Promise.resolve();
219
428
  const execution = previous.then(fn);
@@ -227,54 +436,107 @@ class KubernetesExecutor {
227
436
  return execution;
228
437
  }
229
438
  placement() {
230
- const backend = this.config.backend;
231
439
  return {
232
440
  prepare: async (run, context, work) => {
233
- const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
234
- await this.namespaces.prepare(namespace);
235
- // what an earlier run left when the server stopped before its end; the runs
236
- // of a caller execute one at a time, so no other run is in the namespace
237
- await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL}!=${(0, job_placement_1.getRunLabelValue)(run.id)}`, RELEASE_TIMEOUT);
238
- // the claims earlier runs kept that expired; the others this run may mount
239
- const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
240
- now: new Date(),
241
- keep: this.keep > 0,
242
- timeout: RELEASE_TIMEOUT,
243
- });
244
- if (next) {
245
- this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
441
+ const namespace = this.namespaceOf(run);
442
+ if (run.up) {
443
+ if (context.signal.aborted) {
444
+ throw new Error('the run was cancelled');
445
+ }
446
+ this.serviceRuns.set(namespace, (this.serviceRuns.get(namespace) ?? 0) + 1);
447
+ this.asleep.delete(namespace);
448
+ this.sweepServiceRuns();
449
+ const up = run.up;
450
+ // after what a stop of its sleep left in it went (see restored)
451
+ await this.enqueue(namespace, async () => {
452
+ if (context.signal.aborted) {
453
+ throw new Error('the run was cancelled');
454
+ }
455
+ // reached by the server at the Services of its endpoints
456
+ await this.namespaces.prepare(namespace, {
457
+ serviceRun: (0, job_placement_1.getRunLabelValue)(run.id),
458
+ server: this.namespace,
459
+ ports: (up.endpoints ?? []).map((endpoint) => endpoint.port),
460
+ quota: this.config.runs.up.quota,
461
+ });
462
+ });
463
+ context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
464
+ return { kubernetes: this.environmentOf(run, namespace), env: this.access.env };
246
465
  }
466
+ const label = (0, job_placement_1.getRunLabelValue)(run.id);
467
+ let capabilitiesProblem;
468
+ await this.enqueue(namespace, async () => {
469
+ if (context.signal.aborted) {
470
+ throw new Error('the run was cancelled');
471
+ }
472
+ capabilitiesProblem = await this.namespaces.prepare(namespace);
473
+ // what an earlier run left when the server stopped before its end, or when its
474
+ // release failed: the objects of a run that does not execute. One that starts
475
+ // after this waits for it here before it makes any.
476
+ if (!this.tidy.has(namespace)) {
477
+ const running = [...new Set([label, ...(this.active.get(namespace)?.keys() ?? [])])];
478
+ await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL} notin (${running.join(',')})`, RELEASE_TIMEOUT);
479
+ this.tidy.add(namespace);
480
+ }
481
+ // the claims earlier runs kept that expired; the others this run may mount
482
+ const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
483
+ now: new Date(),
484
+ keep: this.keep > 0,
485
+ timeout: RELEASE_TIMEOUT,
486
+ inUse: this.scopesInUse(namespace, label),
487
+ });
488
+ if (next) {
489
+ this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
490
+ }
491
+ });
247
492
  this.sweepLeftClaims();
248
493
  context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
249
- const kubernetes = {
250
- type: 'kubernetes',
251
- namespace,
252
- kubeConfig: this.access.kubeConfig,
253
- context: this.access.context,
254
- httpRoutes: [],
255
- storageClass: backend.storageClass,
256
- volumeSize: backend.volumeSize,
257
- maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
258
- networkPolicyEnforced: backend.networkPolicy === 'enforced',
259
- server: {
260
- run: run.id,
261
- namespace: this.namespace,
262
- pools: backend.pools,
263
- providers: backend.providers,
264
- accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
265
- ...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace) } : {}),
266
- ...(this.keep > 0 ? { volumeScope: getVolumeScope(run) } : {}),
267
- },
494
+ return {
495
+ kubernetes: this.environmentOf(run, namespace, work, capabilitiesProblem),
496
+ env: this.access.env,
268
497
  };
269
- return { kubernetes, env: this.access.env };
270
498
  },
271
499
  release: async (run, context) => {
272
- const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
500
+ const namespace = this.namespaceOf(run);
501
+ if (run.up) {
502
+ const say = (text) => context.emit({ type: 'log', task: 'hammerkit', stream: 'stderr', text });
503
+ try {
504
+ if (context.keep?.() === 'everything') {
505
+ // the services are another execution's of the run now: the one it moved to, or
506
+ // the one a move that did not get there leaves them to
507
+ return;
508
+ }
509
+ if (context.keep?.() === 'volumes' && this.keepServiceRuns) {
510
+ // it sleeps: what runs goes, the namespace stays with its claims and policies
511
+ this.asleep.add(namespace);
512
+ await this.enqueue(namespace, () => removeRunObjects(this.instance, namespace, job_placement_1.RUN_LABEL, RELEASE_TIMEOUT, { claims: false })).catch((e) => {
513
+ // the wake applies its services again over what is left, the end removes it
514
+ const text = `what runs in the namespace ${namespace} could not be stopped: ${(0, log_1.getErrorMessage)(e)}`;
515
+ this.log?.(text);
516
+ say(text);
517
+ });
518
+ return;
519
+ }
520
+ await this.enqueue(namespace, () => this.removeServiceRun(namespace, say));
521
+ }
522
+ finally {
523
+ const executions = (this.serviceRuns.get(namespace) ?? 1) - 1;
524
+ if (executions > 0) {
525
+ this.serviceRuns.set(namespace, executions);
526
+ }
527
+ else {
528
+ this.serviceRuns.delete(namespace);
529
+ }
530
+ }
531
+ return;
532
+ }
273
533
  try {
274
534
  const selector = `${job_placement_1.RUN_LABEL}=${(0, job_placement_1.getRunLabelValue)(run.id)}`;
275
535
  await removeRunObjects(this.instance, namespace, selector, RELEASE_TIMEOUT);
276
536
  }
277
537
  catch (e) {
538
+ // the next run removes what is left
539
+ this.tidy.delete(namespace);
278
540
  context.emit({
279
541
  type: 'log',
280
542
  task: 'hammerkit',
@@ -300,8 +562,69 @@ class KubernetesExecutor {
300
562
  },
301
563
  };
302
564
  }
303
- // The kept claims of a namespace that expired go once no run of the caller is in it: at
304
- // the soonest expiry the server knows of
565
+ // The cluster as the process of a run sees it: the namespace its tasks and services go in.
566
+ environmentOf(run, namespace, work, capabilitiesProblem) {
567
+ const backend = this.config.backend;
568
+ return {
569
+ type: 'kubernetes',
570
+ namespace,
571
+ kubeConfig: this.access.kubeConfig,
572
+ context: this.access.context,
573
+ httpRoutes: [],
574
+ storageClass: backend.storageClass,
575
+ volumeSize: backend.volumeSize,
576
+ maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
577
+ networkPolicyEnforced: backend.networkPolicy === 'enforced',
578
+ server: {
579
+ run: run.id,
580
+ namespace: this.namespace,
581
+ pools: backend.pools,
582
+ providers: backend.providers,
583
+ accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
584
+ ...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace, backend.sandboxes.runner) } : {}),
585
+ // the claims of a service run go with its namespace
586
+ ...(this.keep > 0 && !run.up ? { volumeScope: getVolumeScope(run) } : {}),
587
+ ...(run.up ? { serviceRun: true } : {}),
588
+ ...(backend.capabilities
589
+ ? {
590
+ capabilities: {
591
+ runtimeClass: backend.capabilities.runtimeClass,
592
+ ...(capabilitiesProblem ? { unavailable: capabilitiesProblem } : {}),
593
+ },
594
+ }
595
+ : {}),
596
+ },
597
+ };
598
+ }
599
+ // Once, on the first service run: the namespaces of service runs the server left when it
600
+ // stopped, whose services went with it
601
+ // Once when the server starts, and on the next service run after a namespace could not be
602
+ // removed: the namespaces of service runs that none of the server's runs is in, whose
603
+ // services went with the process of a server that stopped
604
+ sweepServiceRuns() {
605
+ if (this.serviceRunsSwept) {
606
+ return;
607
+ }
608
+ this.serviceRunsSwept = true;
609
+ Promise.resolve()
610
+ .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,${exports.SERVICE_RUN_LABEL}` }))
611
+ .then(async (namespaces) => {
612
+ for (const namespace of namespaces.items) {
613
+ const name = namespace.metadata?.name;
614
+ if (name &&
615
+ isCallerNamespace(this.config.backend.namespacePrefix, name) &&
616
+ !this.serviceRuns.has(name) &&
617
+ !this.asleep.has(name) &&
618
+ !namespace.metadata?.deletionTimestamp) {
619
+ this.log?.(`removing the namespace ${name} of a service run from before the server started`);
620
+ await removeServiceRunNamespace(this.instance, name, 0);
621
+ }
622
+ }
623
+ })
624
+ .catch((e) => this.log?.(`the namespaces of service runs could not be removed: ${(0, log_1.getErrorMessage)(e)}`));
625
+ }
626
+ // The kept claims of a namespace that expired go at the soonest expiry the server knows
627
+ // of, but those of a scope a run of the caller executes in
305
628
  sweepLater(namespace, delay) {
306
629
  const at = Date.now() + delay + 1000;
307
630
  const pending = this.sweeps.get(namespace);
@@ -321,6 +644,7 @@ class KubernetesExecutor {
321
644
  now: new Date(),
322
645
  keep: this.keep > 0,
323
646
  timeout: RELEASE_TIMEOUT,
647
+ inUse: this.scopesInUse(namespace),
324
648
  });
325
649
  if (removed.length > 0) {
326
650
  this.log?.(`removed ${removed.length} kept claims in ${namespace}`);
@@ -337,7 +661,7 @@ class KubernetesExecutor {
337
661
  }
338
662
  this.swept = true;
339
663
  Promise.resolve()
340
- .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true` }))
664
+ .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,!${exports.SERVICE_RUN_LABEL}` }))
341
665
  .then((namespaces) => {
342
666
  for (const namespace of namespaces.items) {
343
667
  const name = namespace.metadata?.name;
@@ -352,11 +676,55 @@ class KubernetesExecutor {
352
676
  }
353
677
  }
354
678
  exports.KubernetesExecutor = KubernetesExecutor;
679
+ const ENFORCE_LABEL = 'pod-security.kubernetes.io/enforce';
680
+ const AUDIT_LABEL = 'pod-security.kubernetes.io/audit';
681
+ /**
682
+ * The Pod Security level a caller's namespace is made with: `baseline`, no privileged pods,
683
+ * host paths or host network, whatever a build asks. `baseline` also refuses the capabilities
684
+ * of `backend.capabilities`, sandboxed or not: with them the namespace enforces `privileged`
685
+ * and audits `baseline`, and the operator's admission policy (see the docs on capabilities)
686
+ * refuses what `baseline` would to a pod outside the sandbox's RuntimeClass.
687
+ */
688
+ function getPodSecurityLabels(backend) {
689
+ return backend.capabilities
690
+ ? { [ENFORCE_LABEL]: 'privileged', [AUDIT_LABEL]: 'baseline' }
691
+ : { [ENFORCE_LABEL]: 'baseline' };
692
+ }
693
+ exports.getPodSecurityLabels = getPodSecurityLabels;
694
+ /**
695
+ * The server never changes the level of a namespace it made (that would take `patch` on every
696
+ * namespace of the cluster): one made before `backend.capabilities` is relabelled by the
697
+ * operator. Until then its tasks that add capabilities fail with this, the others run.
698
+ */
699
+ function getCapabilitiesProblem(namespace, labels, backend) {
700
+ const enforce = labels?.[ENFORCE_LABEL];
701
+ if (!backend.capabilities || enforce === 'privileged') {
702
+ return undefined;
703
+ }
704
+ return (`the namespace ${namespace} was made before backend.capabilities and enforces ` +
705
+ `${enforce ? `Pod Security ${enforce}` : 'no Pod Security level of its own'}, not privileged: kubectl label namespace ${namespace} ` +
706
+ `${ENFORCE_LABEL}=privileged ${AUDIT_LABEL}=baseline --overwrite`);
707
+ }
708
+ exports.getCapabilitiesProblem = getCapabilitiesProblem;
709
+ /**
710
+ * A namespace made while the server had `backend.capabilities` admits privileged pods but for
711
+ * the operator's admission policy. Without capabilities the server runs nothing in it until the
712
+ * operator sets it back.
713
+ */
714
+ function getPrivilegedNamespaceProblem(namespace, labels, backend) {
715
+ if (backend.capabilities || labels?.[ENFORCE_LABEL] !== 'privileged') {
716
+ return undefined;
717
+ }
718
+ return (`the namespace ${namespace} enforces Pod Security privileged, made while the server had ` +
719
+ `backend.capabilities, and the server runs nothing in it without them: kubectl label namespace ` +
720
+ `${namespace} ${ENFORCE_LABEL}=baseline ${AUDIT_LABEL}- --overwrite`);
721
+ }
722
+ exports.getPrivilegedNamespaceProblem = getPrivilegedNamespaceProblem;
355
723
  // What may mount a claim a run kept: the runs of the same repository, as they share the
356
724
  // cache, and on a sandbox the runs of that sandbox only, whose claim is on the same node or
357
725
  // zone as the kept ones. The owner is the namespace's.
358
726
  function getVolumeScope(run) {
359
- if (run.source.type === 'sandbox') {
727
+ if (run.source.type === 'sandbox' && !run.source.check) {
360
728
  return `sandbox-${(0, crypto_1.createHash)('sha256').update(run.source.id).digest('hex').slice(0, 24)}`;
361
729
  }
362
730
  return (0, executor_1.cacheNamespace)(run);
@@ -370,6 +738,8 @@ class CallerNamespaces {
370
738
  constructor(instance, backend) {
371
739
  this.instance = instance;
372
740
  this.backend = backend;
741
+ // the namespaces this process set up, by their uid, and when
742
+ this.applied = new Map();
373
743
  }
374
744
  name(owner) {
375
745
  return getCallerNamespace(this.backend.namespacePrefix, owner);
@@ -377,9 +747,15 @@ class CallerNamespaces {
377
747
  // The caller's namespace: made by the server and kept apart from the others. The
378
748
  // server binds its rights there, isolates the network of the pods, applies the
379
749
  // operator's quota and gives containers without resources default requests, so they
380
- // are admitted under a quota on compute. All of it is replaced on every run, so the
381
- // namespace follows the config.
382
- async prepare(namespace) {
750
+ // are admitted under a quota on compute. All of it is replaced when the server sets up
751
+ // a namespace it did not set up in the last `NAMESPACE_SETUP_REUSE_MS` (it reads the
752
+ // config once, at its start), so the namespace follows the config; a namespace made anew
753
+ // since (deleted and made again) is set up again at once. Returns what keeps the tasks that
754
+ // add capabilities from running in it (getCapabilitiesProblem), if anything.
755
+ async prepare(namespace,
756
+ // a service run's own namespace: its label, the namespace of the server that reaches its
757
+ // endpoints, and its quota
758
+ serviceRun) {
383
759
  const backend = this.backend;
384
760
  let existing;
385
761
  try {
@@ -393,17 +769,33 @@ class CallerNamespaces {
393
769
  if (existing && existing.metadata?.labels?.[MANAGED_LABEL] !== 'true') {
394
770
  throw new Error(`the namespace ${namespace} was not made by hammerkit, the server does not run tasks in it`);
395
771
  }
772
+ const privileged = getPrivilegedNamespaceProblem(namespace, existing?.metadata?.labels, backend);
773
+ if (privileged) {
774
+ throw new Error(privileged);
775
+ }
776
+ // one made by another run of this server at the same time has the labels of its config
777
+ const problem = () => getCapabilitiesProblem(namespace, existing?.metadata?.labels ?? getPodSecurityLabels(backend), backend);
778
+ const started = Date.now();
779
+ const applied = this.applied.get(namespace);
780
+ if (existing &&
781
+ !existing.metadata?.deletionTimestamp &&
782
+ applied &&
783
+ applied.uid === existing.metadata?.uid &&
784
+ started - applied.at < NAMESPACE_SETUP_REUSE_MS) {
785
+ return problem();
786
+ }
787
+ this.applied.delete(namespace);
396
788
  if (!existing) {
397
789
  try {
398
- await this.instance.coreApi.createNamespace({
790
+ existing = await this.instance.coreApi.createNamespace({
399
791
  body: {
400
792
  metadata: {
401
793
  name: namespace,
402
794
  labels: {
403
795
  [MANAGED_LABEL]: 'true',
404
796
  'hammerkit.dev/caller': namespace,
405
- // no privileged pods, host paths or host network, whatever a build asks
406
- 'pod-security.kubernetes.io/enforce': 'baseline',
797
+ ...(serviceRun ? { [exports.SERVICE_RUN_LABEL]: serviceRun.serviceRun } : {}),
798
+ ...getPodSecurityLabels(backend),
407
799
  },
408
800
  },
409
801
  },
@@ -419,7 +811,10 @@ class CallerNamespaces {
419
811
  const [first, ...policies] = getNamespacePolicies(namespace, backend.deniedEgress, backend.allowedEgress);
420
812
  // the API server may refuse what the binding allows for a moment after it was made
421
813
  await untilAllowed(() => (0, apply_1.replace)(this.instance, first));
422
- for (const policy of policies) {
814
+ for (const policy of [
815
+ ...policies,
816
+ ...(serviceRun ? [getServerIngressPolicy(namespace, serviceRun.server, serviceRun.ports)] : []),
817
+ ]) {
423
818
  await (0, apply_1.replace)(this.instance, policy);
424
819
  }
425
820
  const metadata = {
@@ -432,7 +827,7 @@ class CallerNamespaces {
432
827
  apiVersion: 'v1',
433
828
  kind: 'ResourceQuota',
434
829
  metadata,
435
- spec: { hard: backend.quota },
830
+ spec: { hard: serviceRun?.quota ?? backend.quota },
436
831
  };
437
832
  const defaults = backend.defaultResources;
438
833
  const limits = {
@@ -450,7 +845,7 @@ class CallerNamespaces {
450
845
  },
451
846
  };
452
847
  for (const [object, wanted] of [
453
- [quota, !!backend.quota],
848
+ [quota, !!(serviceRun?.quota ?? backend.quota)],
454
849
  [limits, !!defaults],
455
850
  ]) {
456
851
  if (wanted) {
@@ -465,6 +860,11 @@ class CallerNamespaces {
465
860
  });
466
861
  }
467
862
  }
863
+ const uid = existing?.metadata?.uid;
864
+ if (uid) {
865
+ this.applied.set(namespace, { uid, at: started });
866
+ }
867
+ return problem();
468
868
  }
469
869
  whoAmI() {
470
870
  if (!this.subject) {
@@ -487,7 +887,7 @@ class CallerNamespaces {
487
887
  exports.CallerNamespaces = CallerNamespaces;
488
888
  // The claim of a sandbox as the Jobs of its run mount it: the run's copy of the files is
489
889
  // `root`, a source below it is the same path below `subPath` in the claim.
490
- function getWorkspace(root, claim, namespace) {
890
+ function getWorkspace(root, claim, namespace, runner) {
491
891
  if (claim.namespace !== namespace) {
492
892
  throw new Error(`the sandbox is in ${claim.namespace}, the run in ${namespace}`);
493
893
  }
@@ -497,6 +897,7 @@ function getWorkspace(root, claim, namespace) {
497
897
  subPath: claim.subPath,
498
898
  label: claim.label,
499
899
  ...(claim.pod ? { pod: claim.pod } : {}),
900
+ ...(runner ? { runner: true } : {}),
500
901
  };
501
902
  }
502
903
  // Everything a run made in the caller's namespace, by the label selector of the run:
@@ -505,16 +906,21 @@ function getWorkspace(root, claim, namespace) {
505
906
  // run outlives its policy.
506
907
  // Deletes what a run made. The NetworkPolicies go last, once the pods are: a limited
507
908
  // pod's own policy is all that limits it.
508
- async function removeRunObjects(instance, namespace, labelSelector, timeout) {
909
+ async function removeRunObjects(instance, namespace, labelSelector, timeout,
910
+ // `claims: false` leaves the claims: a service run that sleeps keeps its volumes
911
+ options = {}) {
912
+ const claims = options.claims ?? true;
509
913
  const selector = { namespace, labelSelector };
510
914
  await instance.batchApi.deleteCollectionNamespacedJob({ ...selector, propagationPolicy: 'Background' });
511
915
  await instance.appsApi.deleteCollectionNamespacedDeployment({ ...selector, propagationPolicy: 'Background' });
512
916
  await instance.coreApi.deleteCollectionNamespacedPod({ ...selector, gracePeriodSeconds: 0 });
513
917
  await instance.coreApi.deleteCollectionNamespacedService(selector);
514
918
  await instance.coreApi.deleteCollectionNamespacedSecret(selector);
515
- await instance.coreApi.deleteCollectionNamespacedPersistentVolumeClaim(selector);
919
+ if (claims) {
920
+ await instance.coreApi.deleteCollectionNamespacedPersistentVolumeClaim(selector);
921
+ }
516
922
  // the next run of the caller also uses the same claim names
517
- await awaitRunObjectsGone(instance, namespace, labelSelector, timeout);
923
+ await awaitRunObjectsGone(instance, namespace, labelSelector, timeout, claims);
518
924
  const policies = await instance.objectApi.list('networking.k8s.io/v1', 'NetworkPolicy', namespace, undefined, undefined, undefined, undefined, labelSelector);
519
925
  for (const policy of policies.items) {
520
926
  // the items of a list do not say their kind
@@ -543,11 +949,13 @@ async function untilAllowed(fn, attempts = 20) {
543
949
  }
544
950
  }
545
951
  // Until the pods and claims of a run are gone: a claim is kept while a pod uses it.
546
- async function awaitRunObjectsGone(instance, namespace, labelSelector, timeout) {
952
+ async function awaitRunObjectsGone(instance, namespace, labelSelector, timeout, withClaims = true) {
547
953
  const deadline = Date.now() + timeout;
548
954
  for (;;) {
549
955
  const pods = await instance.coreApi.listNamespacedPod({ namespace, labelSelector });
550
- const claims = await instance.coreApi.listNamespacedPersistentVolumeClaim({ namespace, labelSelector });
956
+ const claims = withClaims
957
+ ? await instance.coreApi.listNamespacedPersistentVolumeClaim({ namespace, labelSelector })
958
+ : { items: [] };
551
959
  const left = [...pods.items, ...claims.items].map((item) => `${item.kind ?? ''} ${item.metadata?.name}`.trim());
552
960
  if (left.length === 0) {
553
961
  return;