hammerkit 1.27.0-next.2 → 1.27.0-next.21

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 (103) hide show
  1. package/build.schema.json +27 -0
  2. package/dist/cache/backends/registry-cache-backend.js +2 -2
  3. package/dist/cache/explain.js +17 -0
  4. package/dist/cli.js +1 -0
  5. package/dist/docker/item-network.js +13 -5
  6. package/dist/executer/deadline.js +17 -3
  7. package/dist/executer/docker-service.js +30 -8
  8. package/dist/executer/event-cache.js +20 -2
  9. package/dist/executer/execute-work-task.js +5 -3
  10. package/dist/executer/execution-steps.js +25 -4
  11. package/dist/executer/reset-work-tree.js +4 -0
  12. package/dist/executer/scheduler/enqueue-next.js +7 -0
  13. package/dist/executer/scheduler/state-key.js +42 -5
  14. package/dist/harness/connect.js +20 -6
  15. package/dist/harness/mcp-command.js +22 -6
  16. package/dist/harness/mcp.js +63 -114
  17. package/dist/harness/opencode.js +158 -50
  18. package/dist/harness/pi.js +29 -0
  19. package/dist/harness/sandbox-client.js +207 -65
  20. package/dist/harness/service-tools.js +141 -0
  21. package/dist/harness/task-tools.js +221 -0
  22. package/dist/kubernetes/apply.js +27 -16
  23. package/dist/kubernetes/claim-layout.js +71 -0
  24. package/dist/kubernetes/ensure-http-route.js +1 -1
  25. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  26. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  27. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  28. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  29. package/dist/kubernetes/get-service-ip.js +2 -2
  30. package/dist/kubernetes/job-secrets.js +33 -19
  31. package/dist/kubernetes/kept-claims.js +22 -10
  32. package/dist/kubernetes/network-policy.js +85 -27
  33. package/dist/kubernetes/remove-persistent-data.js +3 -1
  34. package/dist/kubernetes/resources.js +25 -3
  35. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  36. package/dist/kubernetes/sandbox-runner.js +626 -0
  37. package/dist/kubernetes/secrets.js +7 -11
  38. package/dist/kubernetes/volumes.js +6 -3
  39. package/dist/network/egress-proxy.js +45 -6
  40. package/dist/optimizer/calculate-checksum.js +3 -2
  41. package/dist/optimizer/check-key.js +94 -0
  42. package/dist/optimizer/check-keys.js +64 -0
  43. package/dist/optimizer/get-work-cache-stats.js +17 -3
  44. package/dist/optimizer/work-task-cache-description.js +51 -1
  45. package/dist/planner/utils/append-work-dependencies.js +10 -5
  46. package/dist/planner/utils/append-work-service.js +1 -0
  47. package/dist/planner/utils/append-work-task.js +15 -2
  48. package/dist/planner/utils/get-work-runtime.js +6 -1
  49. package/dist/planner/utils/normalize-path.js +19 -1
  50. package/dist/planner/utils/parse-work-mount.js +7 -1
  51. package/dist/planner/utils/parse-work-port.js +4 -1
  52. package/dist/planner/utils/task-arguments.js +3 -0
  53. package/dist/planner/validate.js +22 -0
  54. package/dist/planner/work-network.js +3 -1
  55. package/dist/planner/work-runtime-aggregate.js +32 -0
  56. package/dist/planner/work-runtime-docker.js +2 -1
  57. package/dist/planner/work-runtime-kubernetes.js +97 -48
  58. package/dist/planner/work-runtime-local.js +2 -1
  59. package/dist/planner/work-task.js +6 -1
  60. package/dist/program.js +35 -13
  61. package/dist/remote/annotations.js +13 -1
  62. package/dist/remote/device-login.js +7 -2
  63. package/dist/remote/host-config.js +64 -3
  64. package/dist/remote/remote-commands.js +41 -9
  65. package/dist/remote/remote-work-commands.js +246 -1
  66. package/dist/remote/server-client.js +15 -2
  67. package/dist/remote/token-source.js +64 -26
  68. package/dist/run-program.js +9 -0
  69. package/dist/schema/build-file-base-task-schema.js +5 -1
  70. package/dist/schema/build-file-container-service-schema.js +2 -0
  71. package/dist/schema/build-file-include-schema.js +10 -4
  72. package/dist/schema/reference-parser.js +9 -1
  73. package/dist/schema/schema-parser.js +36 -4
  74. package/dist/schema/work-scope-parser.js +33 -10
  75. package/dist/secrets/github-app-token-command.js +3 -1
  76. package/dist/server/accounts-api.js +3 -1
  77. package/dist/server/approvals-api.js +1 -1
  78. package/dist/server/checks-api.js +83 -0
  79. package/dist/server/checks.js +116 -0
  80. package/dist/server/definitions-api.js +4 -2
  81. package/dist/server/endpoints.js +612 -0
  82. package/dist/server/executor.js +56 -5
  83. package/dist/server/kubernetes-backend.js +303 -64
  84. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  85. package/dist/server/oauth.js +12 -1
  86. package/dist/server/plan.js +126 -62
  87. package/dist/server/policy.js +32 -2
  88. package/dist/server/repo-access.js +5 -0
  89. package/dist/server/run-plan.js +50 -19
  90. package/dist/server/run-request.js +64 -3
  91. package/dist/server/run-worker.js +146 -10
  92. package/dist/server/runs.js +347 -27
  93. package/dist/server/sandbox-pod.js +28 -0
  94. package/dist/server/sandbox-storage.js +23 -0
  95. package/dist/server/sandboxes-api.js +10 -4
  96. package/dist/server/sandboxes.js +8 -0
  97. package/dist/server/server-command.js +39 -20
  98. package/dist/server/server-config.js +213 -14
  99. package/dist/server/server.js +268 -15
  100. package/dist/server/service-runs.js +82 -0
  101. package/dist/utils/abort-on-signals.js +46 -6
  102. package/dist/utils/tar.js +36 -0
  103. package/package.json +1 -1
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HostExecutor = exports.volumeScope = exports.cacheNamespace = exports.defaultWorker = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const crypto_1 = require("crypto");
6
+ const checks_1 = require("./checks");
6
7
  const fs_1 = require("fs");
7
8
  const promises_1 = require("fs/promises");
8
9
  const path_1 = require("path");
@@ -35,9 +36,18 @@ exports.defaultWorker = defaultWorker;
35
36
  // share one, those of an upload or a sandbox only their owner's. Somebody who can only
36
37
  // upload, or an agent on a sandbox, can not leave an entry for a run from a reviewed
37
38
  // repository to find.
39
+ //
40
+ // A check on a sandbox runs on a copy of the files a commit of them would hold, so it is a
41
+ // run of the repository like a ref run of that commit, and shares its cache: what its
42
+ // owner may write in the sandbox is not part of it. It leaves entries a run from main can
43
+ // find, keyed by the files it ran on, as a ref run of a branch does: so only a caller who
44
+ // may push a branch of the repository and run from it may check (server.ts).
38
45
  function cacheNamespace(run) {
39
- const key = run.source.type === 'ref' ? `ref\0${run.source.repo}` : `${run.source.type}\0${run.owner.iss}\0${run.owner.sub}`;
40
- return `${run.source.type}-${(0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 24)}`;
46
+ const repository = run.source.type === 'ref' || (run.source.type === 'sandbox' && run.source.check);
47
+ const key = repository
48
+ ? `ref\0${run.source.repo}`
49
+ : `${run.source.type}\0${run.owner.iss}\0${run.owner.sub}`;
50
+ return `${repository ? 'ref' : run.source.type}-${(0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 24)}`;
41
51
  }
42
52
  exports.cacheNamespace = cacheNamespace;
43
53
  // The docker volumes of a run's services are its caller's, within the cache's source trust:
@@ -100,7 +110,7 @@ class HostExecutor {
100
110
  if (extra.length > 0) {
101
111
  throw new Error(`the run would use ${extra.join(', ')}, which it was not admitted for`);
102
112
  }
103
- const refused = (0, run_plan_1.refuseArguments)(run, planned.plan);
113
+ const refused = (0, run_plan_1.refuseArguments)(run, planned.plan, policy);
104
114
  if (refused) {
105
115
  throw new Error(refused);
106
116
  }
@@ -121,13 +131,42 @@ class HostExecutor {
121
131
  let claim;
122
132
  // the files of the sandbox the tasks work on
123
133
  let work;
124
- if (source.type === 'ref') {
134
+ if (source.type === 'sandbox' && source.check) {
135
+ // a check works on the files a push of the sandbox commits, copied out of it: never
136
+ // on the sandbox, whose ignored files (node_modules, outputs) its owner writes
137
+ const { registry, network } = sandboxes;
138
+ cwd = (0, path_1.join)(root, 'source');
139
+ try {
140
+ await registry.exportTree(source.id, source.tree, cwd, abortCtrl.signal);
141
+ }
142
+ catch (e) {
143
+ throw new Error(`the files of the sandbox ${source.id} could not be copied for the check, submit it again: ${e instanceof Error ? e.message : String(e)}`);
144
+ }
145
+ // a repository of its own, as a checkout of the commit is: the root of the project,
146
+ // which the paths of the tasks' keys are relative to, is the one of git
147
+ await (0, git_checkout_1.git)(['init', '--quiet', cwd], {}, abortCtrl.signal, `the check of sandbox ${source.id}`, 'init');
148
+ context.emit({
149
+ type: 'log',
150
+ task: 'hammerkit',
151
+ stream: 'stdout',
152
+ text: `check of sandbox ${source.id} of ${source.repo}: files ${source.tree}, fetched at ${source.commit}`,
153
+ });
154
+ await this.writeBundle(cwd, bundle.files);
155
+ input = {
156
+ entry: (0, path_1.join)(cwd, version.entry),
157
+ ...(planning.policy.network ? { network: (0, work_network_1.toNetworkSchema)(planning.policy.network) } : {}),
158
+ sandboxNetwork: network,
159
+ };
160
+ }
161
+ else if (source.type === 'ref') {
125
162
  cwd = (0, path_1.join)(root, 'source');
126
163
  const { repo, ref, commit: pinned } = source;
127
164
  const credentials = await repos.credentials(repo);
128
165
  let commit;
129
166
  try {
130
- commit = await (0, git_checkout_1.checkoutRef)(repos.url(repo), ref, cwd, credentials.env, abortCtrl.signal, pinned);
167
+ // a woken service run starts the commit it started first, where the ref is now or not
168
+ const fetched = run.wakes ? pinned : ref;
169
+ commit = await (0, git_checkout_1.checkoutRef)(repos.url(repo), fetched, cwd, credentials.env, abortCtrl.signal, pinned);
131
170
  }
132
171
  catch (e) {
133
172
  throw repos.scrub(e, credentials.values);
@@ -188,7 +227,16 @@ class HostExecutor {
188
227
  options: run.options,
189
228
  accounts: run.accounts,
190
229
  volumeScope: volumeScope(run),
230
+ ...((0, checks_1.isCheckRun)(run) ? { check: true } : {}),
191
231
  catalog: planning.environment.hosted?.catalog,
232
+ ...(run.up
233
+ ? {
234
+ up: {
235
+ services: run.up.services,
236
+ endpoints: run.up.endpoints.map(({ service, port }) => ({ service, port })),
237
+ },
238
+ }
239
+ : {}),
192
240
  ...(placed ? { kubernetes: placed.kubernetes } : {}),
193
241
  }, cwd, { ...this.processEnvironment(home, cache), ...placed?.env }, context, abortCtrl.signal);
194
242
  }
@@ -282,6 +330,9 @@ class HostExecutor {
282
330
  if (message.kind === 'event') {
283
331
  context.emit(message.event);
284
332
  }
333
+ else if (message.kind === 'up') {
334
+ context.up?.(message.targets);
335
+ }
285
336
  else if (message.kind === 'result') {
286
337
  result = message;
287
338
  // nothing left to tell the process
@@ -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.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");
@@ -22,6 +22,8 @@ const POLICY_NAME = 'hammerkit';
22
22
  const MANAGED_LABEL = 'hammerkit.dev/managed';
23
23
  // how long the end of a run waits for its pods and claims to be gone
24
24
  const RELEASE_TIMEOUT = 120000;
25
+ // how long the setup of a caller's namespace is taken as it is, before it is applied again
26
+ const NAMESPACE_SETUP_REUSE_MS = 5 * 60000;
25
27
  // where a pod of the cluster finds the token and certificate of its ServiceAccount
26
28
  const IN_CLUSTER_NAMESPACE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace';
27
29
  /**
@@ -32,6 +34,19 @@ function getCallerNamespace(prefix, owner) {
32
34
  return `${prefix}${(0, crypto_1.createHash)('sha256').update(`${owner.iss}\0${owner.sub}`).digest('hex').slice(0, 16)}`;
33
35
  }
34
36
  exports.getCallerNamespace = getCallerNamespace;
37
+ /**
38
+ * The namespace of a service run: one of its own, as the services stay up for hours while
39
+ * the caller's other runs go on in theirs (one at a time, each removing what the one
40
+ * before left).
41
+ */
42
+ function getServiceRunNamespace(prefix, run, wakes = 0) {
43
+ // a woken run starts in a namespace of its own, the one it slept in may still be removed
44
+ const name = wakes > 0 ? `up\0${run}\0${wakes}` : `up\0${run}`;
45
+ return `${prefix}${(0, crypto_1.createHash)('sha256').update(name).digest('hex').slice(0, 16)}`;
46
+ }
47
+ exports.getServiceRunNamespace = getServiceRunNamespace;
48
+ // the label of the namespace of a service run: the run label value of its run
49
+ exports.SERVICE_RUN_LABEL = 'hammerkit.dev/service-run';
35
50
  // Whether a namespace is one getCallerNamespace makes with this prefix: a server named `hk`
36
51
  // passes over the namespaces of a server named `hk-test` on the same cluster.
37
52
  function isCallerNamespace(prefix, name) {
@@ -143,6 +158,64 @@ function getNamespacePolicies(namespace, deniedEgress, allowedEgress = []) {
143
158
  ];
144
159
  }
145
160
  exports.getNamespacePolicies = getNamespacePolicies;
161
+ /**
162
+ * What lets the server into the namespace of a service run, to the ports of its endpoints:
163
+ * policies add up, so this one opens the pods to the server's namespace besides what the
164
+ * others let in, limited ones too.
165
+ */
166
+ // The server reaches the ports of the endpoints, from its own namespace, and no other port:
167
+ // what else the run starts (a database) stays for the pods of the run.
168
+ function getServerIngressPolicy(namespace, server, ports) {
169
+ return {
170
+ apiVersion: 'networking.k8s.io/v1',
171
+ kind: 'NetworkPolicy',
172
+ metadata: {
173
+ name: 'hammerkit-server-ingress',
174
+ namespace,
175
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
176
+ labels: { [MANAGED_LABEL]: 'true' },
177
+ },
178
+ spec: {
179
+ podSelector: {},
180
+ policyTypes: ['Ingress'],
181
+ ingress: ports.length > 0
182
+ ? [
183
+ {
184
+ _from: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': server } } }],
185
+ ports: [...new Set(ports)].map((port) => ({ protocol: 'TCP', port })),
186
+ },
187
+ ]
188
+ : [],
189
+ },
190
+ };
191
+ }
192
+ exports.getServerIngressPolicy = getServerIngressPolicy;
193
+ // The namespace of a service run goes with all it holds; waits up to `timeout` for it.
194
+ async function removeServiceRunNamespace(instance, namespace, timeout) {
195
+ try {
196
+ await instance.coreApi.deleteNamespace({ name: namespace, propagationPolicy: 'Background' });
197
+ }
198
+ catch (e) {
199
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
200
+ return;
201
+ }
202
+ throw e;
203
+ }
204
+ const deadline = Date.now() + timeout;
205
+ while (Date.now() < deadline) {
206
+ try {
207
+ await instance.coreApi.readNamespace({ name: namespace });
208
+ }
209
+ catch (e) {
210
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
211
+ return;
212
+ }
213
+ throw e;
214
+ }
215
+ await (0, sleep_1.sleep)(1000);
216
+ }
217
+ }
218
+ exports.removeServiceRunNamespace = removeServiceRunNamespace;
146
219
  /**
147
220
  * The server's rights in a caller's namespace: the operator's ClusterRole, bound there
148
221
  * only, so the server needs no rights on Secrets or pods anywhere else.
@@ -167,22 +240,31 @@ exports.getRunRoleBinding = getRunRoleBinding;
167
240
  * process of the server as on a host (see HostExecutor); what changes is where its
168
241
  * container tasks and services go: into the caller's namespace, on the nodes of the
169
242
  * 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.
243
+ * of a caller execute side by side in their namespace, up to `runs.limits.running`:
244
+ * the objects of a run are named and found by its run label (getItemKey), so two runs
245
+ * of the same task have their own. What two runs share are the claims the server keeps
246
+ * (`backend.keepVolumes`) of a scope (getVolumeScope): the runs of one scope go one at
247
+ * a time (exclusive). When a run ends, everything made for it is removed, but for the
248
+ * kept claims, which go once they expired.
174
249
  */
175
250
  class KubernetesExecutor {
176
251
  constructor(config, processEnvs, options) {
177
252
  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
253
+ // per caller namespace, what changes objects of the whole namespace: its setup, the
254
+ // removal of what a stopped server left, the sweeps of kept claims. The last one waiting.
181
255
  this.queues = new Map();
256
+ // per caller namespace, the runs that execute in it: by run label, their volume scope
257
+ this.active = new Map();
182
258
  // per caller namespace, when its kept claims are next looked at
183
259
  this.sweeps = new Map();
184
260
  // whether the kept claims a server left when it stopped were looked for
185
261
  this.swept = false;
262
+ // the namespaces where no run of this process left anything: what a run makes goes at its
263
+ // end, unless its release failed
264
+ this.tidy = new Set();
265
+ // the namespaces of the service runs that execute, and whether those of earlier ones went
266
+ this.serviceRuns = new Set();
267
+ this.serviceRunsSwept = false;
186
268
  this.access = getClusterAccess(config.backend, processEnvs);
187
269
  this.instance = (0, kubernetes_instance_1.createKubernetesInstances)({
188
270
  type: 'kubernetes',
@@ -203,17 +285,56 @@ class KubernetesExecutor {
203
285
  this.log = options.log;
204
286
  this.host = new executor_1.HostExecutor({ ...options, placement: this.placement() });
205
287
  }
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');
288
+ async execute(run, context) {
289
+ if (run.up) {
290
+ if (run.source.type === 'sandbox') {
291
+ throw new Error('on a cluster a service run starts from a branch, push the sandbox and start it from there');
211
292
  }
293
+ // in a namespace of its own, apart from the caller's runs and their claims
212
294
  return this.host.execute(run, context);
213
- });
295
+ }
296
+ const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
297
+ const label = (0, job_placement_1.getRunLabelValue)(run.id);
298
+ let runs = this.active.get(namespace);
299
+ if (!runs) {
300
+ runs = new Map();
301
+ this.active.set(namespace, runs);
302
+ }
303
+ runs.set(label, getVolumeScope(run));
304
+ try {
305
+ await this.host.execute(run, context);
306
+ }
307
+ finally {
308
+ runs.delete(label);
309
+ if (runs.size === 0 && this.active.get(namespace) === runs) {
310
+ this.active.delete(namespace);
311
+ }
312
+ }
313
+ }
314
+ namespaceOf(run) {
315
+ const prefix = this.config.backend.namespacePrefix;
316
+ return run.up ? getServiceRunNamespace(prefix, run.id, run.wakes) : getCallerNamespace(prefix, run.owner);
317
+ }
318
+ // The runs of a scope share its kept claims, which one of them may change at a time:
319
+ // what a task finds in one is what the last run left there. Without kept claims, runs
320
+ // share no object and nothing is held.
321
+ exclusive(run) {
322
+ if (this.keep === 0 || run.up) {
323
+ return undefined;
324
+ }
325
+ return `${getCallerNamespace(this.config.backend.namespacePrefix, run.owner)}/${getVolumeScope(run)}`;
326
+ }
327
+ // The scopes of the runs that execute in the namespace, but `except`'s: their kept claims
328
+ // are theirs to mount and stay. A server that keeps none mounts none.
329
+ scopesInUse(namespace, except) {
330
+ if (this.keep === 0) {
331
+ return new Set();
332
+ }
333
+ return new Set([...(this.active.get(namespace) ?? new Map()).entries()]
334
+ .filter(([label]) => label !== except)
335
+ .map(([, scope]) => scope));
214
336
  }
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
337
+ // What changes objects of a whole namespace happens one at a time, not what its runs do
217
338
  enqueue(namespace, fn) {
218
339
  const previous = this.queues.get(namespace) ?? Promise.resolve();
219
340
  const execution = previous.then(fn);
@@ -227,54 +348,94 @@ class KubernetesExecutor {
227
348
  return execution;
228
349
  }
229
350
  placement() {
230
- const backend = this.config.backend;
231
351
  return {
232
352
  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()));
353
+ const namespace = this.namespaceOf(run);
354
+ if (run.up) {
355
+ if (context.signal.aborted) {
356
+ throw new Error('the run was cancelled');
357
+ }
358
+ this.serviceRuns.add(namespace);
359
+ this.sweepServiceRuns();
360
+ // reached by the server at the Services of its endpoints
361
+ await this.namespaces.prepare(namespace, {
362
+ serviceRun: (0, job_placement_1.getRunLabelValue)(run.id),
363
+ server: this.namespace,
364
+ ports: (run.up.endpoints ?? []).map((endpoint) => endpoint.port),
365
+ quota: this.config.runs.up.quota,
366
+ });
367
+ context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
368
+ return { kubernetes: this.environmentOf(run, namespace), env: this.access.env };
246
369
  }
370
+ const label = (0, job_placement_1.getRunLabelValue)(run.id);
371
+ await this.enqueue(namespace, async () => {
372
+ if (context.signal.aborted) {
373
+ throw new Error('the run was cancelled');
374
+ }
375
+ await this.namespaces.prepare(namespace);
376
+ // what an earlier run left when the server stopped before its end, or when its
377
+ // release failed: the objects of a run that does not execute. One that starts
378
+ // after this waits for it here before it makes any.
379
+ if (!this.tidy.has(namespace)) {
380
+ const running = [...new Set([label, ...(this.active.get(namespace)?.keys() ?? [])])];
381
+ await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL} notin (${running.join(',')})`, RELEASE_TIMEOUT);
382
+ this.tidy.add(namespace);
383
+ }
384
+ // the claims earlier runs kept that expired; the others this run may mount
385
+ const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
386
+ now: new Date(),
387
+ keep: this.keep > 0,
388
+ timeout: RELEASE_TIMEOUT,
389
+ inUse: this.scopesInUse(namespace, label),
390
+ });
391
+ if (next) {
392
+ this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
393
+ }
394
+ });
247
395
  this.sweepLeftClaims();
248
396
  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
- },
268
- };
269
- return { kubernetes, env: this.access.env };
397
+ return { kubernetes: this.environmentOf(run, namespace, work), env: this.access.env };
270
398
  },
271
399
  release: async (run, context) => {
272
- const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
400
+ const namespace = this.namespaceOf(run);
401
+ if (run.up) {
402
+ try {
403
+ await removeServiceRunNamespace(this.instance, namespace, RELEASE_TIMEOUT);
404
+ }
405
+ catch (e) {
406
+ // at least nothing of it runs on: its objects are the server's to remove (runRole)
407
+ const forbidden = (0, apply_1.statusCodeOf)(e) === 403;
408
+ const text = forbidden
409
+ ? `the server may not delete the namespace ${namespace}, its ClusterRole needs delete on namespaces (see the backend docs); removing what runs in it`
410
+ : `the namespace ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(e)}`;
411
+ this.log?.(text);
412
+ context.emit({ type: 'log', task: 'hammerkit', stream: 'stderr', text });
413
+ try {
414
+ await removeRunObjects(this.instance, namespace, job_placement_1.RUN_LABEL, RELEASE_TIMEOUT);
415
+ }
416
+ catch (inner) {
417
+ context.emit({
418
+ type: 'log',
419
+ task: 'hammerkit',
420
+ stream: 'stderr',
421
+ text: `what the run made in ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(inner)}`,
422
+ });
423
+ }
424
+ // the next look removes the namespace
425
+ this.serviceRunsSwept = false;
426
+ }
427
+ finally {
428
+ this.serviceRuns.delete(namespace);
429
+ }
430
+ return;
431
+ }
273
432
  try {
274
433
  const selector = `${job_placement_1.RUN_LABEL}=${(0, job_placement_1.getRunLabelValue)(run.id)}`;
275
434
  await removeRunObjects(this.instance, namespace, selector, RELEASE_TIMEOUT);
276
435
  }
277
436
  catch (e) {
437
+ // the next run removes what is left
438
+ this.tidy.delete(namespace);
278
439
  context.emit({
279
440
  type: 'log',
280
441
  task: 'hammerkit',
@@ -300,8 +461,59 @@ class KubernetesExecutor {
300
461
  },
301
462
  };
302
463
  }
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
464
+ // The cluster as the process of a run sees it: the namespace its tasks and services go in.
465
+ environmentOf(run, namespace, work) {
466
+ const backend = this.config.backend;
467
+ return {
468
+ type: 'kubernetes',
469
+ namespace,
470
+ kubeConfig: this.access.kubeConfig,
471
+ context: this.access.context,
472
+ httpRoutes: [],
473
+ storageClass: backend.storageClass,
474
+ volumeSize: backend.volumeSize,
475
+ maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
476
+ networkPolicyEnforced: backend.networkPolicy === 'enforced',
477
+ server: {
478
+ run: run.id,
479
+ namespace: this.namespace,
480
+ pools: backend.pools,
481
+ providers: backend.providers,
482
+ accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
483
+ ...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace, backend.sandboxes.runner) } : {}),
484
+ // the claims of a service run go with its namespace
485
+ ...(this.keep > 0 && !run.up ? { volumeScope: getVolumeScope(run) } : {}),
486
+ },
487
+ };
488
+ }
489
+ // Once, on the first service run: the namespaces of service runs the server left when it
490
+ // stopped, whose services went with it
491
+ // Once when the server starts, and on the next service run after a namespace could not be
492
+ // removed: the namespaces of service runs that none of the server's runs is in, whose
493
+ // services went with the process of a server that stopped
494
+ sweepServiceRuns() {
495
+ if (this.serviceRunsSwept) {
496
+ return;
497
+ }
498
+ this.serviceRunsSwept = true;
499
+ Promise.resolve()
500
+ .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,${exports.SERVICE_RUN_LABEL}` }))
501
+ .then(async (namespaces) => {
502
+ for (const namespace of namespaces.items) {
503
+ const name = namespace.metadata?.name;
504
+ if (name &&
505
+ isCallerNamespace(this.config.backend.namespacePrefix, name) &&
506
+ !this.serviceRuns.has(name) &&
507
+ !namespace.metadata?.deletionTimestamp) {
508
+ this.log?.(`removing the namespace ${name} of a service run from before the server started`);
509
+ await removeServiceRunNamespace(this.instance, name, 0);
510
+ }
511
+ }
512
+ })
513
+ .catch((e) => this.log?.(`the namespaces of service runs could not be removed: ${(0, log_1.getErrorMessage)(e)}`));
514
+ }
515
+ // The kept claims of a namespace that expired go at the soonest expiry the server knows
516
+ // of, but those of a scope a run of the caller executes in
305
517
  sweepLater(namespace, delay) {
306
518
  const at = Date.now() + delay + 1000;
307
519
  const pending = this.sweeps.get(namespace);
@@ -321,6 +533,7 @@ class KubernetesExecutor {
321
533
  now: new Date(),
322
534
  keep: this.keep > 0,
323
535
  timeout: RELEASE_TIMEOUT,
536
+ inUse: this.scopesInUse(namespace),
324
537
  });
325
538
  if (removed.length > 0) {
326
539
  this.log?.(`removed ${removed.length} kept claims in ${namespace}`);
@@ -337,7 +550,7 @@ class KubernetesExecutor {
337
550
  }
338
551
  this.swept = true;
339
552
  Promise.resolve()
340
- .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true` }))
553
+ .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,!${exports.SERVICE_RUN_LABEL}` }))
341
554
  .then((namespaces) => {
342
555
  for (const namespace of namespaces.items) {
343
556
  const name = namespace.metadata?.name;
@@ -356,7 +569,7 @@ exports.KubernetesExecutor = KubernetesExecutor;
356
569
  // cache, and on a sandbox the runs of that sandbox only, whose claim is on the same node or
357
570
  // zone as the kept ones. The owner is the namespace's.
358
571
  function getVolumeScope(run) {
359
- if (run.source.type === 'sandbox') {
572
+ if (run.source.type === 'sandbox' && !run.source.check) {
360
573
  return `sandbox-${(0, crypto_1.createHash)('sha256').update(run.source.id).digest('hex').slice(0, 24)}`;
361
574
  }
362
575
  return (0, executor_1.cacheNamespace)(run);
@@ -370,6 +583,8 @@ class CallerNamespaces {
370
583
  constructor(instance, backend) {
371
584
  this.instance = instance;
372
585
  this.backend = backend;
586
+ // the namespaces this process set up, by their uid, and when
587
+ this.applied = new Map();
373
588
  }
374
589
  name(owner) {
375
590
  return getCallerNamespace(this.backend.namespacePrefix, owner);
@@ -377,9 +592,14 @@ class CallerNamespaces {
377
592
  // The caller's namespace: made by the server and kept apart from the others. The
378
593
  // server binds its rights there, isolates the network of the pods, applies the
379
594
  // 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) {
595
+ // are admitted under a quota on compute. All of it is replaced when the server sets up
596
+ // a namespace it did not set up in the last `NAMESPACE_SETUP_REUSE_MS` (it reads the
597
+ // config once, at its start), so the namespace follows the config; a namespace made anew
598
+ // since (deleted and made again) is set up again at once.
599
+ async prepare(namespace,
600
+ // a service run's own namespace: its label, the namespace of the server that reaches its
601
+ // endpoints, and its quota
602
+ serviceRun) {
383
603
  const backend = this.backend;
384
604
  let existing;
385
605
  try {
@@ -393,15 +613,26 @@ class CallerNamespaces {
393
613
  if (existing && existing.metadata?.labels?.[MANAGED_LABEL] !== 'true') {
394
614
  throw new Error(`the namespace ${namespace} was not made by hammerkit, the server does not run tasks in it`);
395
615
  }
616
+ const started = Date.now();
617
+ const applied = this.applied.get(namespace);
618
+ if (existing &&
619
+ !existing.metadata?.deletionTimestamp &&
620
+ applied &&
621
+ applied.uid === existing.metadata?.uid &&
622
+ started - applied.at < NAMESPACE_SETUP_REUSE_MS) {
623
+ return;
624
+ }
625
+ this.applied.delete(namespace);
396
626
  if (!existing) {
397
627
  try {
398
- await this.instance.coreApi.createNamespace({
628
+ existing = await this.instance.coreApi.createNamespace({
399
629
  body: {
400
630
  metadata: {
401
631
  name: namespace,
402
632
  labels: {
403
633
  [MANAGED_LABEL]: 'true',
404
634
  'hammerkit.dev/caller': namespace,
635
+ ...(serviceRun ? { [exports.SERVICE_RUN_LABEL]: serviceRun.serviceRun } : {}),
405
636
  // no privileged pods, host paths or host network, whatever a build asks
406
637
  'pod-security.kubernetes.io/enforce': 'baseline',
407
638
  },
@@ -419,7 +650,10 @@ class CallerNamespaces {
419
650
  const [first, ...policies] = getNamespacePolicies(namespace, backend.deniedEgress, backend.allowedEgress);
420
651
  // the API server may refuse what the binding allows for a moment after it was made
421
652
  await untilAllowed(() => (0, apply_1.replace)(this.instance, first));
422
- for (const policy of policies) {
653
+ for (const policy of [
654
+ ...policies,
655
+ ...(serviceRun ? [getServerIngressPolicy(namespace, serviceRun.server, serviceRun.ports)] : []),
656
+ ]) {
423
657
  await (0, apply_1.replace)(this.instance, policy);
424
658
  }
425
659
  const metadata = {
@@ -432,7 +666,7 @@ class CallerNamespaces {
432
666
  apiVersion: 'v1',
433
667
  kind: 'ResourceQuota',
434
668
  metadata,
435
- spec: { hard: backend.quota },
669
+ spec: { hard: serviceRun?.quota ?? backend.quota },
436
670
  };
437
671
  const defaults = backend.defaultResources;
438
672
  const limits = {
@@ -450,7 +684,7 @@ class CallerNamespaces {
450
684
  },
451
685
  };
452
686
  for (const [object, wanted] of [
453
- [quota, !!backend.quota],
687
+ [quota, !!(serviceRun?.quota ?? backend.quota)],
454
688
  [limits, !!defaults],
455
689
  ]) {
456
690
  if (wanted) {
@@ -465,6 +699,10 @@ class CallerNamespaces {
465
699
  });
466
700
  }
467
701
  }
702
+ const uid = existing?.metadata?.uid;
703
+ if (uid) {
704
+ this.applied.set(namespace, { uid, at: started });
705
+ }
468
706
  }
469
707
  whoAmI() {
470
708
  if (!this.subject) {
@@ -487,7 +725,7 @@ class CallerNamespaces {
487
725
  exports.CallerNamespaces = CallerNamespaces;
488
726
  // The claim of a sandbox as the Jobs of its run mount it: the run's copy of the files is
489
727
  // `root`, a source below it is the same path below `subPath` in the claim.
490
- function getWorkspace(root, claim, namespace) {
728
+ function getWorkspace(root, claim, namespace, runner) {
491
729
  if (claim.namespace !== namespace) {
492
730
  throw new Error(`the sandbox is in ${claim.namespace}, the run in ${namespace}`);
493
731
  }
@@ -497,6 +735,7 @@ function getWorkspace(root, claim, namespace) {
497
735
  subPath: claim.subPath,
498
736
  label: claim.label,
499
737
  ...(claim.pod ? { pod: claim.pod } : {}),
738
+ ...(runner ? { runner: true } : {}),
500
739
  };
501
740
  }
502
741
  // Everything a run made in the caller's namespace, by the label selector of the run: