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.
- package/build.schema.json +53 -1
- package/dist/cache/backends/local-cache-backend.js +10 -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/container-task-state.js +8 -6
- package/dist/executer/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- 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 +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -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 +3 -2
- package/dist/planner/service-definition.js +8 -2
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -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 +155 -0
- package/dist/planner/work-cache-id.js +15 -1
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +43 -0
- package/dist/planner/work-runtime-kubernetes.js +336 -102
- package/dist/planner/work-runtime-local.js +16 -8
- package/dist/planner/work-service.js +7 -3
- package/dist/planner/work-state-record.js +30 -0
- package/dist/program.js +80 -7
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- 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 +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -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 +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +13 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +23 -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 +554 -0
- package/dist/server/kubernetes-sandbox-storage.js +996 -0
- package/dist/server/oauth.js +1 -0
- package/dist/server/plan.js +51 -1
- 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 +51 -2
- package/dist/server/run-request.js +23 -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 +184 -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 +210 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-config.js +412 -22
- package/dist/server/server.js +204 -39
- package/dist/utils/run-id.js +12 -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,519 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SandboxRegistry = exports.SandboxError = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const units_1 = require("../utils/units");
|
|
8
|
+
const git_checkout_1 = require("./git-checkout");
|
|
9
|
+
const runs_1 = require("./runs");
|
|
10
|
+
const repo_1 = require("./repo");
|
|
11
|
+
const sandbox_storage_1 = require("./sandbox-storage");
|
|
12
|
+
// How long creating a sandbox or moving it to another commit may take
|
|
13
|
+
const FETCH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
14
|
+
// how old storage without a record has to be before a start removes it
|
|
15
|
+
const ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
|
16
|
+
class SandboxError extends Error {
|
|
17
|
+
constructor(status, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
exports.SandboxError = SandboxError;
|
|
23
|
+
/**
|
|
24
|
+
* The sandboxes of a server: who owns which, the limits, and one change at a time per
|
|
25
|
+
* sandbox. Everything is scoped to the owner: somebody else's sandbox is "not found".
|
|
26
|
+
*/
|
|
27
|
+
class SandboxRegistry {
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.sandboxes = new Map();
|
|
31
|
+
// creates that are under way, so they count against the limit of their owner, with the
|
|
32
|
+
// bytes they take once they were measured
|
|
33
|
+
this.creating = new Map();
|
|
34
|
+
this.deleting = new Set();
|
|
35
|
+
// the measures of runs that are over, which a run's result does not wait for
|
|
36
|
+
this.measuring = new Map();
|
|
37
|
+
this.locks = new Map();
|
|
38
|
+
this.runs = { holder: () => undefined, stop: async () => undefined };
|
|
39
|
+
this.now = options.now ?? Date.now;
|
|
40
|
+
}
|
|
41
|
+
// The runs are made after the executor, which needs the sandboxes.
|
|
42
|
+
useRuns(runs) {
|
|
43
|
+
this.runs = runs;
|
|
44
|
+
}
|
|
45
|
+
get limits() {
|
|
46
|
+
return this.options.limits;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Takes back the sandboxes of a previous start. Storage without a record is what a
|
|
50
|
+
* create left behind when the server stopped, and a record without storage is of no use.
|
|
51
|
+
*/
|
|
52
|
+
async load() {
|
|
53
|
+
const { storage, records } = this.options;
|
|
54
|
+
// no records at all only on a first start: a directory that can't be read is an error,
|
|
55
|
+
// not a reason to remove every sandbox
|
|
56
|
+
const names = await (0, promises_1.readdir)(records).catch((e) => {
|
|
57
|
+
if (e.code === 'ENOENT') {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
throw e;
|
|
61
|
+
});
|
|
62
|
+
// a record that can't be read keeps its files, for the operator to look at
|
|
63
|
+
const recorded = new Set(names.filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5)));
|
|
64
|
+
const stored = await storage.list();
|
|
65
|
+
const ids = new Set(stored.map((entry) => entry.id));
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
if (!name.endsWith('.json')) {
|
|
68
|
+
await (0, promises_1.rm)((0, path_1.join)(records, name), { force: true });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const sandbox = JSON.parse(await (0, promises_1.readFile)((0, path_1.join)(records, name), 'utf8'));
|
|
73
|
+
if (!runs_1.SANDBOX_ID_PATTERN.test(sandbox.id) || `${sandbox.id}.json` !== name) {
|
|
74
|
+
throw new Error('not a sandbox record');
|
|
75
|
+
}
|
|
76
|
+
if (!ids.has(sandbox.id)) {
|
|
77
|
+
this.options.log?.(`drop sandbox ${sandbox.id}: its files are gone`);
|
|
78
|
+
await (0, promises_1.rm)((0, path_1.join)(records, name), { force: true });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
this.sandboxes.set(sandbox.id, sandbox);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
this.options.log?.(`skip sandbox record ${name}: it can not be read`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
for (const entry of stored) {
|
|
89
|
+
if (recorded.has(entry.id) || !entry.remove) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// a sandbox being created has storage before its record
|
|
93
|
+
if (entry.createdAt && now - entry.createdAt.getTime() < ORPHAN_GRACE_MS) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
this.options.log?.(`remove the storage of sandbox ${entry.id}: it has no record`);
|
|
97
|
+
await entry.remove();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// oldest first
|
|
101
|
+
list(owner) {
|
|
102
|
+
return [...this.sandboxes.values()]
|
|
103
|
+
.filter((sandbox) => owns(owner, sandbox))
|
|
104
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
105
|
+
.map(copy);
|
|
106
|
+
}
|
|
107
|
+
get(owner, id) {
|
|
108
|
+
const sandbox = this.sandboxes.get(id);
|
|
109
|
+
return sandbox && owns(owner, sandbox) && !this.deleting.has(id) ? copy(sandbox) : undefined;
|
|
110
|
+
}
|
|
111
|
+
holder(id) {
|
|
112
|
+
return this.runs.holder(id);
|
|
113
|
+
}
|
|
114
|
+
usage(owner) {
|
|
115
|
+
const mine = this.list(owner);
|
|
116
|
+
const creating = [...this.creating.values()].filter((create) => owns(owner, create.owner));
|
|
117
|
+
return {
|
|
118
|
+
count: mine.length + creating.length,
|
|
119
|
+
bytes: [...mine, ...creating].reduce((sum, sandbox) => sum + sandbox.bytes, 0),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Waits for the measures of the owner's runs that are over, so their limits hold.
|
|
123
|
+
async measured(owner) {
|
|
124
|
+
const pending = [...this.measuring.entries()].filter(([id]) => {
|
|
125
|
+
const sandbox = this.sandboxes.get(id);
|
|
126
|
+
return sandbox && owns(owner, sandbox);
|
|
127
|
+
});
|
|
128
|
+
await Promise.all(pending.map(([, measure]) => measure));
|
|
129
|
+
}
|
|
130
|
+
// Whether the owner is over their bytes, so no more runs grow their sandboxes.
|
|
131
|
+
overLimit(owner) {
|
|
132
|
+
const { bytes } = this.usage(owner);
|
|
133
|
+
return bytes > this.limits.bytes
|
|
134
|
+
? `your sandboxes take ${(0, units_1.formatSize)(bytes)}, more than your limit of ${(0, units_1.formatSize)(this.limits.bytes)}, delete a sandbox`
|
|
135
|
+
: undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Fetches a branch of a repository into a new sandbox of the owner. Whether they may is the
|
|
139
|
+
* caller's to decide (`decideSandbox`); this checks the limits.
|
|
140
|
+
*/
|
|
141
|
+
async create(owner, repo, ref) {
|
|
142
|
+
const { limits, storage, repos } = this.options;
|
|
143
|
+
await this.measured(owner);
|
|
144
|
+
const usage = this.usage(owner);
|
|
145
|
+
if (usage.count >= limits.count) {
|
|
146
|
+
throw new SandboxError(413, `you have ${usage.count} sandboxes, the limit is ${limits.count}: delete one`);
|
|
147
|
+
}
|
|
148
|
+
if (usage.bytes >= limits.bytes) {
|
|
149
|
+
throw new SandboxError(413, `your sandboxes take ${(0, units_1.formatSize)(usage.bytes)}, your limit is ${(0, units_1.formatSize)(limits.bytes)}: delete one`);
|
|
150
|
+
}
|
|
151
|
+
if (this.total() >= limits.total) {
|
|
152
|
+
throw new SandboxError(507, 'the server has no room for another sandbox');
|
|
153
|
+
}
|
|
154
|
+
const id = `sbx_${(0, crypto_1.randomBytes)(12).toString('base64url')}`;
|
|
155
|
+
const key = { id, owner: { iss: owner.iss, sub: owner.sub } };
|
|
156
|
+
this.creating.set(id, { owner: { iss: owner.iss, sub: owner.sub }, bytes: 0 });
|
|
157
|
+
try {
|
|
158
|
+
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
159
|
+
await this.resolve(repo, ref, signal);
|
|
160
|
+
const credentials = await repos.credentials(repo);
|
|
161
|
+
let commit;
|
|
162
|
+
try {
|
|
163
|
+
commit = await storage.create(key, { url: repos.url(repo), ref, env: credentials.env, signal });
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
// the storage's own limits (a quota on a cluster) are said as they are
|
|
167
|
+
throw e instanceof SandboxError ? e : new SandboxError(502, repos.scrub(e, credentials.values).message);
|
|
168
|
+
}
|
|
169
|
+
const bytes = await storage.usage(key);
|
|
170
|
+
// checked against what is there now, other creates of the owner included, and taken
|
|
171
|
+
// before anything else can check: no await between the two
|
|
172
|
+
const used = this.usage(owner).bytes;
|
|
173
|
+
const problem = used + bytes > limits.bytes
|
|
174
|
+
? new SandboxError(413, `${repo} at ${ref} takes ${(0, units_1.formatSize)(bytes)}, more than is left of your limit of ${(0, units_1.formatSize)(limits.bytes)}`)
|
|
175
|
+
: this.total() + bytes > limits.total
|
|
176
|
+
? new SandboxError(507, 'the server has no room for another sandbox')
|
|
177
|
+
: undefined;
|
|
178
|
+
if (problem) {
|
|
179
|
+
await storage.remove(key);
|
|
180
|
+
throw problem;
|
|
181
|
+
}
|
|
182
|
+
this.creating.set(id, { owner: { iss: owner.iss, sub: owner.sub }, bytes });
|
|
183
|
+
const at = this.timestamp();
|
|
184
|
+
const sandbox = {
|
|
185
|
+
id,
|
|
186
|
+
owner: { iss: owner.iss, sub: owner.sub },
|
|
187
|
+
repo,
|
|
188
|
+
ref,
|
|
189
|
+
commit,
|
|
190
|
+
bytes,
|
|
191
|
+
createdAt: at,
|
|
192
|
+
lastUsedAt: at,
|
|
193
|
+
};
|
|
194
|
+
await this.save(sandbox);
|
|
195
|
+
this.sandboxes.set(id, sandbox);
|
|
196
|
+
return copy(sandbox);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
this.creating.delete(id);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Moves a sandbox to the commit a ref points to now. Only a sandbox without changes is
|
|
204
|
+
* moved, and none that a run holds: the files of a run are not changed under it.
|
|
205
|
+
*/
|
|
206
|
+
async fetch(owner, id, ref) {
|
|
207
|
+
return this.withLock(id, async () => {
|
|
208
|
+
const sandbox = this.mine(owner, id);
|
|
209
|
+
this.mustBeFree(id);
|
|
210
|
+
const state = await this.options.storage.state(keyOf(sandbox));
|
|
211
|
+
if (!state.clean) {
|
|
212
|
+
throw new SandboxError(409, `the sandbox ${id} has changes, a fetch would mix them with another commit`);
|
|
213
|
+
}
|
|
214
|
+
const { storage, repos } = this.options;
|
|
215
|
+
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
216
|
+
await this.resolve(sandbox.repo, ref, signal);
|
|
217
|
+
const credentials = await repos.credentials(sandbox.repo);
|
|
218
|
+
try {
|
|
219
|
+
sandbox.commit = await storage.fetch(keyOf(sandbox), {
|
|
220
|
+
url: repos.url(sandbox.repo),
|
|
221
|
+
ref,
|
|
222
|
+
env: credentials.env,
|
|
223
|
+
signal,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
throw new SandboxError(502, repos.scrub(e, credentials.values).message);
|
|
228
|
+
}
|
|
229
|
+
sandbox.ref = ref;
|
|
230
|
+
sandbox.bytes = await storage.usage(keyOf(sandbox));
|
|
231
|
+
sandbox.lastUsedAt = this.timestamp();
|
|
232
|
+
await this.save(sandbox);
|
|
233
|
+
return copy(sandbox);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Commits the changes of a sandbox as its owner and pushes them to a branch the operator
|
|
238
|
+
* allows (`sources[].push.branches`). The branch must not exist yet, or be one this
|
|
239
|
+
* sandbox pushed and still at what it pushed: a sandbox never writes over somebody
|
|
240
|
+
* else's work. The sandbox is at the pushed commit afterwards, so its changes are what
|
|
241
|
+
* is not pushed yet and the next push to the branch adds a commit to it.
|
|
242
|
+
*/
|
|
243
|
+
// `allowed` are the branches the owner's entitlements let them push to from a sandbox of
|
|
244
|
+
// its repository
|
|
245
|
+
async push(owner, id, branch, author, allowed) {
|
|
246
|
+
return this.withLock(id, async () => {
|
|
247
|
+
const sandbox = this.mine(owner, id);
|
|
248
|
+
const { storage, repos } = this.options;
|
|
249
|
+
if (!repos.source(sandbox.repo)?.secret) {
|
|
250
|
+
throw new SandboxError(403, `this server does not push to ${sandbox.repo}`);
|
|
251
|
+
}
|
|
252
|
+
if (allowed.length === 0) {
|
|
253
|
+
throw new SandboxError(403, `you may not push from sandboxes of ${sandbox.repo}`);
|
|
254
|
+
}
|
|
255
|
+
if (!(0, git_checkout_1.isValidBranch)(branch)) {
|
|
256
|
+
throw new SandboxError(400, `${JSON.stringify(branch.slice(0, 80))} is not a branch name`);
|
|
257
|
+
}
|
|
258
|
+
if (!(0, repo_1.matchesRef)(allowed, branch)) {
|
|
259
|
+
throw new SandboxError(403, `you may only push to ${allowed.join(', ')} of ${sandbox.repo}`);
|
|
260
|
+
}
|
|
261
|
+
this.mustBeFree(id);
|
|
262
|
+
if ((await storage.state(keyOf(sandbox))).clean) {
|
|
263
|
+
throw new SandboxError(409, `the sandbox ${id} has no changes to push`);
|
|
264
|
+
}
|
|
265
|
+
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
266
|
+
const url = repos.url(sandbox.repo);
|
|
267
|
+
const read = await repos.credentials(sandbox.repo);
|
|
268
|
+
let current;
|
|
269
|
+
try {
|
|
270
|
+
current = await (0, git_checkout_1.remoteBranch)(url, branch, read.env, signal);
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
throw new SandboxError(502, repos.scrub(e, read.values).message);
|
|
274
|
+
}
|
|
275
|
+
const pushed = sandbox.pushed?.[branch];
|
|
276
|
+
if (current && current !== pushed) {
|
|
277
|
+
throw new SandboxError(409, pushed
|
|
278
|
+
? `${branch} moved since this sandbox pushed it, push to a new branch`
|
|
279
|
+
: `${branch} exists and this sandbox did not push it, push to a new branch`);
|
|
280
|
+
}
|
|
281
|
+
let write;
|
|
282
|
+
try {
|
|
283
|
+
write = await repos.pushCredentials(sandbox.repo);
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
// what the provider said is the operator's: it can name accounts, projects and paths
|
|
287
|
+
this.options.log?.(`sandbox push ${id}: ${e instanceof Error ? e.message : e}`);
|
|
288
|
+
throw new SandboxError(502, 'the server could not read its credential to push');
|
|
289
|
+
}
|
|
290
|
+
let commit;
|
|
291
|
+
try {
|
|
292
|
+
commit = await storage.push(keyOf(sandbox), {
|
|
293
|
+
url,
|
|
294
|
+
branch,
|
|
295
|
+
expect: current ?? null,
|
|
296
|
+
author,
|
|
297
|
+
env: write.env,
|
|
298
|
+
signal,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
if (e instanceof sandbox_storage_1.SandboxPushConflict) {
|
|
303
|
+
throw new SandboxError(409, e.message);
|
|
304
|
+
}
|
|
305
|
+
// the lease refuses a branch that somebody created or moved since it was read; and a
|
|
306
|
+
// push that failed late (a timeout after the remote answered) may have gone through
|
|
307
|
+
const now = await (0, git_checkout_1.remoteBranch)(url, branch, read.env, AbortSignal.timeout(30 * 1000)).catch(() => current);
|
|
308
|
+
if (e instanceof sandbox_storage_1.SandboxPushFailed && now === e.commit) {
|
|
309
|
+
commit = e.commit;
|
|
310
|
+
}
|
|
311
|
+
else if (now !== current) {
|
|
312
|
+
throw new SandboxError(409, `${branch} changed while the sandbox pushed to it, push to a new branch`);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
throw new SandboxError(502, repos.scrub(e, [...write.values, ...read.values]).message);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!commit) {
|
|
319
|
+
// only files the API never reads changed: the deny list stays on the server
|
|
320
|
+
throw new SandboxError(409, `the sandbox ${id} has no changes to push`);
|
|
321
|
+
}
|
|
322
|
+
// the branch is this sandbox's from now on, whatever happens next
|
|
323
|
+
sandbox.pushed = { ...sandbox.pushed, [branch]: commit };
|
|
324
|
+
sandbox.lastUsedAt = this.timestamp();
|
|
325
|
+
await this.save(sandbox);
|
|
326
|
+
try {
|
|
327
|
+
await storage.moveTo(keyOf(sandbox), commit);
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
throw new SandboxError(502, `pushed ${commit} to ${branch}, but could not move the sandbox to it: ${e instanceof Error ? e.message : e}`);
|
|
331
|
+
}
|
|
332
|
+
sandbox.commit = commit;
|
|
333
|
+
sandbox.ref = branch;
|
|
334
|
+
sandbox.bytes = await storage.usage(keyOf(sandbox));
|
|
335
|
+
await this.save(sandbox);
|
|
336
|
+
return copy(sandbox);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
// Cancels the runs on the sandbox, waits for them to stop and removes its files.
|
|
340
|
+
async remove(owner, id) {
|
|
341
|
+
const sandbox = this.get(owner, id);
|
|
342
|
+
if (!sandbox) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
this.deleting.add(id);
|
|
346
|
+
try {
|
|
347
|
+
await this.runs.stop(id);
|
|
348
|
+
await this.withLock(id, async () => {
|
|
349
|
+
await this.options.storage.remove(keyOf(sandbox));
|
|
350
|
+
await (0, promises_1.rm)((0, path_1.join)(this.options.records, `${id}.json`), { force: true });
|
|
351
|
+
this.sandboxes.delete(id);
|
|
352
|
+
});
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
this.deleting.delete(id);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Runs `fn` while nothing else changes the sandbox: a fetch, a delete, or another run
|
|
361
|
+
* being submitted. The sandbox has to be the owner's and not on its way out.
|
|
362
|
+
*/
|
|
363
|
+
async withSandbox(owner, id, fn) {
|
|
364
|
+
return this.withLock(id, async () => fn(copy(this.mine(owner, id))));
|
|
365
|
+
}
|
|
366
|
+
// The files of one of the owner's sandboxes, to read one: while a run holds it too.
|
|
367
|
+
files(owner, id) {
|
|
368
|
+
const sandbox = this.mine(owner, id);
|
|
369
|
+
return this.options.storage.files(keyOf(sandbox));
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* A read that walks the files with other programs (ripgrep, git): between two batches of
|
|
373
|
+
* edits, and not while a run holds the sandbox. Those programs follow a directory that a
|
|
374
|
+
* task swaps for a link while they read; a read of one file checks what it opened.
|
|
375
|
+
*/
|
|
376
|
+
async read(owner, id, fn) {
|
|
377
|
+
return this.withLock(id, async () => {
|
|
378
|
+
const sandbox = this.mine(owner, id);
|
|
379
|
+
this.mustBeFree(id);
|
|
380
|
+
return fn(this.options.storage.files(keyOf(sandbox)));
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Applies a batch of edits to the files of a sandbox, all of them or none. Not while a run
|
|
385
|
+
* holds the sandbox, and not past the owner's bytes.
|
|
386
|
+
*/
|
|
387
|
+
async edit(owner, id, ops, dryRun = false) {
|
|
388
|
+
return this.withLock(id, async () => {
|
|
389
|
+
const sandbox = this.mine(owner, id);
|
|
390
|
+
this.mustBeFree(id);
|
|
391
|
+
const plan = await this.options.storage.files(keyOf(sandbox)).plan(ops);
|
|
392
|
+
if (dryRun || plan.files.length === 0) {
|
|
393
|
+
return plan.files;
|
|
394
|
+
}
|
|
395
|
+
if (plan.bytes > 0) {
|
|
396
|
+
const { limits } = this.options;
|
|
397
|
+
const used = this.usage(owner).bytes;
|
|
398
|
+
if (used + plan.bytes > limits.bytes) {
|
|
399
|
+
throw new SandboxError(413, `the edits take ${(0, units_1.formatSize)(plan.bytes)} more, your sandboxes take ${(0, units_1.formatSize)(used)} of your limit of ${(0, units_1.formatSize)(limits.bytes)}: delete a sandbox`);
|
|
400
|
+
}
|
|
401
|
+
if (this.total() + plan.bytes > limits.total) {
|
|
402
|
+
throw new SandboxError(507, 'the server has no room for these edits');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
await plan.commit();
|
|
406
|
+
sandbox.bytes = Math.max(0, sandbox.bytes + plan.bytes);
|
|
407
|
+
sandbox.lastUsedAt = this.timestamp();
|
|
408
|
+
await this.save(sandbox);
|
|
409
|
+
return plan.files;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async state(id, signal) {
|
|
413
|
+
return this.options.storage.state(keyOf(this.existing(id)), signal);
|
|
414
|
+
}
|
|
415
|
+
// What a run works on, `into` a directory of the run when the storage copies the files;
|
|
416
|
+
// the run is the sandbox's use.
|
|
417
|
+
async attach(id, into) {
|
|
418
|
+
const sandbox = this.existing(id);
|
|
419
|
+
sandbox.lastUsedAt = this.timestamp();
|
|
420
|
+
await this.save(sandbox);
|
|
421
|
+
return this.options.storage.attach(keyOf(sandbox), into);
|
|
422
|
+
}
|
|
423
|
+
// What a sandbox takes after a run wrote to it. The run's result is not held up by it: the
|
|
424
|
+
// owner's next run or create waits for it (`measured`).
|
|
425
|
+
measure(id) {
|
|
426
|
+
const previous = this.measuring.get(id) ?? Promise.resolve();
|
|
427
|
+
const measure = previous.then(async () => {
|
|
428
|
+
const sandbox = this.sandboxes.get(id);
|
|
429
|
+
if (!sandbox || this.deleting.has(id)) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
sandbox.bytes = await this.options.storage.usage(keyOf(sandbox));
|
|
433
|
+
await this.save(sandbox);
|
|
434
|
+
});
|
|
435
|
+
const settled = measure.catch(() => undefined);
|
|
436
|
+
this.measuring.set(id, settled);
|
|
437
|
+
settled.then(() => {
|
|
438
|
+
if (this.measuring.get(id) === settled) {
|
|
439
|
+
this.measuring.delete(id);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
return measure;
|
|
443
|
+
}
|
|
444
|
+
existing(id) {
|
|
445
|
+
const sandbox = this.sandboxes.get(id);
|
|
446
|
+
if (!sandbox || this.deleting.has(id)) {
|
|
447
|
+
throw new Error(`the sandbox ${id} was deleted`);
|
|
448
|
+
}
|
|
449
|
+
return sandbox;
|
|
450
|
+
}
|
|
451
|
+
mine(owner, id) {
|
|
452
|
+
const sandbox = this.sandboxes.get(id);
|
|
453
|
+
if (!sandbox || !owns(owner, sandbox) || this.deleting.has(id)) {
|
|
454
|
+
throw new SandboxError(404, 'not found');
|
|
455
|
+
}
|
|
456
|
+
return sandbox;
|
|
457
|
+
}
|
|
458
|
+
mustBeFree(id) {
|
|
459
|
+
const run = this.runs.holder(id);
|
|
460
|
+
if (run) {
|
|
461
|
+
throw new SandboxError(409, `the run ${run} holds the sandbox ${id}, wait for it to finish or cancel it`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// a sandbox starts from a branch, never a tag or a commit
|
|
465
|
+
async resolve(repo, ref, signal) {
|
|
466
|
+
try {
|
|
467
|
+
(0, git_checkout_1.assertBranchName)(ref);
|
|
468
|
+
return await this.options.repos.resolve(repo, ref, signal, 'branch');
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
472
|
+
throw e instanceof git_checkout_1.RefError
|
|
473
|
+
? new SandboxError(400, message)
|
|
474
|
+
: new SandboxError(502, `could not resolve ${ref}: ${message}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
total() {
|
|
478
|
+
return [...this.sandboxes.values(), ...this.creating.values()].reduce((sum, sandbox) => sum + sandbox.bytes, 0);
|
|
479
|
+
}
|
|
480
|
+
withLock(id, fn) {
|
|
481
|
+
const previous = this.locks.get(id) ?? Promise.resolve();
|
|
482
|
+
const next = previous.then(fn, fn);
|
|
483
|
+
const settled = next.then(() => undefined, () => undefined);
|
|
484
|
+
this.locks.set(id, settled);
|
|
485
|
+
void settled.then(() => {
|
|
486
|
+
if (this.locks.get(id) === settled) {
|
|
487
|
+
this.locks.delete(id);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
return next;
|
|
491
|
+
}
|
|
492
|
+
async save(sandbox) {
|
|
493
|
+
const directory = this.options.records;
|
|
494
|
+
await (0, promises_1.mkdir)(directory, { recursive: true, mode: 0o700 });
|
|
495
|
+
const file = (0, path_1.join)(directory, `${sandbox.id}.json`);
|
|
496
|
+
const temporary = `${file}.${(0, crypto_1.randomBytes)(4).toString('hex')}.tmp`;
|
|
497
|
+
await (0, promises_1.writeFile)(temporary, JSON.stringify(sandbox), { mode: 0o600 });
|
|
498
|
+
await (0, promises_1.rename)(temporary, file);
|
|
499
|
+
}
|
|
500
|
+
timestamp() {
|
|
501
|
+
return new Date(this.now()).toISOString();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
exports.SandboxRegistry = SandboxRegistry;
|
|
505
|
+
function owns(owner, sandbox) {
|
|
506
|
+
const of = 'owner' in sandbox ? sandbox.owner : sandbox;
|
|
507
|
+
return of.iss === owner.iss && of.sub === owner.sub;
|
|
508
|
+
}
|
|
509
|
+
function keyOf(sandbox) {
|
|
510
|
+
return { id: sandbox.id, owner: sandbox.owner };
|
|
511
|
+
}
|
|
512
|
+
function copy(sandbox) {
|
|
513
|
+
return {
|
|
514
|
+
...sandbox,
|
|
515
|
+
owner: { ...sandbox.owner },
|
|
516
|
+
...(sandbox.pushed ? { pushed: { ...sandbox.pushed } } : {}),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
//# sourceMappingURL=sandboxes.js.map
|