hammerkit 1.25.0 → 1.27.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 (123) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/container-task-state.js +8 -6
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +4 -0
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +373 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +31 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
  34. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  35. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  36. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  37. package/dist/kubernetes/get-service-ip.js +15 -1
  38. package/dist/kubernetes/job-placement.js +99 -0
  39. package/dist/kubernetes/job-secrets.js +306 -0
  40. package/dist/kubernetes/kept-claims.js +191 -0
  41. package/dist/kubernetes/kubernetes-instance.js +10 -2
  42. package/dist/kubernetes/network-gate.js +71 -0
  43. package/dist/kubernetes/network-policy.js +526 -0
  44. package/dist/kubernetes/resources.js +13 -2
  45. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  46. package/dist/kubernetes/secrets.js +2 -0
  47. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  48. package/dist/kubernetes/volumes.js +100 -11
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +8 -2
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/utils/task-arguments.js +155 -0
  61. package/dist/planner/work-cache-id.js +15 -1
  62. package/dist/planner/work-network.js +193 -0
  63. package/dist/planner/work-runtime-docker.js +43 -0
  64. package/dist/planner/work-runtime-kubernetes.js +336 -102
  65. package/dist/planner/work-runtime-local.js +16 -8
  66. package/dist/planner/work-service.js +7 -3
  67. package/dist/planner/work-state-record.js +30 -0
  68. package/dist/program.js +80 -7
  69. package/dist/remote/annotations.js +103 -0
  70. package/dist/remote/bundle-text.js +24 -0
  71. package/dist/remote/bundle.js +9 -1
  72. package/dist/remote/remote-commands.js +4 -1
  73. package/dist/remote/remote-work-commands.js +182 -69
  74. package/dist/remote/server-client.js +18 -5
  75. package/dist/remote/source-manifest.js +15 -2
  76. package/dist/remote/token-source.js +67 -0
  77. package/dist/schema/build-file-base-task-schema.js +3 -0
  78. package/dist/schema/build-file-container-service-schema.js +6 -0
  79. package/dist/schema/build-file-container-task-schema.js +3 -0
  80. package/dist/schema/build-file-environment-schema.js +9 -0
  81. package/dist/schema/network-schema.js +78 -0
  82. package/dist/schema/reference-parser.js +1 -0
  83. package/dist/schema/schema-parser.js +16 -3
  84. package/dist/schema/work-scope-parser.js +13 -0
  85. package/dist/secrets/command-secret-provider.js +73 -10
  86. package/dist/secrets/github-app-token-command.js +23 -0
  87. package/dist/secrets/github-app-token.js +191 -0
  88. package/dist/server/accounts-api.js +9 -2
  89. package/dist/server/definitions-api.js +69 -47
  90. package/dist/server/definitions.js +76 -51
  91. package/dist/server/executor.js +156 -41
  92. package/dist/server/git-checkout.js +72 -8
  93. package/dist/server/kubernetes-backend.js +554 -0
  94. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  95. package/dist/server/oauth.js +1 -0
  96. package/dist/server/plan.js +51 -1
  97. package/dist/server/policy.js +104 -15
  98. package/dist/server/proposals-api.js +24 -40
  99. package/dist/server/proposals.js +12 -25
  100. package/dist/server/repo-access.js +37 -6
  101. package/dist/server/run-logs.js +217 -0
  102. package/dist/server/run-plan.js +51 -2
  103. package/dist/server/run-request.js +23 -10
  104. package/dist/server/run-store.js +5 -0
  105. package/dist/server/run-worker-command.js +13 -1
  106. package/dist/server/run-worker.js +121 -24
  107. package/dist/server/runs.js +184 -10
  108. package/dist/server/sandbox-docker.js +62 -0
  109. package/dist/server/sandbox-files-api.js +269 -0
  110. package/dist/server/sandbox-files.js +1054 -0
  111. package/dist/server/sandbox-patch.js +481 -0
  112. package/dist/server/sandbox-pod-command.js +15 -0
  113. package/dist/server/sandbox-pod.js +146 -0
  114. package/dist/server/sandbox-storage.js +462 -0
  115. package/dist/server/sandboxes-api.js +210 -0
  116. package/dist/server/sandboxes.js +519 -0
  117. package/dist/server/server-config.js +412 -22
  118. package/dist/server/server.js +204 -39
  119. package/dist/utils/run-id.js +12 -0
  120. package/harness/opencode.ts +11 -0
  121. package/harness/pi.ts +7 -0
  122. package/package.json +8 -2
  123. package/dist/remote/text-diff.js +0 -89
@@ -0,0 +1,554 @@
1
+ "use strict";
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.getCallerNamespace = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const fs_1 = require("fs");
6
+ const os_1 = require("os");
7
+ const path_1 = require("path");
8
+ const net_1 = require("net");
9
+ const apply_1 = require("../kubernetes/apply");
10
+ const job_placement_1 = require("../kubernetes/job-placement");
11
+ const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
12
+ const log_1 = require("../log");
13
+ const network_policy_1 = require("../kubernetes/network-policy");
14
+ const sleep_1 = require("../utils/sleep");
15
+ const version_1 = require("../version");
16
+ const executor_1 = require("./executor");
17
+ const kept_claims_1 = require("../kubernetes/kept-claims");
18
+ const units_1 = require("../utils/units");
19
+ const server_config_1 = require("./server-config");
20
+ // the name of the quota and limit range the server keeps in a caller's namespace
21
+ const POLICY_NAME = 'hammerkit';
22
+ const MANAGED_LABEL = 'hammerkit.dev/managed';
23
+ // how long the end of a run waits for its pods and claims to be gone
24
+ const RELEASE_TIMEOUT = 120000;
25
+ // where a pod of the cluster finds the token and certificate of its ServiceAccount
26
+ const IN_CLUSTER_NAMESPACE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace';
27
+ /**
28
+ * The namespace of a caller: the prefix and a hash of their issuer and subject. A
29
+ * subject is the caller's own, so nobody else's runs, claims or quota are in it.
30
+ */
31
+ function getCallerNamespace(prefix, owner) {
32
+ return `${prefix}${(0, crypto_1.createHash)('sha256').update(`${owner.iss}\0${owner.sub}`).digest('hex').slice(0, 16)}`;
33
+ }
34
+ exports.getCallerNamespace = getCallerNamespace;
35
+ // How the server and the processes of its runs reach the cluster: a kubeconfig and
36
+ // context the operator names, else the server's default kubeconfig, else the cluster
37
+ // the server runs in (its pod's ServiceAccount, found through the variables
38
+ // Kubernetes sets in every pod, which a run's process gets too).
39
+ function getClusterAccess(backend, processEnvs) {
40
+ if (backend.kubeconfig) {
41
+ return { kubeConfig: backend.kubeconfig, context: backend.context, env: {} };
42
+ }
43
+ const fallback = processEnvs.KUBECONFIG?.split(process.platform === 'win32' ? ';' : ':')[0];
44
+ const kubeConfig = fallback || (0, path_1.join)(processEnvs.HOME ?? (0, os_1.homedir)(), '.kube', 'config');
45
+ if ((0, fs_1.existsSync)(kubeConfig)) {
46
+ return { kubeConfig, context: backend.context, env: {} };
47
+ }
48
+ const env = {};
49
+ for (const name of ['KUBERNETES_SERVICE_HOST', 'KUBERNETES_SERVICE_PORT']) {
50
+ const value = processEnvs[name];
51
+ if (value) {
52
+ env[name] = value;
53
+ }
54
+ }
55
+ return { context: backend.context, env };
56
+ }
57
+ exports.getClusterAccess = getClusterAccess;
58
+ function readInClusterNamespace() {
59
+ try {
60
+ return (0, fs_1.readFileSync)(IN_CLUSTER_NAMESPACE, 'utf8').trim() || undefined;
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ /**
67
+ * The NetworkPolicies of a caller's namespace. A namespace is no network boundary on its
68
+ * own: without them a task could connect to the pods of other callers, to the server
69
+ * and to the metadata endpoint of the cloud, which hands out the node's identity.
70
+ * - ingress of a pod with an open network: connections from the pods of its namespace
71
+ * only (the services a task needs)
72
+ * - egress of a pod with an open network, and of an egress proxy: its namespace, DNS,
73
+ * and any address but the denied ranges (link-local and private by default), plus the
74
+ * allowed ones
75
+ * Policies add up, so neither selects the pods of a limited item: its own policy (see
76
+ * buildNetworkPolicy) is all it may reach, DNS left out, and all that may reach it. An
77
+ * egress proxy takes connections from its item only (buildProxyPolicy).
78
+ */
79
+ function getNamespacePolicies(namespace, deniedEgress, allowedEgress = []) {
80
+ const metadata = (name) => ({
81
+ name,
82
+ namespace,
83
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
84
+ labels: { [MANAGED_LABEL]: 'true' },
85
+ });
86
+ const block = (cidr, except) => ({ ipBlock: { cidr, ...(except.length ? { except } : {}) } });
87
+ return [
88
+ {
89
+ apiVersion: 'networking.k8s.io/v1',
90
+ kind: 'NetworkPolicy',
91
+ metadata: metadata('hammerkit-ingress'),
92
+ spec: {
93
+ podSelector: { matchExpressions: [{ key: network_policy_1.NETWORK_LABEL, operator: 'DoesNotExist' }] },
94
+ policyTypes: ['Ingress'],
95
+ ingress: [{ _from: [{ podSelector: {} }] }],
96
+ },
97
+ },
98
+ {
99
+ apiVersion: 'networking.k8s.io/v1',
100
+ kind: 'NetworkPolicy',
101
+ metadata: metadata('hammerkit-egress'),
102
+ spec: {
103
+ podSelector: { matchExpressions: [{ key: network_policy_1.NETWORK_LABEL, operator: 'NotIn', values: [network_policy_1.NETWORK_LIMITED] }] },
104
+ policyTypes: ['Egress'],
105
+ egress: [
106
+ { to: [{ podSelector: {} }] },
107
+ // to whatever resolver the pod is given, a node-local cache included
108
+ {
109
+ ports: [
110
+ { protocol: 'UDP', port: 53 },
111
+ { protocol: 'TCP', port: 53 },
112
+ ],
113
+ },
114
+ {
115
+ to: [
116
+ block('0.0.0.0/0', deniedEgress.filter((range) => (0, net_1.isIPv4)(range.split('/')[0]))),
117
+ block('::/0', deniedEgress.filter((range) => !(0, net_1.isIPv4)(range.split('/')[0]))),
118
+ ],
119
+ },
120
+ // the operator's exceptions, which rules add up to
121
+ ...(allowedEgress.length > 0 ? [{ to: allowedEgress.map((cidr) => ({ ipBlock: { cidr } })) }] : []),
122
+ ],
123
+ },
124
+ },
125
+ {
126
+ apiVersion: 'networking.k8s.io/v1',
127
+ kind: 'NetworkPolicy',
128
+ metadata: metadata('hammerkit-proxy-ingress'),
129
+ spec: {
130
+ // an egress proxy takes only what its own policy lets in: one whose policy went
131
+ // before the pod ended takes nothing
132
+ podSelector: { matchLabels: { [network_policy_1.NETWORK_LABEL]: network_policy_1.NETWORK_PROXY } },
133
+ policyTypes: ['Ingress'],
134
+ ingress: [],
135
+ },
136
+ },
137
+ ];
138
+ }
139
+ exports.getNamespacePolicies = getNamespacePolicies;
140
+ /**
141
+ * The server's rights in a caller's namespace: the operator's ClusterRole, bound there
142
+ * only, so the server needs no rights on Secrets or pods anywhere else.
143
+ */
144
+ function getRunRoleBinding(namespace, role, subject) {
145
+ return {
146
+ apiVersion: 'rbac.authorization.k8s.io/v1',
147
+ kind: 'RoleBinding',
148
+ metadata: {
149
+ name: 'hammerkit-runs',
150
+ namespace,
151
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
152
+ labels: { [MANAGED_LABEL]: 'true' },
153
+ },
154
+ roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'ClusterRole', name: role },
155
+ subjects: [subject],
156
+ };
157
+ }
158
+ exports.getRunRoleBinding = getRunRoleBinding;
159
+ /**
160
+ * Runs the tasks of runs as Jobs on a cluster. A run is planned and driven by a
161
+ * process of the server as on a host (see HostExecutor); what changes is where its
162
+ * container tasks and services go: into the caller's namespace, on the nodes of the
163
+ * operator's pools, with the secrets of providers read in the Job itself. The runs
164
+ * of one caller execute one after the other, since the objects of a task are named
165
+ * by its cache id in the caller's namespace; different callers run side by side.
166
+ * When a run ends, everything made for it is removed, but for the claims of its tasks
167
+ * the server keeps (`backend.keepVolumes`), which go once they expired.
168
+ */
169
+ class KubernetesExecutor {
170
+ constructor(config, processEnvs, options) {
171
+ this.config = config;
172
+ // the registry keeps a caller's next run in its queue, not running and timed here
173
+ this.runningPerCaller = 1;
174
+ // the run that executes, or the last one waiting, per caller namespace
175
+ this.queues = new Map();
176
+ // per caller namespace, when its kept claims are next looked at
177
+ this.sweeps = new Map();
178
+ // whether the kept claims a server left when it stopped were looked for
179
+ this.swept = false;
180
+ this.access = getClusterAccess(config.backend, processEnvs);
181
+ this.instance = (0, kubernetes_instance_1.createKubernetesInstances)({
182
+ type: 'kubernetes',
183
+ namespace: 'default',
184
+ kubeConfig: this.access.kubeConfig,
185
+ context: this.access.context,
186
+ httpRoutes: [],
187
+ networkPolicyEnforced: config.backend.networkPolicy === 'enforced',
188
+ });
189
+ const namespace = config.backend.namespace ?? readInClusterNamespace();
190
+ if (!namespace) {
191
+ throw new Error('backend.namespace: name the namespace of the server, where the credentials of the accounts are; ' +
192
+ 'it is only found on its own in a pod of the cluster');
193
+ }
194
+ this.namespace = namespace;
195
+ this.namespaces = new CallerNamespaces(this.instance, config.backend);
196
+ this.keep = config.backend.keepVolumes === false ? 0 : (0, units_1.parseDuration)(config.backend.keepVolumes);
197
+ this.log = options.log;
198
+ this.host = new executor_1.HostExecutor({ ...options, placement: this.placement() });
199
+ }
200
+ execute(run, context) {
201
+ const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
202
+ return this.enqueue(namespace, () => {
203
+ if (context.signal.aborted) {
204
+ throw new Error('the run was cancelled');
205
+ }
206
+ return this.host.execute(run, context);
207
+ });
208
+ }
209
+ // What happens in a caller's namespace happens one at a time: their runs, and the
210
+ // removal of the claims their runs kept
211
+ enqueue(namespace, fn) {
212
+ const previous = this.queues.get(namespace) ?? Promise.resolve();
213
+ const execution = previous.then(fn);
214
+ const settled = execution.then(() => undefined, () => undefined);
215
+ this.queues.set(namespace, settled);
216
+ settled.then(() => {
217
+ if (this.queues.get(namespace) === settled) {
218
+ this.queues.delete(namespace);
219
+ }
220
+ });
221
+ return execution;
222
+ }
223
+ placement() {
224
+ const backend = this.config.backend;
225
+ return {
226
+ prepare: async (run, context, work) => {
227
+ const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
228
+ await this.namespaces.prepare(namespace);
229
+ // what an earlier run left when the server stopped before its end; the runs
230
+ // of a caller execute one at a time, so no other run is in the namespace
231
+ await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL}!=${(0, job_placement_1.getRunLabelValue)(run.id)}`, RELEASE_TIMEOUT);
232
+ // the claims earlier runs kept that expired; the others this run may mount
233
+ const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
234
+ now: new Date(),
235
+ keep: this.keep > 0,
236
+ timeout: RELEASE_TIMEOUT,
237
+ });
238
+ if (next) {
239
+ this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
240
+ }
241
+ this.sweepLeftClaims();
242
+ context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
243
+ const kubernetes = {
244
+ type: 'kubernetes',
245
+ namespace,
246
+ kubeConfig: this.access.kubeConfig,
247
+ context: this.access.context,
248
+ httpRoutes: [],
249
+ storageClass: backend.storageClass,
250
+ volumeSize: backend.volumeSize,
251
+ maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
252
+ networkPolicyEnforced: backend.networkPolicy === 'enforced',
253
+ server: {
254
+ run: run.id,
255
+ namespace: this.namespace,
256
+ pools: backend.pools,
257
+ providers: backend.providers,
258
+ accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
259
+ ...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace) } : {}),
260
+ ...(this.keep > 0 ? { volumeScope: getVolumeScope(run) } : {}),
261
+ },
262
+ };
263
+ return { kubernetes, env: this.access.env };
264
+ },
265
+ release: async (run, context) => {
266
+ const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
267
+ try {
268
+ const selector = `${job_placement_1.RUN_LABEL}=${(0, job_placement_1.getRunLabelValue)(run.id)}`;
269
+ await removeRunObjects(this.instance, namespace, selector, RELEASE_TIMEOUT);
270
+ }
271
+ catch (e) {
272
+ context.emit({
273
+ type: 'log',
274
+ task: 'hammerkit',
275
+ stream: 'stderr',
276
+ text: `what the run made in ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(e)}`,
277
+ });
278
+ }
279
+ if (this.keep > 0) {
280
+ try {
281
+ await (0, kept_claims_1.expireKeptClaims)(this.instance, namespace, (0, job_placement_1.getRunLabelValue)(run.id), new Date(Date.now() + this.keep));
282
+ }
283
+ catch (e) {
284
+ // without an expiry, the next look at the namespace removes them
285
+ context.emit({
286
+ type: 'log',
287
+ task: 'hammerkit',
288
+ stream: 'stderr',
289
+ text: `the claims the run kept in ${namespace} could not be given their expiry: ${(0, log_1.getErrorMessage)(e)}`,
290
+ });
291
+ }
292
+ this.sweepLater(namespace, this.keep);
293
+ }
294
+ },
295
+ };
296
+ }
297
+ // The kept claims of a namespace that expired go once no run of the caller is in it: at
298
+ // the soonest expiry the server knows of
299
+ sweepLater(namespace, delay) {
300
+ const at = Date.now() + delay + 1000;
301
+ const pending = this.sweeps.get(namespace);
302
+ if (pending && pending.at <= at) {
303
+ return;
304
+ }
305
+ clearTimeout(pending?.timer);
306
+ const timer = setTimeout(() => {
307
+ this.sweeps.delete(namespace);
308
+ this.enqueue(namespace, () => this.sweep(namespace)).catch((e) => this.log?.(`the kept claims in ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(e)}`));
309
+ }, at - Date.now());
310
+ timer.unref();
311
+ this.sweeps.set(namespace, { at, timer });
312
+ }
313
+ async sweep(namespace) {
314
+ const { removed, next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
315
+ now: new Date(),
316
+ keep: this.keep > 0,
317
+ timeout: RELEASE_TIMEOUT,
318
+ });
319
+ if (removed.length > 0) {
320
+ this.log?.(`removed ${removed.length} kept claims in ${namespace}`);
321
+ }
322
+ if (next) {
323
+ this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
324
+ }
325
+ }
326
+ // Once, on the first run: the claims the server kept before it stopped, in the namespaces
327
+ // of other callers too, whose next run may be far off
328
+ sweepLeftClaims() {
329
+ if (this.swept) {
330
+ return;
331
+ }
332
+ this.swept = true;
333
+ Promise.resolve()
334
+ .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true` }))
335
+ .then((namespaces) => {
336
+ for (const namespace of namespaces.items) {
337
+ const name = namespace.metadata?.name;
338
+ if (name?.startsWith(this.config.backend.namespacePrefix) && !namespace.metadata?.deletionTimestamp) {
339
+ this.sweepLater(name, 0);
340
+ }
341
+ }
342
+ })
343
+ .catch((e) => this.log?.(`the namespaces of callers could not be listed: ${(0, log_1.getErrorMessage)(e)}`));
344
+ }
345
+ }
346
+ exports.KubernetesExecutor = KubernetesExecutor;
347
+ // What may mount a claim a run kept: the runs of the same repository, as they share the
348
+ // cache, and on a sandbox the runs of that sandbox only, whose claim is on the same node or
349
+ // zone as the kept ones. The owner is the namespace's.
350
+ function getVolumeScope(run) {
351
+ if (run.source.type === 'sandbox') {
352
+ return `sandbox-${(0, crypto_1.createHash)('sha256').update(run.source.id).digest('hex').slice(0, 24)}`;
353
+ }
354
+ return (0, executor_1.cacheNamespace)(run);
355
+ }
356
+ exports.getVolumeScope = getVolumeScope;
357
+ /**
358
+ * The namespaces of callers on the cluster: named by the caller (getCallerNamespace),
359
+ * made on first use, with the operator's quota.
360
+ */
361
+ class CallerNamespaces {
362
+ constructor(instance, backend) {
363
+ this.instance = instance;
364
+ this.backend = backend;
365
+ }
366
+ name(owner) {
367
+ return getCallerNamespace(this.backend.namespacePrefix, owner);
368
+ }
369
+ // The caller's namespace: made by the server and kept apart from the others. The
370
+ // server binds its rights there, isolates the network of the pods, applies the
371
+ // operator's quota and gives containers without resources default requests, so they
372
+ // are admitted under a quota on compute. All of it is replaced on every run, so the
373
+ // namespace follows the config.
374
+ async prepare(namespace) {
375
+ const backend = this.backend;
376
+ let existing;
377
+ try {
378
+ existing = await this.instance.coreApi.readNamespace({ name: namespace });
379
+ }
380
+ catch (e) {
381
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
382
+ throw e;
383
+ }
384
+ }
385
+ if (existing && existing.metadata?.labels?.[MANAGED_LABEL] !== 'true') {
386
+ throw new Error(`the namespace ${namespace} was not made by hammerkit, the server does not run tasks in it`);
387
+ }
388
+ if (!existing) {
389
+ try {
390
+ await this.instance.coreApi.createNamespace({
391
+ body: {
392
+ metadata: {
393
+ name: namespace,
394
+ labels: {
395
+ [MANAGED_LABEL]: 'true',
396
+ 'hammerkit.dev/caller': namespace,
397
+ // no privileged pods, host paths or host network, whatever a build asks
398
+ 'pod-security.kubernetes.io/enforce': 'baseline',
399
+ },
400
+ },
401
+ },
402
+ });
403
+ }
404
+ catch (createError) {
405
+ if ((0, apply_1.statusCodeOf)(createError) !== 409) {
406
+ throw createError;
407
+ }
408
+ }
409
+ }
410
+ await (0, apply_1.replace)(this.instance, getRunRoleBinding(namespace, backend.runRole, await this.whoAmI()));
411
+ const [first, ...policies] = getNamespacePolicies(namespace, backend.deniedEgress, backend.allowedEgress);
412
+ // the API server may refuse what the binding allows for a moment after it was made
413
+ await untilAllowed(() => (0, apply_1.replace)(this.instance, first));
414
+ for (const policy of policies) {
415
+ await (0, apply_1.replace)(this.instance, policy);
416
+ }
417
+ const metadata = {
418
+ name: POLICY_NAME,
419
+ namespace,
420
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
421
+ labels: { [MANAGED_LABEL]: 'true' },
422
+ };
423
+ const quota = {
424
+ apiVersion: 'v1',
425
+ kind: 'ResourceQuota',
426
+ metadata,
427
+ spec: { hard: backend.quota },
428
+ };
429
+ const defaults = backend.defaultResources;
430
+ const limits = {
431
+ apiVersion: 'v1',
432
+ kind: 'LimitRange',
433
+ metadata,
434
+ spec: {
435
+ limits: [
436
+ {
437
+ type: 'Container',
438
+ ...(defaults?.requests ? { defaultRequest: defaults.requests } : {}),
439
+ ...(defaults?.limits ? { _default: defaults.limits } : {}),
440
+ },
441
+ ],
442
+ },
443
+ };
444
+ for (const [object, wanted] of [
445
+ [quota, !!backend.quota],
446
+ [limits, !!defaults],
447
+ ]) {
448
+ if (wanted) {
449
+ await (0, apply_1.replace)(this.instance, object);
450
+ }
451
+ else {
452
+ // one the config had before
453
+ await this.instance.objectApi.delete(object).catch((e) => {
454
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
455
+ throw e;
456
+ }
457
+ });
458
+ }
459
+ }
460
+ }
461
+ whoAmI() {
462
+ if (!this.subject) {
463
+ this.subject = this.instance.authApi
464
+ .createSelfSubjectReview({ body: { apiVersion: 'authentication.k8s.io/v1', kind: 'SelfSubjectReview' } })
465
+ .then((review) => {
466
+ const name = review.status?.userInfo?.username;
467
+ if (!name) {
468
+ throw new Error('the cluster did not say who the server is (SelfSubjectReview)');
469
+ }
470
+ return { kind: 'User', apiGroup: 'rbac.authorization.k8s.io', name };
471
+ });
472
+ this.subject.catch(() => {
473
+ this.subject = undefined;
474
+ });
475
+ }
476
+ return this.subject;
477
+ }
478
+ }
479
+ exports.CallerNamespaces = CallerNamespaces;
480
+ // The claim of a sandbox as the Jobs of its run mount it: the run's copy of the files is
481
+ // `root`, a source below it is the same path below `subPath` in the claim.
482
+ function getWorkspace(root, claim, namespace) {
483
+ if (claim.namespace !== namespace) {
484
+ throw new Error(`the sandbox is in ${claim.namespace}, the run in ${namespace}`);
485
+ }
486
+ return {
487
+ root,
488
+ claim: claim.name,
489
+ subPath: claim.subPath,
490
+ label: claim.label,
491
+ ...(claim.pod ? { pod: claim.pod } : {}),
492
+ };
493
+ }
494
+ // Everything a run made in the caller's namespace, by the label selector of the run:
495
+ // Jobs with their pods, Deployments (services and egress proxies), Services, claims,
496
+ // Secrets and the NetworkPolicies of limited networks, which go last so no pod of the
497
+ // run outlives its policy.
498
+ // Deletes what a run made. The NetworkPolicies go last, once the pods are: a limited
499
+ // pod's own policy is all that limits it.
500
+ async function removeRunObjects(instance, namespace, labelSelector, timeout) {
501
+ const selector = { namespace, labelSelector };
502
+ await instance.batchApi.deleteCollectionNamespacedJob({ ...selector, propagationPolicy: 'Background' });
503
+ await instance.appsApi.deleteCollectionNamespacedDeployment({ ...selector, propagationPolicy: 'Background' });
504
+ await instance.coreApi.deleteCollectionNamespacedPod({ ...selector, gracePeriodSeconds: 0 });
505
+ await instance.coreApi.deleteCollectionNamespacedService(selector);
506
+ await instance.coreApi.deleteCollectionNamespacedSecret(selector);
507
+ await instance.coreApi.deleteCollectionNamespacedPersistentVolumeClaim(selector);
508
+ // the next run of the caller also uses the same claim names
509
+ await awaitRunObjectsGone(instance, namespace, labelSelector, timeout);
510
+ const policies = await instance.objectApi.list('networking.k8s.io/v1', 'NetworkPolicy', namespace, undefined, undefined, undefined, undefined, labelSelector);
511
+ for (const policy of policies.items) {
512
+ // the items of a list do not say their kind
513
+ const metadata = { name: policy.metadata?.name, namespace };
514
+ await instance.objectApi
515
+ .delete({ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy', metadata })
516
+ .catch((e) => {
517
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
518
+ throw e;
519
+ }
520
+ });
521
+ }
522
+ }
523
+ exports.removeRunObjects = removeRunObjects;
524
+ async function untilAllowed(fn, attempts = 20) {
525
+ for (let attempt = 1;; attempt++) {
526
+ try {
527
+ return await fn();
528
+ }
529
+ catch (e) {
530
+ if ((0, apply_1.statusCodeOf)(e) !== 403 || attempt >= attempts) {
531
+ throw e;
532
+ }
533
+ await (0, sleep_1.sleep)(500);
534
+ }
535
+ }
536
+ }
537
+ // Until the pods and claims of a run are gone: a claim is kept while a pod uses it.
538
+ async function awaitRunObjectsGone(instance, namespace, labelSelector, timeout) {
539
+ const deadline = Date.now() + timeout;
540
+ for (;;) {
541
+ const pods = await instance.coreApi.listNamespacedPod({ namespace, labelSelector });
542
+ const claims = await instance.coreApi.listNamespacedPersistentVolumeClaim({ namespace, labelSelector });
543
+ const left = [...pods.items, ...claims.items].map((item) => `${item.kind ?? ''} ${item.metadata?.name}`.trim());
544
+ if (left.length === 0) {
545
+ return;
546
+ }
547
+ if (Date.now() > deadline) {
548
+ throw new Error(`still there after ${Math.round(timeout / 1000)}s: ${left.join(', ')}`);
549
+ }
550
+ await (0, sleep_1.sleep)(1000);
551
+ }
552
+ }
553
+ exports.awaitRunObjectsGone = awaitRunObjectsGone;
554
+ //# sourceMappingURL=kubernetes-backend.js.map