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