hammerkit 1.26.0 → 1.27.0-next.10

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