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,996 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getPushRefusal = exports.KubernetesSandboxStorage = exports.getSandboxNames = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const promises_1 = require("fs/promises");
6
+ const path_1 = require("path");
7
+ const stream_1 = require("stream");
8
+ const promises_2 = require("stream/promises");
9
+ const tar_1 = require("tar");
10
+ const apply_1 = require("../kubernetes/apply");
11
+ const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
12
+ const network_policy_1 = require("../kubernetes/network-policy");
13
+ const volumes_1 = require("../kubernetes/volumes");
14
+ const log_1 = require("../log");
15
+ const sleep_1 = require("../utils/sleep");
16
+ const units_1 = require("../utils/units");
17
+ const version_1 = require("../version");
18
+ const git_checkout_1 = require("./git-checkout");
19
+ const kubernetes_backend_1 = require("./kubernetes-backend");
20
+ const sandbox_files_1 = require("./sandbox-files");
21
+ const sandbox_pod_1 = require("./sandbox-pod");
22
+ const sandbox_storage_1 = require("./sandbox-storage");
23
+ const sandboxes_1 = require("./sandboxes");
24
+ // A sandbox has two claims. One holds its files (in `files/`), which the tasks of runs
25
+ // mount; the other holds its git data, which only the pod of its files mounts, so nothing a
26
+ // task does reaches the configuration or the hooks of git: a link a task makes in the files
27
+ // leads at most to the root of the claim of the files.
28
+ //
29
+ // The pod mounts the claim of the git data where a host keeps its sandboxes, and the files
30
+ // at `<id>/files` below it, so the code of a host (`hammerkit server-sandbox`) works on it
31
+ // unchanged; what the server fetched for it goes to `incoming` there.
32
+ const CLAIM_PATH = '/claim';
33
+ const FILES_DIRECTORY = 'files';
34
+ const MANAGED = { 'hammerkit.dev/managed': 'true' };
35
+ // the claims of the sandboxes of one server (its prefix of caller namespaces and its own
36
+ // namespace): another server on the cluster leaves them alone
37
+ const SERVER_LABEL = 'hammerkit.dev/sandbox-server';
38
+ // the pods of sandboxes' files: no network at all
39
+ const FILES_LABEL = 'hammerkit.dev/sandbox-files';
40
+ const FILES_POLICY = 'hammerkit-sandbox-files';
41
+ const QUOTA_NAME = 'hammerkit-sandboxes';
42
+ const ID_ANNOTATION = 'hammerkit.dev/sandbox-id';
43
+ const ISS_ANNOTATION = 'hammerkit.dev/owner-iss';
44
+ const SUB_ANNOTATION = 'hammerkit.dev/owner-sub';
45
+ // a pod that is left behind (the server stopped) ends on its own
46
+ const POD_DEADLINE_SECONDS = 6 * 60 * 60;
47
+ // a pod this old is replaced when nothing uses it, well before its deadline ends it
48
+ const POD_RENEW_MS = (POD_DEADLINE_SECONDS - 60 * 60) * 1000;
49
+ // how often a run's pods are looked at, and how long one may find no room next to the pod
50
+ // of the files before that pod makes way
51
+ const UNSCHEDULABLE_POLL_MS = 3000;
52
+ const UNSCHEDULABLE_MS = 10000;
53
+ // how long a pod may take to start, its image pull included
54
+ const POD_START_MS = 5 * 60 * 1000;
55
+ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
56
+ const FETCH_TIMEOUT_MS = 10 * 60 * 1000;
57
+ // an answer is read into memory up to this; a diff is up to 64 MiB, as JSON a bit more
58
+ const MAX_ANSWER_BYTES = 160 * 1024 * 1024;
59
+ const MAX_STDERR = 16 * 1024;
60
+ // what is sent into a pod and not taken by the cluster yet, before the server waits
61
+ const MAX_STDIN_BUFFERED = 4 * 1024 * 1024;
62
+ // The names of a sandbox on the cluster: its id has letters of both cases and `_`, which
63
+ // names and labels can not all take, so they carry a hash of it.
64
+ function getSandboxNames(id) {
65
+ const label = (0, crypto_1.createHash)('sha256').update(id).digest('hex').slice(0, 20);
66
+ return {
67
+ claim: `hammerkit-sandbox-${label}`,
68
+ git: `hammerkit-sandbox-${label}-git`,
69
+ pod: `hammerkit-sandbox-${label}-files`,
70
+ label,
71
+ };
72
+ }
73
+ exports.getSandboxNames = getSandboxNames;
74
+ /**
75
+ * Sandboxes on a cluster: PersistentVolumeClaims per sandbox in its caller's namespace (its
76
+ * files, and its git data), mounted only while a task of a run on it or a short pod for its
77
+ * files runs. An idle sandbox costs its storage only.
78
+ *
79
+ * - The server fetches the commit (it holds the read credential of `sources`, as for a
80
+ * ref run) and hands the repository to the pod of the sandbox, which takes it into
81
+ * the claim. Nothing of the credential is in the caller's namespace.
82
+ * - Reading, searching and editing the files, the state and the usage run in that pod,
83
+ * with the code of a host (`hammerkit server-sandbox`, see sandbox-pod.ts), so the
84
+ * rules on paths, links and the deny list are the same. The pod has no network and no
85
+ * token, and stays while requests come, `backend.sandboxes.idle` after the last.
86
+ * - A run on the sandbox gets a copy of the files to plan with, and its tasks mount the
87
+ * claim itself (WorkKubernetesWorkspace). The pod of its files stays while the run
88
+ * works, and the pods of the run go to its node: a claim of one node at a time is then
89
+ * never moved between nodes, which takes seconds each time.
90
+ * - A push is made by the server: the pod commits the changes, the server copies the
91
+ * objects of the sandbox's git data, checks the commit and pushes it with the write
92
+ * credential, which never enters the caller's namespace either.
93
+ * - With `backend.sandboxes.storageClass`, a ResourceQuota on that class in the
94
+ * caller's namespace holds `sandboxes.limits` on the cluster as well.
95
+ */
96
+ class KubernetesSandboxStorage {
97
+ constructor(config, limits,
98
+ // the environment of git when the server fetches a repository: the variables runs see
99
+ env,
100
+ // where the server fetches a commit before it goes into a claim
101
+ staging, instance) {
102
+ this.limits = limits;
103
+ this.env = env;
104
+ this.staging = staging;
105
+ this.pods = new Map();
106
+ // the namespaces whose policy and quota for sandboxes this process has applied
107
+ this.prepared = new Map();
108
+ // one request at a time per sandbox, as a host has it: the objects `state` writes are
109
+ // pruned again in the pod, which must not meet another request that still needs them
110
+ this.queues = new Map();
111
+ this.backend = config.backend;
112
+ const access = (0, kubernetes_backend_1.getClusterAccess)(config.backend, env);
113
+ this.instance =
114
+ instance ??
115
+ (0, kubernetes_instance_1.createKubernetesInstances)({
116
+ type: 'kubernetes',
117
+ namespace: 'default',
118
+ kubeConfig: access.kubeConfig,
119
+ context: access.context,
120
+ httpRoutes: [],
121
+ networkPolicyEnforced: true,
122
+ });
123
+ this.namespaces = new kubernetes_backend_1.CallerNamespaces(this.instance, config.backend);
124
+ this.server = (0, crypto_1.createHash)('sha256')
125
+ .update(`${config.backend.namespacePrefix}\n${config.backend.namespace ?? ''}`)
126
+ .digest('hex')
127
+ .slice(0, 20);
128
+ }
129
+ async create(key, fetch) {
130
+ const namespace = this.namespaces.name(key.owner);
131
+ await this.namespaces.prepare(namespace);
132
+ await this.prepare(namespace);
133
+ const names = getSandboxNames(key.id);
134
+ try {
135
+ await this.createClaim(key, namespace, names.git);
136
+ await this.createClaim(key, namespace, names.claim);
137
+ return await this.receive(key, fetch, 'create');
138
+ }
139
+ catch (e) {
140
+ await this.remove(key).catch(() => undefined);
141
+ throw e;
142
+ }
143
+ }
144
+ async createClaim(key, namespace, name) {
145
+ const sandboxes = this.backend.sandboxes;
146
+ const claim = {
147
+ apiVersion: 'v1',
148
+ kind: 'PersistentVolumeClaim',
149
+ metadata: {
150
+ name,
151
+ namespace,
152
+ labels: { ...MANAGED, [volumes_1.SANDBOX_LABEL]: getSandboxNames(key.id).label, [SERVER_LABEL]: this.server },
153
+ annotations: {
154
+ 'hammerkit.dev/version': (0, version_1.getVersion)(),
155
+ [ID_ANNOTATION]: key.id,
156
+ [ISS_ANNOTATION]: key.owner.iss,
157
+ [SUB_ANNOTATION]: key.owner.sub,
158
+ },
159
+ },
160
+ spec: {
161
+ accessModes: ['ReadWriteOnce'],
162
+ storageClassName: sandboxes.storageClass ?? this.backend.storageClass,
163
+ resources: { requests: { storage: String((0, units_1.parseSize)(sandboxes.size)) } },
164
+ },
165
+ };
166
+ try {
167
+ await this.instance.objectApi.create(claim);
168
+ }
169
+ catch (e) {
170
+ // over the ResourceQuota of the caller's sandboxes
171
+ if ((0, apply_1.statusCodeOf)(e) === 403 && /exceeded quota/.test((0, log_1.getErrorMessage)(e))) {
172
+ throw new sandboxes_1.SandboxError(413, `your sandboxes are at your limit of ${this.limits.count} or ${(0, units_1.formatSize)(this.limits.bytes)}: delete one`);
173
+ }
174
+ throw e;
175
+ }
176
+ }
177
+ async fetch(key, fetch) {
178
+ return this.receive(key, fetch, 'fetch');
179
+ }
180
+ async state(key, signal) {
181
+ return (await this.request(key, { op: 'state', id: key.id }, signal)).result;
182
+ }
183
+ async usage(key) {
184
+ return (await this.request(key, { op: 'usage', id: key.id })).result;
185
+ }
186
+ /**
187
+ * The pod commits the changes; the server takes the objects of the sandbox's git data
188
+ * (not its configuration), checks that the commit is one on top of the sandbox's that
189
+ * leaves out the deny list, and pushes it. The pod of the files can not make the server
190
+ * push something else, and the write credential stays with the server.
191
+ */
192
+ async push(key, push) {
193
+ // the commit is copied out and pushed before a `state` may prune it again
194
+ return this.exclusive(key.id, () => this.commitAndPush(key, push));
195
+ }
196
+ async commitAndPush(key, push) {
197
+ const commit = (await this.requestNow(key, { op: 'commit', id: key.id, author: push.author }, push.signal)).result;
198
+ if (commit === null) {
199
+ return undefined;
200
+ }
201
+ if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(commit)) {
202
+ throw new Error(`the pod of the sandbox ${key.id} did not answer a commit`);
203
+ }
204
+ const repository = (0, path_1.join)(this.staging, `${key.id}-push-${(0, crypto_1.randomBytes)(6).toString('hex')}.git`);
205
+ const what = `sandbox ${key.id}`;
206
+ const env = defined(this.env);
207
+ const run = (args, action, runEnv = env, options = {}) => (0, git_checkout_1.git)([`--git-dir=${repository}`, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args], runEnv, push.signal, what, action, options);
208
+ try {
209
+ await (0, promises_1.mkdir)(this.staging, { recursive: true, mode: 0o700 });
210
+ await (0, git_checkout_1.git)(['init', '--quiet', '--bare', repository], env, push.signal, what, 'push');
211
+ // objects and where the shallow history ends, nothing that configures git or points
212
+ // it at other repositories (objects/info)
213
+ await this.copyOut(key, ['tar', 'cf', '-', '-C', `${CLAIM_PATH}/${key.id}/git`, '.'], repository, (path, type) => (type === 'File' &&
214
+ /^(\.\/)?(shallow|objects\/[0-9a-f]{2}\/[0-9a-f]+|objects\/pack\/pack-[0-9a-f]+\.(pack|idx))$/.test(path)) ||
215
+ (type === 'Directory' && /^(\.\/)?(objects(\/([0-9a-f]{2}|pack))?\/?)?$/.test(path)), push.signal);
216
+ const [found, ...parents] = (await run(['rev-list', '--parents', '-n', '1', commit], 'read')).trim().split(' ');
217
+ if (found !== commit || parents.length !== 1) {
218
+ throw new Error(`${commit} of the sandbox ${key.id} is not one commit on top of the sandbox's`);
219
+ }
220
+ const changed = (await run(['diff-tree', '-r', '-z', '--no-commit-id', '--name-only', '--no-renames', parents[0], commit], 'read'))
221
+ .split('\0')
222
+ .filter((path) => path.length > 0);
223
+ const denied = changed.find((path) => (0, sandbox_files_1.deniedPath)(path) || (0, sandbox_files_1.isRunState)(path));
224
+ if (denied) {
225
+ throw new Error(`the commit of the sandbox ${key.id} has ${denied}, which never leaves the server`);
226
+ }
227
+ // a branch this sandbox pushed before only moves forward
228
+ if (push.expect) {
229
+ const ancestor = await run(['merge-base', '--is-ancestor', push.expect, commit], 'read').then(() => true, () => false);
230
+ if (!ancestor) {
231
+ throw new sandbox_storage_1.SandboxPushConflict(`${push.branch} is at ${push.expect}, which the sandbox is no longer on: push to a new branch`);
232
+ }
233
+ }
234
+ let printed;
235
+ try {
236
+ printed = await run([
237
+ 'push',
238
+ // a refused ref is a line with the remote's reason, as on a host
239
+ '--porcelain',
240
+ '--no-verify',
241
+ `--force-with-lease=refs/heads/${push.branch}:${push.expect ?? ''}`,
242
+ '--',
243
+ push.url,
244
+ `${commit}:refs/heads/${push.branch}`,
245
+ ], 'push',
246
+ // the write credential is in the environment of this one git process
247
+ push.env, { exitCodes: [1] });
248
+ }
249
+ catch (e) {
250
+ throw new sandbox_storage_1.SandboxPushFailed(commit, e);
251
+ }
252
+ const refusal = getPushRefusal(printed, push.branch);
253
+ if (refusal) {
254
+ throw new sandbox_storage_1.SandboxPushFailed(commit, new Error(refusal));
255
+ }
256
+ return commit;
257
+ }
258
+ finally {
259
+ await (0, promises_1.rm)(repository, { recursive: true, force: true });
260
+ }
261
+ }
262
+ async moveTo(key, commit) {
263
+ await this.request(key, { op: 'move', id: key.id, commit }, AbortSignal.timeout(FETCH_TIMEOUT_MS));
264
+ }
265
+ files(key) {
266
+ return new KubernetesSandboxFiles((request, signal) => this.request(key, { ...request, id: key.id }, signal), (path) => this.raw(key, path));
267
+ }
268
+ /**
269
+ * Copies the files into `into` for the process of the run, which reads the sources of
270
+ * tasks to know what changed. The pod of the files stays until the run released it,
271
+ * so the pods of the run, which go next to it, find the claim on its node.
272
+ */
273
+ async attach(key, into) {
274
+ const names = getSandboxNames(key.id);
275
+ const namespace = this.namespaces.name(key.owner);
276
+ const pod = await this.usePod(key);
277
+ pod.runs++;
278
+ const leave = () => {
279
+ pod.runs--;
280
+ this.leavePod(key, pod);
281
+ };
282
+ try {
283
+ // runs on a sandbox go one at a time: a proxy the pod of the files does not own was
284
+ // left by a run before (the server stopped, or its release could not remove it)
285
+ await (0, network_policy_1.removeSandboxProxies)(this.instance, namespace, { sandbox: names.label }, (deployment) => isOwnedBy(deployment, pod.uid)).catch(() => undefined);
286
+ await (0, promises_1.mkdir)(into, { recursive: true });
287
+ await this.copyOut(key, ['tar', 'cf', '-', '-C', `${CLAIM_PATH}/${key.id}/files`, '.'], into,
288
+ // what the run plans with: files, directories and links, nothing outside of it
289
+ (_path, type) => ['File', 'OldFile', 'ContiguousFile', 'Directory', 'SymbolicLink'].includes(type), AbortSignal.timeout(FETCH_TIMEOUT_MS));
290
+ }
291
+ catch (e) {
292
+ leave();
293
+ throw e;
294
+ }
295
+ const watching = new AbortController();
296
+ void this.makeWayForRun(key, pod, watching.signal);
297
+ return {
298
+ directory: into,
299
+ claim: {
300
+ name: names.claim,
301
+ namespace,
302
+ subPath: FILES_DIRECTORY,
303
+ label: names.label,
304
+ ...(pod.uid ? { pod: { name: pod.name, uid: pod.uid } } : {}),
305
+ },
306
+ release: () => {
307
+ if (!watching.signal.aborted) {
308
+ watching.abort();
309
+ leave();
310
+ if (pod.movedAside) {
311
+ // the egress proxies it owned stayed for the run; those of a pod after it stay
312
+ (0, network_policy_1.removeSandboxProxies)(this.instance, namespace, { sandbox: names.label }, (deployment) => !isOwnedBy(deployment, pod.uid) && (deployment.metadata?.ownerReferences ?? []).length > 0).catch(() => undefined);
313
+ }
314
+ }
315
+ },
316
+ };
317
+ }
318
+ /**
319
+ * The pods of a run go to the node of the pod of the files. When one finds no room there
320
+ * (a small node, which the cluster can not grow), the pod of the files goes, so the run's
321
+ * pod goes where there is room and the claim follows it, as it would without the pod.
322
+ */
323
+ async makeWayForRun(key, pod, signal) {
324
+ const selector = `${volumes_1.SANDBOX_LABEL}=${getSandboxNames(key.id).label},!${FILES_LABEL}`;
325
+ while (!signal.aborted && this.pods.get(key.id) === pod) {
326
+ await (0, sleep_1.sleep)(UNSCHEDULABLE_POLL_MS);
327
+ if (signal.aborted || this.pods.get(key.id) !== pod) {
328
+ return;
329
+ }
330
+ try {
331
+ const pods = await this.instance.coreApi.listNamespacedPod({
332
+ namespace: pod.namespace,
333
+ labelSelector: selector,
334
+ });
335
+ const stuck = pods.items.some((runPod) => runPod.status?.conditions?.some((condition) => condition.type === 'PodScheduled' &&
336
+ condition.status === 'False' &&
337
+ condition.reason === 'Unschedulable' &&
338
+ Date.now() - new Date(condition.lastTransitionTime ?? Date.now()).getTime() >= UNSCHEDULABLE_MS));
339
+ if (stuck && !signal.aborted && this.pods.get(key.id) === pod) {
340
+ // requests under way are answered first; the next one starts a pod next to the run's
341
+ this.pods.delete(key.id);
342
+ clearTimeout(pod.idle);
343
+ for (let i = 0; pod.busy > pod.runs && i < REQUEST_TIMEOUT_MS / 100; i++) {
344
+ await (0, sleep_1.sleep)(100);
345
+ }
346
+ // what it owns stays for the run (the egress proxies of its tasks) and goes
347
+ // when the run released it
348
+ pod.movedAside = true;
349
+ await this.deletePod(pod.namespace, pod.name, 'Orphan');
350
+ return;
351
+ }
352
+ }
353
+ catch {
354
+ // the next look may get an answer
355
+ }
356
+ }
357
+ }
358
+ // What a tar in the pod writes, extracted into `into`: the entries `keep` takes, up to
359
+ // the bytes a sandbox may take.
360
+ async copyOut(key, command, into, keep, signal) {
361
+ let bytes = 0;
362
+ const extract = (0, tar_1.extract)({
363
+ cwd: into,
364
+ filter: (path, entry) => 'type' in entry && keep(path, entry.type),
365
+ });
366
+ const counted = new stream_1.PassThrough();
367
+ counted.on('data', (chunk) => {
368
+ bytes += chunk.length;
369
+ if (bytes > this.limits.bytes) {
370
+ counted.destroy(new Error(`the sandbox ${key.id} takes more than ${(0, units_1.formatSize)(this.limits.bytes)}`));
371
+ }
372
+ });
373
+ const extracted = new Promise((resolve, reject) => {
374
+ extract.on('finish', resolve);
375
+ extract.on('error', reject);
376
+ counted.on('error', reject);
377
+ });
378
+ extracted.catch(() => undefined);
379
+ counted.pipe(extract);
380
+ await this.withPod(key, (pod) => this.exec(pod, command, null, counted, signal));
381
+ counted.end();
382
+ await extracted;
383
+ }
384
+ async remove(key) {
385
+ await this.removeIn(this.namespaces.name(key.owner), key.id);
386
+ }
387
+ async removeIn(namespace, id) {
388
+ const names = getSandboxNames(id);
389
+ await this.stopPod({ id, owner: { iss: '', sub: '' } }, namespace);
390
+ // the cluster removes the proxies the pod owned; those it did not own go here
391
+ await (0, network_policy_1.removeSandboxProxies)(this.instance, namespace, { sandbox: names.label });
392
+ for (const name of [names.claim, names.git]) {
393
+ try {
394
+ await this.instance.coreApi.deleteNamespacedPersistentVolumeClaim({ name, namespace });
395
+ }
396
+ catch (e) {
397
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
398
+ throw e;
399
+ }
400
+ }
401
+ }
402
+ }
403
+ // The claims of sandboxes on the cluster. Those of this server, in the namespace their
404
+ // owner has, are removed when they have no record; the others are kept, and their records
405
+ // too (a server that changed its prefix still has them).
406
+ async list() {
407
+ // the server has rights in the namespaces it made only (runRole, bound there): it looks
408
+ // for claims in those, and passes over one it can not read, another server's
409
+ const namespaces = await this.instance.coreApi.listNamespace({ labelSelector: 'hammerkit.dev/managed=true' });
410
+ const claims = [];
411
+ for (const namespace of namespaces.items) {
412
+ const name = namespace.metadata?.name;
413
+ if (!name || namespace.metadata?.deletionTimestamp) {
414
+ continue;
415
+ }
416
+ try {
417
+ const listed = await this.instance.coreApi.listNamespacedPersistentVolumeClaim({
418
+ namespace: name,
419
+ labelSelector: `hammerkit.dev/managed=true,${volumes_1.SANDBOX_LABEL}`,
420
+ });
421
+ claims.push(...listed.items);
422
+ }
423
+ catch (e) {
424
+ if ((0, apply_1.statusCodeOf)(e) !== 403) {
425
+ throw e;
426
+ }
427
+ }
428
+ }
429
+ const found = new Map();
430
+ for (const claim of claims) {
431
+ const annotations = claim.metadata?.annotations ?? {};
432
+ const id = annotations[ID_ANNOTATION];
433
+ const namespace = claim.metadata?.namespace;
434
+ if (!id || !namespace || claim.metadata?.deletionTimestamp) {
435
+ continue;
436
+ }
437
+ const names = getSandboxNames(id);
438
+ if (claim.metadata?.name !== names.claim && claim.metadata?.name !== names.git) {
439
+ continue;
440
+ }
441
+ const owner = { iss: annotations[ISS_ANNOTATION] ?? '', sub: annotations[SUB_ANNOTATION] ?? '' };
442
+ const ours = claim.metadata?.labels?.[SERVER_LABEL] === this.server && this.namespaces.name(owner) === namespace;
443
+ const created = claim.metadata?.creationTimestamp ? new Date(claim.metadata.creationTimestamp) : new Date();
444
+ const seen = found.get(id);
445
+ found.set(id, {
446
+ id,
447
+ ...((seen ? seen.remove : true) && ours ? { remove: () => this.removeIn(namespace, id) } : {}),
448
+ createdAt: seen?.createdAt && seen.createdAt > created ? seen.createdAt : created,
449
+ });
450
+ }
451
+ return [...found.values()];
452
+ }
453
+ // The commit, fetched by the server and taken into the claim by the pod.
454
+ async receive(key, fetch, op) {
455
+ if (!(0, git_checkout_1.isValidRef)(fetch.ref)) {
456
+ throw new Error(`${JSON.stringify(fetch.ref.slice(0, 80))} is not a branch, tag or commit`);
457
+ }
458
+ const repository = (0, path_1.join)(this.staging, `${key.id}-${(0, crypto_1.randomBytes)(6).toString('hex')}.git`);
459
+ try {
460
+ const what = `sandbox ${key.id}`;
461
+ await (0, promises_1.mkdir)(this.staging, { recursive: true, mode: 0o700 });
462
+ await (0, git_checkout_1.git)(['init', '--quiet', '--bare', repository], fetch.env, fetch.signal, what, op);
463
+ await (0, git_checkout_1.git)([`--git-dir=${repository}`, 'fetch', '--quiet', '--depth', '1', '--', fetch.url, fetch.ref], fetch.env, fetch.signal, what);
464
+ await (0, git_checkout_1.git)([`--git-dir=${repository}`, 'update-ref', `refs/heads/${sandbox_pod_1.INCOMING_REF}`, 'FETCH_HEAD'], fetch.env, fetch.signal, what, op);
465
+ const incoming = `${CLAIM_PATH}/${sandbox_pod_1.INCOMING}`;
466
+ await this.withPod(key, (pod) => this.exec(pod, ['sh', '-c', 'rm -rf "$1" && mkdir -p "$1" && tar xf - -C "$1"', 'sh', incoming], stream_1.Readable.from((0, tar_1.create)({ cwd: repository, portable: true }, ['.'])), null, fetch.signal));
467
+ return (await this.request(key, { op, id: key.id }, fetch.signal)).result;
468
+ }
469
+ finally {
470
+ await (0, promises_1.rm)(repository, { recursive: true, force: true });
471
+ }
472
+ }
473
+ async raw(key, path) {
474
+ const answer = await this.call(key, { op: 'raw', id: key.id, path }, AbortSignal.timeout(REQUEST_TIMEOUT_MS), true);
475
+ const result = answer.result;
476
+ return 'stream' in answer ? { stat: result.stat, stream: answer.stream } : { ...result, content: answer.body };
477
+ }
478
+ request(key, request, signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS)) {
479
+ return this.exclusive(key.id, () => this.requestNow(key, request, signal));
480
+ }
481
+ async requestNow(key, request, signal) {
482
+ const answer = await this.call(key, request, signal, false);
483
+ return { result: answer.result };
484
+ }
485
+ exclusive(id, fn) {
486
+ const previous = this.queues.get(id) ?? Promise.resolve();
487
+ const next = previous.then(fn, fn);
488
+ const settled = next.catch(() => undefined);
489
+ this.queues.set(id, settled);
490
+ void settled.then(() => {
491
+ if (this.queues.get(id) === settled) {
492
+ this.queues.delete(id);
493
+ }
494
+ });
495
+ return next;
496
+ }
497
+ // One request to `hammerkit server-sandbox` in the pod: the first line of its answer
498
+ // says how it went, a raw read sends the bytes after it.
499
+ async call(key, request, signal, body) {
500
+ const encoded = request.op === 'edit' ? { ...request, ops: (0, sandbox_pod_1.encodeEditOps)(request.ops) } : request;
501
+ const reader = new AnswerReader(body);
502
+ // the process ends when the server stops waiting for it: closing the connection does
503
+ // not end a command in a pod
504
+ const seconds = Math.ceil((['create', 'fetch', 'move'].includes(request.op) ? FETCH_TIMEOUT_MS : REQUEST_TIMEOUT_MS) / 1000);
505
+ const done = this.withPod(key, (pod) => this.exec(pod, ['timeout', String(seconds), 'hammerkit', 'server-sandbox', CLAIM_PATH], stream_1.Readable.from([Buffer.from(JSON.stringify(encoded))]), reader, signal));
506
+ done.then(() => reader.complete(), (e) => reader.fail(e));
507
+ const answer = await reader.answer;
508
+ if (!answer.header.ok) {
509
+ throw (0, sandbox_pod_1.decodePodError)(answer.header.error);
510
+ }
511
+ return 'stream' in answer
512
+ ? { result: answer.header.result, stream: answer.stream }
513
+ : { ...answer, result: answer.header.result };
514
+ }
515
+ /**
516
+ * Runs `fn` with the pod of the sandbox's files, made when there is none. It stays for
517
+ * the requests that follow and goes `idle` after the last one.
518
+ */
519
+ async withPod(key, fn) {
520
+ const pod = await this.usePod(key);
521
+ try {
522
+ return await fn(pod);
523
+ }
524
+ finally {
525
+ this.leavePod(key, pod);
526
+ }
527
+ }
528
+ // The running pod of the sandbox's files, which stays until `leavePod`.
529
+ async usePod(key) {
530
+ let pod = this.pods.get(key.id);
531
+ if (pod?.started !== undefined && pod.busy === 0 && Date.now() - pod.started > POD_RENEW_MS) {
532
+ // nothing uses it and its deadline nears: a new one, before a run needs it for long
533
+ await this.stopPod(key);
534
+ pod = this.pods.get(key.id);
535
+ }
536
+ if (!pod) {
537
+ const namespace = this.namespaces.name(key.owner);
538
+ const created = {
539
+ namespace,
540
+ name: getSandboxNames(key.id).pod,
541
+ busy: 0,
542
+ runs: 0,
543
+ ready: Promise.resolve(),
544
+ };
545
+ created.ready = this.startPod(key, namespace).then(({ started, uid }) => {
546
+ created.started = started;
547
+ created.uid = uid;
548
+ });
549
+ created.ready.catch(() => {
550
+ if (this.pods.get(key.id) === created) {
551
+ this.pods.delete(key.id);
552
+ }
553
+ });
554
+ this.pods.set(key.id, created);
555
+ pod = created;
556
+ }
557
+ clearTimeout(pod.idle);
558
+ pod.busy++;
559
+ try {
560
+ await pod.ready;
561
+ }
562
+ catch (e) {
563
+ this.leavePod(key, pod);
564
+ throw e;
565
+ }
566
+ return pod;
567
+ }
568
+ leavePod(key, pod) {
569
+ pod.busy--;
570
+ if (pod.busy === 0 && this.pods.get(key.id) === pod) {
571
+ pod.idle = setTimeout(() => {
572
+ if (pod.busy === 0 && this.pods.get(key.id) === pod) {
573
+ this.stopPod(key).catch(() => undefined);
574
+ }
575
+ }, (0, units_1.parseDuration)(this.backend.sandboxes.idle));
576
+ pod.idle.unref();
577
+ }
578
+ }
579
+ // Takes the pod of the sandbox's files down, once the requests under way are answered.
580
+ async stopPod(key, namespace = this.namespaces.name(key.owner)) {
581
+ const pod = this.pods.get(key.id);
582
+ if (pod) {
583
+ this.pods.delete(key.id);
584
+ clearTimeout(pod.idle);
585
+ for (let i = 0; pod.busy > 0 && i < REQUEST_TIMEOUT_MS / 100; i++) {
586
+ await (0, sleep_1.sleep)(100);
587
+ }
588
+ }
589
+ await this.deletePod(namespace, getSandboxNames(key.id).pod);
590
+ }
591
+ async deletePod(namespace, name, propagationPolicy) {
592
+ try {
593
+ await this.instance.coreApi.deleteNamespacedPod({
594
+ name,
595
+ namespace,
596
+ gracePeriodSeconds: 0,
597
+ ...(propagationPolicy ? { propagationPolicy } : {}),
598
+ });
599
+ }
600
+ catch (e) {
601
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
602
+ return;
603
+ }
604
+ throw e;
605
+ }
606
+ for (let attempt = 0; attempt < 120; attempt++) {
607
+ try {
608
+ await this.instance.coreApi.readNamespacedPod({ name, namespace });
609
+ }
610
+ catch (e) {
611
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
612
+ return;
613
+ }
614
+ throw e;
615
+ }
616
+ await (0, sleep_1.sleep)(500);
617
+ }
618
+ }
619
+ // Starts the pod of the sandbox's files and answers when it started, and its uid.
620
+ async startPod(key, namespace) {
621
+ const names = getSandboxNames(key.id);
622
+ await this.prepare(namespace);
623
+ // one left by a server that stopped is used while it runs
624
+ const existing = await this.instance.coreApi
625
+ .readNamespacedPod({ name: names.pod, namespace })
626
+ .catch((e) => ((0, apply_1.statusCodeOf)(e) === 404 ? undefined : Promise.reject(e)));
627
+ if (existing?.status?.phase === 'Running' && !existing.metadata?.deletionTimestamp) {
628
+ return { started: startedAt(existing), uid: existing.metadata?.uid };
629
+ }
630
+ if (existing) {
631
+ await this.deletePod(namespace, names.pod);
632
+ }
633
+ // where the pods of runs go: the first pool, which container tasks run on, so that a
634
+ // claim a zonal class provisions for this pod is where the tasks can mount it
635
+ const pool = this.backend.pools[0];
636
+ const resources = this.backend.sandboxes.resources;
637
+ const pod = {
638
+ apiVersion: 'v1',
639
+ kind: 'Pod',
640
+ metadata: {
641
+ name: names.pod,
642
+ namespace,
643
+ // the NetworkPolicy of the caller's namespace for pods with an open network leaves
644
+ // it out: its own policy, no network at all, is all it gets
645
+ labels: { ...MANAGED, [volumes_1.SANDBOX_LABEL]: names.label, [FILES_LABEL]: 'true', [network_policy_1.NETWORK_LABEL]: 'limited' },
646
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
647
+ },
648
+ spec: {
649
+ automountServiceAccountToken: false,
650
+ enableServiceLinks: false,
651
+ restartPolicy: 'Never',
652
+ activeDeadlineSeconds: POD_DEADLINE_SECONDS,
653
+ terminationGracePeriodSeconds: 0,
654
+ nodeSelector: {
655
+ 'kubernetes.io/os': pool.platform.os,
656
+ 'kubernetes.io/arch': pool.platform.arch,
657
+ ...pool.nodeSelector,
658
+ },
659
+ ...(pool.tolerations.length > 0 ? { tolerations: pool.tolerations } : {}),
660
+ affinity: (0, volumes_1.getSandboxAffinity)(names.label),
661
+ securityContext: { seccompProfile: { type: 'RuntimeDefault' } },
662
+ containers: [
663
+ {
664
+ name: 'files',
665
+ image: this.backend.sandboxes.image,
666
+ command: ['sleep', String(POD_DEADLINE_SECONDS)],
667
+ workingDir: CLAIM_PATH,
668
+ env: [{ name: 'HOME', value: '/tmp' }],
669
+ ...(resources.requests || resources.limits ? { resources } : {}),
670
+ // root, as tasks may run as root and write files only root may change, but
671
+ // with no more than the capabilities to own and change any file of the claims
672
+ securityContext: {
673
+ allowPrivilegeEscalation: false,
674
+ readOnlyRootFilesystem: true,
675
+ capabilities: { drop: ['ALL'], add: ['CHOWN', 'DAC_OVERRIDE', 'FOWNER'] },
676
+ },
677
+ volumeMounts: [
678
+ { name: 'git', mountPath: CLAIM_PATH },
679
+ { name: 'files', mountPath: `${CLAIM_PATH}/${key.id}/files`, subPath: FILES_DIRECTORY },
680
+ { name: 'tmp', mountPath: '/tmp' },
681
+ ],
682
+ },
683
+ ],
684
+ volumes: [
685
+ { name: 'git', persistentVolumeClaim: { claimName: names.git } },
686
+ { name: 'files', persistentVolumeClaim: { claimName: names.claim } },
687
+ { name: 'tmp', emptyDir: {} },
688
+ ],
689
+ },
690
+ };
691
+ await this.instance.objectApi.create(pod);
692
+ const deadline = Date.now() + POD_START_MS;
693
+ for (;;) {
694
+ const current = await this.instance.coreApi.readNamespacedPod({ name: names.pod, namespace });
695
+ const phase = current.status?.phase;
696
+ if (phase === 'Running') {
697
+ return { started: startedAt(current), uid: current.metadata?.uid };
698
+ }
699
+ const waiting = current.status?.containerStatuses?.[0]?.state?.waiting;
700
+ if (phase === 'Failed' || phase === 'Succeeded' || Date.now() > deadline) {
701
+ await this.deletePod(namespace, names.pod).catch(() => undefined);
702
+ throw new Error(`the pod of the sandbox ${key.id} did not start${waiting?.reason ? `: ${waiting.reason}${waiting.message ? ` ${waiting.message}` : ''}` : ` (${phase})`}`);
703
+ }
704
+ await (0, sleep_1.sleep)(500);
705
+ }
706
+ }
707
+ // What the caller's namespace has for sandboxes: no network for the pods of their files,
708
+ // and the quota on the storage class of sandboxes when they have one of their own.
709
+ prepare(namespace) {
710
+ let prepared = this.prepared.get(namespace);
711
+ if (!prepared) {
712
+ prepared = this.applyPolicy(namespace);
713
+ prepared.catch(() => this.prepared.delete(namespace));
714
+ this.prepared.set(namespace, prepared);
715
+ }
716
+ return prepared;
717
+ }
718
+ async applyPolicy(namespace) {
719
+ const metadata = {
720
+ namespace,
721
+ labels: MANAGED,
722
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
723
+ };
724
+ const policy = {
725
+ apiVersion: 'networking.k8s.io/v1',
726
+ kind: 'NetworkPolicy',
727
+ metadata: { ...metadata, name: FILES_POLICY },
728
+ spec: { podSelector: { matchLabels: { [FILES_LABEL]: 'true' } }, policyTypes: ['Ingress', 'Egress'] },
729
+ };
730
+ await (0, apply_1.replace)(this.instance, policy);
731
+ const storageClass = this.backend.sandboxes.storageClass;
732
+ if (!storageClass) {
733
+ // one an earlier config had
734
+ await this.instance.objectApi
735
+ .delete({ apiVersion: 'v1', kind: 'ResourceQuota', metadata: { ...metadata, name: QUOTA_NAME } })
736
+ .catch((e) => ((0, apply_1.statusCodeOf)(e) === 404 ? undefined : Promise.reject(e)));
737
+ }
738
+ else {
739
+ const scope = `${storageClass}.storageclass.storage.k8s.io`;
740
+ const quota = {
741
+ apiVersion: 'v1',
742
+ kind: 'ResourceQuota',
743
+ metadata: { ...metadata, name: QUOTA_NAME },
744
+ spec: {
745
+ hard: {
746
+ // two claims of a sandbox, both of `size`
747
+ [`${scope}/requests.storage`]: String(2 * this.limits.bytes),
748
+ [`${scope}/persistentvolumeclaims`]: String(2 * this.limits.count),
749
+ },
750
+ },
751
+ };
752
+ await (0, apply_1.replace)(this.instance, quota);
753
+ }
754
+ }
755
+ // A command in the pod. It fails when the command does, with the last of its error output.
756
+ async exec(pod, command, stdin, stdout, signal) {
757
+ let stderr = '';
758
+ const errors = new stream_1.Writable({
759
+ write(chunk, _encoding, callback) {
760
+ stderr = (stderr + chunk.toString('utf8')).slice(-MAX_STDERR);
761
+ callback();
762
+ },
763
+ });
764
+ let status;
765
+ // the stdin of the command waits on the connection, which is there once it started
766
+ const connection = {};
767
+ const socket = await this.instance.exec.exec(pod.namespace, pod.name, 'files', command, stdout ?? new stream_1.Writable({ write: (_chunk, _encoding, callback) => callback() }), errors, stdin && throttle(stdin, () => connection.socket), false, (received) => {
768
+ status = received;
769
+ });
770
+ connection.socket = socket;
771
+ if (stdout) {
772
+ holdBack(socket, stdout);
773
+ }
774
+ await new Promise((resolve, reject) => {
775
+ const abort = () => {
776
+ socket.close();
777
+ reject(new Error(`${command[0]} in the pod of the sandbox was cancelled`));
778
+ };
779
+ if (signal.aborted) {
780
+ abort();
781
+ return;
782
+ }
783
+ signal.addEventListener('abort', abort, { once: true });
784
+ socket.on('error', (e) => {
785
+ signal.removeEventListener('abort', abort);
786
+ reject(e);
787
+ });
788
+ socket.on('close', () => {
789
+ signal.removeEventListener('abort', abort);
790
+ if (status?.status === 'Success') {
791
+ resolve();
792
+ return;
793
+ }
794
+ const reason = stderr.trim().split('\n').pop() || status?.message || 'the connection to the pod closed';
795
+ reject(new Error(`${command[0]} in the pod of the sandbox failed: ${reason}`));
796
+ });
797
+ });
798
+ }
799
+ }
800
+ exports.KubernetesSandboxStorage = KubernetesSandboxStorage;
801
+ // Why the remote refused the push of `branch`, from what `git push --porcelain` printed
802
+ // (`<flag>\t<from>:<to>\t<summary>`, `!` for a refused ref); none when it took it.
803
+ function getPushRefusal(printed, branch) {
804
+ const line = printed
805
+ .split('\n')
806
+ .map((printedLine) => printedLine.split('\t'))
807
+ .find((fields) => fields.length >= 3 && fields[1].endsWith(`:refs/heads/${branch}`));
808
+ if (line && line[0] !== '!') {
809
+ return undefined;
810
+ }
811
+ const reason = line ? line.slice(2).join(' ') : printed.trim() || 'git printed nothing';
812
+ return `the remote refused the push to ${branch}: ${reason}`;
813
+ }
814
+ exports.getPushRefusal = getPushRefusal;
815
+ function isOwnedBy(object, uid) {
816
+ return !!uid && (object.metadata?.ownerReferences ?? []).some((reference) => reference.uid === uid);
817
+ }
818
+ // When the pod started, by the cluster's clock as far as it says, else now.
819
+ function startedAt(pod) {
820
+ const started = pod.status?.startTime ? new Date(pod.status.startTime).getTime() : NaN;
821
+ return Number.isNaN(started) ? Date.now() : Math.min(started, Date.now());
822
+ }
823
+ function defined(env) {
824
+ const result = {};
825
+ for (const [key, value] of Object.entries(env)) {
826
+ if (value !== undefined) {
827
+ result[key] = value;
828
+ }
829
+ }
830
+ return result;
831
+ }
832
+ // The client of the cluster sends what stdin has as fast as it comes: this waits while the
833
+ // connection has more than MAX_STDIN_BUFFERED not sent yet.
834
+ function throttle(stdin, connection) {
835
+ return stream_1.Readable.from((async function* () {
836
+ for await (const chunk of stdin) {
837
+ while ((connection()?.bufferedAmount ?? 0) > MAX_STDIN_BUFFERED) {
838
+ await (0, sleep_1.sleep)(10);
839
+ }
840
+ yield chunk;
841
+ }
842
+ })());
843
+ }
844
+ // The client of the cluster writes what the pod sends to stdout without waiting for it to
845
+ // be taken: this stops reading from the connection while stdout is full, so a slow reader
846
+ // of a file or of the files of a run holds the pod back instead of the server's memory.
847
+ function holdBack(socket, stdout) {
848
+ const tcp = socket._socket;
849
+ if (!tcp) {
850
+ return;
851
+ }
852
+ let paused = false;
853
+ socket.on('message', () => {
854
+ if (paused || !stdout.writableNeedDrain) {
855
+ return;
856
+ }
857
+ paused = true;
858
+ tcp.pause();
859
+ const resume = () => {
860
+ paused = false;
861
+ stdout.off('close', resume);
862
+ stdout.off('drain', resume);
863
+ tcp.resume();
864
+ };
865
+ stdout.once('drain', resume);
866
+ stdout.once('close', resume);
867
+ });
868
+ }
869
+ /**
870
+ * The answer of the pod: its first line, then what follows. Whatever follows is kept in
871
+ * memory, unless the first line says it is a stream: then it is passed on as it comes.
872
+ */
873
+ class AnswerReader extends stream_1.Writable {
874
+ constructor(body) {
875
+ super();
876
+ this.body = body;
877
+ this.chunks = [];
878
+ this.size = 0;
879
+ this.answer = new Promise((resolve, reject) => {
880
+ this.resolveAnswer = resolve;
881
+ this.rejectAnswer = reject;
882
+ });
883
+ this.answer.catch(() => undefined);
884
+ }
885
+ _write(chunk, _encoding, callback) {
886
+ if (this.stream) {
887
+ this.stream.write(chunk, callback);
888
+ return;
889
+ }
890
+ this.size += chunk.length;
891
+ if (this.size > MAX_ANSWER_BYTES) {
892
+ callback(new Error('the answer of the pod of the sandbox is too large'));
893
+ return;
894
+ }
895
+ this.chunks.push(chunk);
896
+ if (!this.header) {
897
+ // what came before has no newline: only this chunk is searched
898
+ const found = chunk.indexOf(10);
899
+ if (found < 0) {
900
+ callback();
901
+ return;
902
+ }
903
+ const all = Buffer.concat(this.chunks);
904
+ const newline = this.size - chunk.length + found;
905
+ try {
906
+ this.header = JSON.parse(all.subarray(0, newline).toString('utf8'));
907
+ }
908
+ catch {
909
+ callback(new Error('the pod of the sandbox did not answer'));
910
+ return;
911
+ }
912
+ this.chunks = [all.subarray(newline + 1)];
913
+ if (this.header.ok && this.header.stream && this.body) {
914
+ this.stream = new stream_1.PassThrough();
915
+ this.resolveAnswer({ header: this.header, stream: this.stream });
916
+ this.stream.write(this.chunks[0], callback);
917
+ this.chunks = [];
918
+ return;
919
+ }
920
+ }
921
+ callback();
922
+ }
923
+ // the command ended well: everything it wrote is here once the stream finished
924
+ async complete() {
925
+ this.end();
926
+ try {
927
+ await (0, promises_2.finished)(this);
928
+ }
929
+ catch (e) {
930
+ this.fail(e);
931
+ return;
932
+ }
933
+ if (this.stream) {
934
+ this.stream.end();
935
+ }
936
+ else if (this.header) {
937
+ this.resolveAnswer({ header: this.header, body: Buffer.concat(this.chunks) });
938
+ }
939
+ else {
940
+ this.rejectAnswer(new Error('the pod of the sandbox did not answer'));
941
+ }
942
+ }
943
+ fail(e) {
944
+ const error = e instanceof Error ? e : new Error(String(e));
945
+ if (this.stream) {
946
+ this.stream.destroy(error);
947
+ }
948
+ this.rejectAnswer(error);
949
+ }
950
+ }
951
+ // The files of a sandbox on a cluster: each call is a request to the pod of its files.
952
+ class KubernetesSandboxFiles {
953
+ constructor(request, rawFile) {
954
+ this.request = request;
955
+ this.rawFile = rawFile;
956
+ }
957
+ async ask(request) {
958
+ return (await this.request(request)).result;
959
+ }
960
+ async stat(path) {
961
+ return (await this.ask({ op: 'stat', path })) ?? undefined;
962
+ }
963
+ tree(query) {
964
+ return this.ask({ op: 'tree', query });
965
+ }
966
+ read(path, query) {
967
+ return this.ask({ op: 'read', path, query });
968
+ }
969
+ raw(path) {
970
+ return this.rawFile(path);
971
+ }
972
+ search(query) {
973
+ return this.ask({ op: 'search', query });
974
+ }
975
+ changes() {
976
+ return this.ask({ op: 'changes' });
977
+ }
978
+ diff(format, author) {
979
+ return this.ask({ op: 'diff', format, author });
980
+ }
981
+ async commit(author) {
982
+ return (await this.ask({ op: 'commit', author })) ?? undefined;
983
+ }
984
+ // Planned in the pod and planned again when written: the sandbox does not change in
985
+ // between, the registry holds it.
986
+ async plan(ops) {
987
+ const planned = await this.ask({ op: 'edit', ops, commit: false });
988
+ return {
989
+ ...planned,
990
+ commit: async () => {
991
+ await this.ask({ op: 'edit', ops, commit: true });
992
+ },
993
+ };
994
+ }
995
+ }
996
+ //# sourceMappingURL=kubernetes-sandbox-storage.js.map