hammerkit 1.27.0-next.9 → 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 (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,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.
@@ -186,6 +261,14 @@ class KubernetesExecutor {
186
261
  this.sweeps = new Map();
187
262
  // whether the kept claims a server left when it stopped were looked for
188
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();
189
272
  this.access = getClusterAccess(config.backend, processEnvs);
190
273
  this.instance = (0, kubernetes_instance_1.createKubernetesInstances)({
191
274
  type: 'kubernetes',
@@ -203,10 +286,42 @@ class KubernetesExecutor {
203
286
  this.namespace = namespace;
204
287
  this.namespaces = new CallerNamespaces(this.instance, config.backend);
205
288
  this.keep = config.backend.keepVolumes === false ? 0 : (0, units_1.parseDuration)(config.backend.keepVolumes);
289
+ this.keepServiceRuns = config.runs.up.keepVolumes;
206
290
  this.log = options.log;
207
291
  this.host = new executor_1.HostExecutor({ ...options, placement: this.placement() });
208
292
  }
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
+ }
209
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');
321
+ }
322
+ // in a namespace of its own, apart from the caller's runs and their claims
323
+ return this.host.execute(run, context);
324
+ }
210
325
  const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
211
326
  const label = (0, job_placement_1.getRunLabelValue)(run.id);
212
327
  let runs = this.active.get(namespace);
@@ -225,11 +340,74 @@ class KubernetesExecutor {
225
340
  }
226
341
  }
227
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
+ }
405
+ }
228
406
  // The runs of a scope share its kept claims, which one of them may change at a time:
229
407
  // what a task finds in one is what the last run left there. Without kept claims, runs
230
408
  // share no object and nothing is held.
231
409
  exclusive(run) {
232
- if (this.keep === 0) {
410
+ if (this.keep === 0 || run.up) {
233
411
  return undefined;
234
412
  }
235
413
  return `${getCallerNamespace(this.config.backend.namespacePrefix, run.owner)}/${getVolumeScope(run)}`;
@@ -258,21 +436,48 @@ class KubernetesExecutor {
258
436
  return execution;
259
437
  }
260
438
  placement() {
261
- const backend = this.config.backend;
262
439
  return {
263
440
  prepare: async (run, context, work) => {
264
- const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
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 };
465
+ }
265
466
  const label = (0, job_placement_1.getRunLabelValue)(run.id);
467
+ let capabilitiesProblem;
266
468
  await this.enqueue(namespace, async () => {
267
469
  if (context.signal.aborted) {
268
470
  throw new Error('the run was cancelled');
269
471
  }
270
- await this.namespaces.prepare(namespace);
271
- // what an earlier run left when the server stopped before its end: the objects of
272
- // a run that does not execute. One that starts after this waits for it here
273
- // before it makes any.
274
- const running = [...new Set([label, ...(this.active.get(namespace)?.keys() ?? [])])];
275
- await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL} notin (${running.join(',')})`, RELEASE_TIMEOUT);
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
+ }
276
481
  // the claims earlier runs kept that expired; the others this run may mount
277
482
  const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
278
483
  now: new Date(),
@@ -286,35 +491,52 @@ class KubernetesExecutor {
286
491
  });
287
492
  this.sweepLeftClaims();
288
493
  context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
289
- const kubernetes = {
290
- type: 'kubernetes',
291
- namespace,
292
- kubeConfig: this.access.kubeConfig,
293
- context: this.access.context,
294
- httpRoutes: [],
295
- storageClass: backend.storageClass,
296
- volumeSize: backend.volumeSize,
297
- maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
298
- networkPolicyEnforced: backend.networkPolicy === 'enforced',
299
- server: {
300
- run: run.id,
301
- namespace: this.namespace,
302
- pools: backend.pools,
303
- providers: backend.providers,
304
- accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
305
- ...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace) } : {}),
306
- ...(this.keep > 0 ? { volumeScope: getVolumeScope(run) } : {}),
307
- },
494
+ return {
495
+ kubernetes: this.environmentOf(run, namespace, work, capabilitiesProblem),
496
+ env: this.access.env,
308
497
  };
309
- return { kubernetes, env: this.access.env };
310
498
  },
311
499
  release: async (run, context) => {
312
- 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
+ }
313
533
  try {
314
534
  const selector = `${job_placement_1.RUN_LABEL}=${(0, job_placement_1.getRunLabelValue)(run.id)}`;
315
535
  await removeRunObjects(this.instance, namespace, selector, RELEASE_TIMEOUT);
316
536
  }
317
537
  catch (e) {
538
+ // the next run removes what is left
539
+ this.tidy.delete(namespace);
318
540
  context.emit({
319
541
  type: 'log',
320
542
  task: 'hammerkit',
@@ -340,6 +562,67 @@ class KubernetesExecutor {
340
562
  },
341
563
  };
342
564
  }
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
+ }
343
626
  // The kept claims of a namespace that expired go at the soonest expiry the server knows
344
627
  // of, but those of a scope a run of the caller executes in
345
628
  sweepLater(namespace, delay) {
@@ -378,7 +661,7 @@ class KubernetesExecutor {
378
661
  }
379
662
  this.swept = true;
380
663
  Promise.resolve()
381
- .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true` }))
664
+ .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,!${exports.SERVICE_RUN_LABEL}` }))
382
665
  .then((namespaces) => {
383
666
  for (const namespace of namespaces.items) {
384
667
  const name = namespace.metadata?.name;
@@ -393,11 +676,55 @@ class KubernetesExecutor {
393
676
  }
394
677
  }
395
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;
396
723
  // What may mount a claim a run kept: the runs of the same repository, as they share the
397
724
  // cache, and on a sandbox the runs of that sandbox only, whose claim is on the same node or
398
725
  // zone as the kept ones. The owner is the namespace's.
399
726
  function getVolumeScope(run) {
400
- if (run.source.type === 'sandbox') {
727
+ if (run.source.type === 'sandbox' && !run.source.check) {
401
728
  return `sandbox-${(0, crypto_1.createHash)('sha256').update(run.source.id).digest('hex').slice(0, 24)}`;
402
729
  }
403
730
  return (0, executor_1.cacheNamespace)(run);
@@ -411,6 +738,8 @@ class CallerNamespaces {
411
738
  constructor(instance, backend) {
412
739
  this.instance = instance;
413
740
  this.backend = backend;
741
+ // the namespaces this process set up, by their uid, and when
742
+ this.applied = new Map();
414
743
  }
415
744
  name(owner) {
416
745
  return getCallerNamespace(this.backend.namespacePrefix, owner);
@@ -418,9 +747,15 @@ class CallerNamespaces {
418
747
  // The caller's namespace: made by the server and kept apart from the others. The
419
748
  // server binds its rights there, isolates the network of the pods, applies the
420
749
  // operator's quota and gives containers without resources default requests, so they
421
- // are admitted under a quota on compute. All of it is replaced on every run, so the
422
- // namespace follows the config.
423
- 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) {
424
759
  const backend = this.backend;
425
760
  let existing;
426
761
  try {
@@ -434,17 +769,33 @@ class CallerNamespaces {
434
769
  if (existing && existing.metadata?.labels?.[MANAGED_LABEL] !== 'true') {
435
770
  throw new Error(`the namespace ${namespace} was not made by hammerkit, the server does not run tasks in it`);
436
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);
437
788
  if (!existing) {
438
789
  try {
439
- await this.instance.coreApi.createNamespace({
790
+ existing = await this.instance.coreApi.createNamespace({
440
791
  body: {
441
792
  metadata: {
442
793
  name: namespace,
443
794
  labels: {
444
795
  [MANAGED_LABEL]: 'true',
445
796
  'hammerkit.dev/caller': namespace,
446
- // no privileged pods, host paths or host network, whatever a build asks
447
- 'pod-security.kubernetes.io/enforce': 'baseline',
797
+ ...(serviceRun ? { [exports.SERVICE_RUN_LABEL]: serviceRun.serviceRun } : {}),
798
+ ...getPodSecurityLabels(backend),
448
799
  },
449
800
  },
450
801
  },
@@ -460,7 +811,10 @@ class CallerNamespaces {
460
811
  const [first, ...policies] = getNamespacePolicies(namespace, backend.deniedEgress, backend.allowedEgress);
461
812
  // the API server may refuse what the binding allows for a moment after it was made
462
813
  await untilAllowed(() => (0, apply_1.replace)(this.instance, first));
463
- for (const policy of policies) {
814
+ for (const policy of [
815
+ ...policies,
816
+ ...(serviceRun ? [getServerIngressPolicy(namespace, serviceRun.server, serviceRun.ports)] : []),
817
+ ]) {
464
818
  await (0, apply_1.replace)(this.instance, policy);
465
819
  }
466
820
  const metadata = {
@@ -473,7 +827,7 @@ class CallerNamespaces {
473
827
  apiVersion: 'v1',
474
828
  kind: 'ResourceQuota',
475
829
  metadata,
476
- spec: { hard: backend.quota },
830
+ spec: { hard: serviceRun?.quota ?? backend.quota },
477
831
  };
478
832
  const defaults = backend.defaultResources;
479
833
  const limits = {
@@ -491,7 +845,7 @@ class CallerNamespaces {
491
845
  },
492
846
  };
493
847
  for (const [object, wanted] of [
494
- [quota, !!backend.quota],
848
+ [quota, !!(serviceRun?.quota ?? backend.quota)],
495
849
  [limits, !!defaults],
496
850
  ]) {
497
851
  if (wanted) {
@@ -506,6 +860,11 @@ class CallerNamespaces {
506
860
  });
507
861
  }
508
862
  }
863
+ const uid = existing?.metadata?.uid;
864
+ if (uid) {
865
+ this.applied.set(namespace, { uid, at: started });
866
+ }
867
+ return problem();
509
868
  }
510
869
  whoAmI() {
511
870
  if (!this.subject) {
@@ -528,7 +887,7 @@ class CallerNamespaces {
528
887
  exports.CallerNamespaces = CallerNamespaces;
529
888
  // The claim of a sandbox as the Jobs of its run mount it: the run's copy of the files is
530
889
  // `root`, a source below it is the same path below `subPath` in the claim.
531
- function getWorkspace(root, claim, namespace) {
890
+ function getWorkspace(root, claim, namespace, runner) {
532
891
  if (claim.namespace !== namespace) {
533
892
  throw new Error(`the sandbox is in ${claim.namespace}, the run in ${namespace}`);
534
893
  }
@@ -538,6 +897,7 @@ function getWorkspace(root, claim, namespace) {
538
897
  subPath: claim.subPath,
539
898
  label: claim.label,
540
899
  ...(claim.pod ? { pod: claim.pod } : {}),
900
+ ...(runner ? { runner: true } : {}),
541
901
  };
542
902
  }
543
903
  // Everything a run made in the caller's namespace, by the label selector of the run:
@@ -546,16 +906,21 @@ function getWorkspace(root, claim, namespace) {
546
906
  // run outlives its policy.
547
907
  // Deletes what a run made. The NetworkPolicies go last, once the pods are: a limited
548
908
  // pod's own policy is all that limits it.
549
- 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;
550
913
  const selector = { namespace, labelSelector };
551
914
  await instance.batchApi.deleteCollectionNamespacedJob({ ...selector, propagationPolicy: 'Background' });
552
915
  await instance.appsApi.deleteCollectionNamespacedDeployment({ ...selector, propagationPolicy: 'Background' });
553
916
  await instance.coreApi.deleteCollectionNamespacedPod({ ...selector, gracePeriodSeconds: 0 });
554
917
  await instance.coreApi.deleteCollectionNamespacedService(selector);
555
918
  await instance.coreApi.deleteCollectionNamespacedSecret(selector);
556
- await instance.coreApi.deleteCollectionNamespacedPersistentVolumeClaim(selector);
919
+ if (claims) {
920
+ await instance.coreApi.deleteCollectionNamespacedPersistentVolumeClaim(selector);
921
+ }
557
922
  // the next run of the caller also uses the same claim names
558
- await awaitRunObjectsGone(instance, namespace, labelSelector, timeout);
923
+ await awaitRunObjectsGone(instance, namespace, labelSelector, timeout, claims);
559
924
  const policies = await instance.objectApi.list('networking.k8s.io/v1', 'NetworkPolicy', namespace, undefined, undefined, undefined, undefined, labelSelector);
560
925
  for (const policy of policies.items) {
561
926
  // the items of a list do not say their kind
@@ -584,11 +949,13 @@ async function untilAllowed(fn, attempts = 20) {
584
949
  }
585
950
  }
586
951
  // Until the pods and claims of a run are gone: a claim is kept while a pod uses it.
587
- async function awaitRunObjectsGone(instance, namespace, labelSelector, timeout) {
952
+ async function awaitRunObjectsGone(instance, namespace, labelSelector, timeout, withClaims = true) {
588
953
  const deadline = Date.now() + timeout;
589
954
  for (;;) {
590
955
  const pods = await instance.coreApi.listNamespacedPod({ namespace, labelSelector });
591
- const claims = await instance.coreApi.listNamespacedPersistentVolumeClaim({ namespace, labelSelector });
956
+ const claims = withClaims
957
+ ? await instance.coreApi.listNamespacedPersistentVolumeClaim({ namespace, labelSelector })
958
+ : { items: [] };
592
959
  const left = [...pods.items, ...claims.items].map((item) => `${item.kind ?? ''} ${item.metadata?.name}`.trim());
593
960
  if (left.length === 0) {
594
961
  return;