hammerkit 1.27.0-next.2 → 1.27.0-next.21
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 +27 -0
- package/dist/cache/backends/registry-cache-backend.js +2 -2
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +1 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/event-cache.js +20 -2
- package/dist/executer/execute-work-task.js +5 -3
- package/dist/executer/execution-steps.js +25 -4
- package/dist/executer/reset-work-tree.js +4 -0
- package/dist/executer/scheduler/enqueue-next.js +7 -0
- package/dist/executer/scheduler/state-key.js +42 -5
- package/dist/harness/connect.js +20 -6
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +63 -114
- package/dist/harness/opencode.js +158 -50
- package/dist/harness/pi.js +29 -0
- package/dist/harness/sandbox-client.js +207 -65
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +27 -16
- 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 +5 -9
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
- package/dist/kubernetes/ensure-persistent-data.js +30 -20
- package/dist/kubernetes/get-service-ip.js +2 -2
- package/dist/kubernetes/job-secrets.js +33 -19
- package/dist/kubernetes/kept-claims.js +22 -10
- package/dist/kubernetes/network-policy.js +85 -27
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/resources.js +25 -3
- package/dist/kubernetes/restore-kubernetes-data.js +9 -3
- package/dist/kubernetes/sandbox-runner.js +626 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/volumes.js +6 -3
- package/dist/network/egress-proxy.js +45 -6
- package/dist/optimizer/calculate-checksum.js +3 -2
- package/dist/optimizer/check-key.js +94 -0
- package/dist/optimizer/check-keys.js +64 -0
- package/dist/optimizer/get-work-cache-stats.js +17 -3
- package/dist/optimizer/work-task-cache-description.js +51 -1
- package/dist/planner/utils/append-work-dependencies.js +10 -5
- package/dist/planner/utils/append-work-service.js +1 -0
- package/dist/planner/utils/append-work-task.js +15 -2
- package/dist/planner/utils/get-work-runtime.js +6 -1
- package/dist/planner/utils/normalize-path.js +19 -1
- package/dist/planner/utils/parse-work-mount.js +7 -1
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/validate.js +22 -0
- package/dist/planner/work-network.js +3 -1
- package/dist/planner/work-runtime-aggregate.js +32 -0
- package/dist/planner/work-runtime-docker.js +2 -1
- package/dist/planner/work-runtime-kubernetes.js +97 -48
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +35 -13
- package/dist/remote/annotations.js +13 -1
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +64 -3
- package/dist/remote/remote-commands.js +41 -9
- package/dist/remote/remote-work-commands.js +246 -1
- package/dist/remote/server-client.js +15 -2
- package/dist/remote/token-source.js +64 -26
- package/dist/run-program.js +9 -0
- package/dist/schema/build-file-base-task-schema.js +5 -1
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-include-schema.js +10 -4
- package/dist/schema/reference-parser.js +9 -1
- package/dist/schema/schema-parser.js +36 -4
- package/dist/schema/work-scope-parser.js +33 -10
- package/dist/secrets/github-app-token-command.js +3 -1
- package/dist/server/accounts-api.js +3 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/definitions-api.js +4 -2
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +56 -5
- package/dist/server/kubernetes-backend.js +303 -64
- package/dist/server/kubernetes-sandbox-storage.js +48 -3
- package/dist/server/oauth.js +12 -1
- package/dist/server/plan.js +126 -62
- package/dist/server/policy.js +32 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +50 -19
- package/dist/server/run-request.js +64 -3
- package/dist/server/run-worker.js +146 -10
- package/dist/server/runs.js +347 -27
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes-api.js +10 -4
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-command.js +39 -20
- package/dist/server/server-config.js +213 -14
- package/dist/server/server.js +268 -15
- package/dist/server/service-runs.js +82 -0
- package/dist/utils/abort-on-signals.js +46 -6
- package/dist/utils/tar.js +36 -0
- package/package.json +1 -1
package/dist/server/runs.js
CHANGED
|
@@ -30,6 +30,8 @@ exports.SANDBOX_ID_PATTERN = /^sbx_[A-Za-z0-9_-]{16}$/;
|
|
|
30
30
|
function onSandbox(run, sandbox) {
|
|
31
31
|
return run.source.type === 'sandbox' && run.source.id === sandbox;
|
|
32
32
|
}
|
|
33
|
+
// the tasks of a run whose check keys are kept, a build file has fewer
|
|
34
|
+
const MAX_KEYED_TASKS = 1000;
|
|
33
35
|
// how much of the end of a task's output its result carries
|
|
34
36
|
const TAIL_LINES = 20;
|
|
35
37
|
const TAIL_LINE_LENGTH = 1000;
|
|
@@ -100,6 +102,7 @@ class RunRegistry {
|
|
|
100
102
|
bytes: 0,
|
|
101
103
|
tails: new Map(Object.entries(run.results ?? {}).map(([task, result]) => [task, result.lastLog])),
|
|
102
104
|
saving: Promise.resolve(),
|
|
105
|
+
keys: {},
|
|
103
106
|
};
|
|
104
107
|
this.entries.set(run.id, entry);
|
|
105
108
|
if (run.state === 'awaiting-approval') {
|
|
@@ -112,9 +115,22 @@ class RunRegistry {
|
|
|
112
115
|
this.finish(entry, 'rejected', 'nobody approved it in time');
|
|
113
116
|
}
|
|
114
117
|
}
|
|
118
|
+
else if (this.maySleep(entry) && ['queued', 'running', 'up', 'asleep'].includes(run.state)) {
|
|
119
|
+
// the services went with the process that held them, the next request wakes them
|
|
120
|
+
if (run.state === 'queued' || run.state === 'running' || run.state === 'up') {
|
|
121
|
+
this.sleep(entry);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
this.expireLater(entry);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
115
127
|
else if (run.state === 'queued' || run.state === 'running') {
|
|
116
128
|
this.finish(entry, 'failed', `the server stopped while the run was ${run.state}, submit it again`);
|
|
117
129
|
}
|
|
130
|
+
else if (run.state === 'up') {
|
|
131
|
+
// the services went with the process that held them
|
|
132
|
+
this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
|
|
133
|
+
}
|
|
118
134
|
}
|
|
119
135
|
this.evict();
|
|
120
136
|
}
|
|
@@ -129,10 +145,18 @@ class RunRegistry {
|
|
|
129
145
|
throw new RunError('too many runs are waiting for an approval');
|
|
130
146
|
}
|
|
131
147
|
this.checkLimits(owner, waiting);
|
|
148
|
+
if (spec.up) {
|
|
149
|
+
this.checkUpLimits(owner);
|
|
150
|
+
this.checkHosts(owner, spec.up);
|
|
151
|
+
}
|
|
132
152
|
const run = {
|
|
133
153
|
...spec,
|
|
134
154
|
id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
|
|
135
|
-
owner: {
|
|
155
|
+
owner: {
|
|
156
|
+
iss: owner.iss,
|
|
157
|
+
sub: owner.sub,
|
|
158
|
+
...(spec.up?.sleepTtl ? { groups: [...(owner.groups ?? [])], claims: { ...(owner.claims ?? {}) } } : {}),
|
|
159
|
+
},
|
|
136
160
|
state: waiting ? 'awaiting-approval' : 'queued',
|
|
137
161
|
createdAt: this.timestamp(),
|
|
138
162
|
};
|
|
@@ -145,6 +169,7 @@ class RunRegistry {
|
|
|
145
169
|
bytes: 0,
|
|
146
170
|
tails: new Map(),
|
|
147
171
|
saving: Promise.resolve(),
|
|
172
|
+
keys: {},
|
|
148
173
|
};
|
|
149
174
|
this.entries.set(run.id, entry);
|
|
150
175
|
if (waiting) {
|
|
@@ -179,30 +204,79 @@ class RunRegistry {
|
|
|
179
204
|
.map((entry) => this.copy(entry.run))
|
|
180
205
|
.reverse();
|
|
181
206
|
}
|
|
182
|
-
// A queued run is cancelled at once; a running one when its executor stops.
|
|
207
|
+
// A queued run is cancelled at once; a running one when its executor stops. A service run
|
|
208
|
+
// that is up stops its services, and says who stopped it.
|
|
183
209
|
cancel(owner, id, canUse) {
|
|
184
210
|
const entry = this.find(owner, id, canUse);
|
|
185
211
|
if (!entry) {
|
|
186
212
|
return undefined;
|
|
187
213
|
}
|
|
188
|
-
this.stop(entry);
|
|
214
|
+
this.stop(entry, undefined, { iss: owner.iss, sub: owner.sub });
|
|
189
215
|
return this.copy(entry.run);
|
|
190
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Moves the end of a service run that is up (or asleep) to `ttl` (ms) from now, at most
|
|
219
|
+
* the server's maxTtl, and never sooner than it was. Its owner may, and whoever `mayOpen`
|
|
220
|
+
* says may open its endpoints.
|
|
221
|
+
*/
|
|
222
|
+
extend(caller, id, ttl, mayOpen) {
|
|
223
|
+
const entry = this.entries.get(id);
|
|
224
|
+
if (!entry || !(this.owns(caller, entry) || (entry.run.up && mayOpen?.(this.copy(entry.run))))) {
|
|
225
|
+
return { status: 'not-found' };
|
|
226
|
+
}
|
|
227
|
+
// up, asleep, or woken and starting again
|
|
228
|
+
if (!entry.run.upAt || isFinal(entry.run.state)) {
|
|
229
|
+
return { status: 'not-up', state: entry.run.state };
|
|
230
|
+
}
|
|
231
|
+
// never sooner than it was: who wants it gone stops it
|
|
232
|
+
const ms = Math.min(ttl, this.options.up?.maxTtl ?? ttl);
|
|
233
|
+
const expiresAt = Math.max(Date.parse(entry.run.expiresAt ?? '') || 0, this.now() + ms);
|
|
234
|
+
entry.run.expiresAt = new Date(expiresAt).toISOString();
|
|
235
|
+
this.record(entry, { type: 'extended', by: { iss: caller.iss, sub: caller.sub }, expiresAt: entry.run.expiresAt });
|
|
236
|
+
this.expireLater(entry);
|
|
237
|
+
return { status: 'ok', run: this.copy(entry.run) };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The service run an endpoint host belongs to: the newest that is up, so one that replaces
|
|
241
|
+
* a run of the same name takes its hosts over as soon as it is up; else the newest that
|
|
242
|
+
* had it, for a page that says how it stands. With the address of the endpoint when up.
|
|
243
|
+
*/
|
|
244
|
+
endpoint(host) {
|
|
245
|
+
let found;
|
|
246
|
+
for (const entry of this.entries.values()) {
|
|
247
|
+
const endpoint = entry.run.up?.endpoints.find((candidate) => candidate.host === host);
|
|
248
|
+
if (endpoint && (entry.run.state === 'up' || found?.entry.run.state !== 'up')) {
|
|
249
|
+
found = { entry, endpoint };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!found) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const { entry, endpoint } = found;
|
|
256
|
+
const target = entry.run.state === 'up'
|
|
257
|
+
? entry.targets?.find((candidate) => candidate.service === endpoint.service && candidate.port === endpoint.port)
|
|
258
|
+
: undefined;
|
|
259
|
+
return { run: this.copy(entry.run), endpoint: { ...endpoint }, ...(target ? { target } : {}) };
|
|
260
|
+
}
|
|
191
261
|
// The run that holds a sandbox: the oldest on it that is not over. It holds it from
|
|
192
|
-
// when it is submitted, so the files are not moved under what an approver saw.
|
|
262
|
+
// when it is submitted, so the files are not moved under what an approver saw. A service
|
|
263
|
+
// run that is up works on a copy of the files, and holds it no longer.
|
|
193
264
|
holder(sandbox) {
|
|
194
|
-
return [...this.entries.values()].find((entry) => onSandbox(entry.run, sandbox) &&
|
|
195
|
-
.
|
|
265
|
+
return [...this.entries.values()].find((entry) => onSandbox(entry.run, sandbox) &&
|
|
266
|
+
!isFinal(entry.run.state) &&
|
|
267
|
+
entry.run.state !== 'up' &&
|
|
268
|
+
entry.run.state !== 'asleep')?.run.id;
|
|
196
269
|
}
|
|
197
270
|
// Cancels every run on a sandbox and resolves when none of them runs anymore.
|
|
198
271
|
async stopSandbox(sandbox, reason) {
|
|
199
272
|
const on = () => [...this.entries.values()].filter((entry) => onSandbox(entry.run, sandbox));
|
|
200
273
|
on().forEach((entry) => this.stop(entry, reason));
|
|
201
|
-
while (on().some((entry) => entry.run.state === 'running')) {
|
|
274
|
+
while (on().some((entry) => entry.run.state === 'running' || entry.run.state === 'up')) {
|
|
202
275
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
203
276
|
}
|
|
204
277
|
}
|
|
205
|
-
stop(entry, reason) {
|
|
278
|
+
stop(entry, reason, by) {
|
|
279
|
+
var _a, _b;
|
|
206
280
|
if (entry.run.state === 'queued') {
|
|
207
281
|
this.queue.splice(this.queue.indexOf(entry), 1);
|
|
208
282
|
this.finish(entry, 'cancelled', reason);
|
|
@@ -213,6 +287,15 @@ class RunRegistry {
|
|
|
213
287
|
else if (entry.run.state === 'running') {
|
|
214
288
|
entry.abort.abort();
|
|
215
289
|
}
|
|
290
|
+
else if (entry.run.state === 'up') {
|
|
291
|
+
(_a = entry.run).stoppedBy ?? (_a.stoppedBy = by ?? 'replaced');
|
|
292
|
+
entry.sleeping = false;
|
|
293
|
+
entry.abort.abort();
|
|
294
|
+
}
|
|
295
|
+
else if (entry.run.state === 'asleep') {
|
|
296
|
+
(_b = entry.run).stoppedBy ?? (_b.stoppedBy = by ?? 'replaced');
|
|
297
|
+
this.finish(entry, 'succeeded');
|
|
298
|
+
}
|
|
216
299
|
}
|
|
217
300
|
// The runs that wait for an approval, of anybody, oldest first. Who may see
|
|
218
301
|
// them is for the caller to decide.
|
|
@@ -289,18 +372,20 @@ class RunRegistry {
|
|
|
289
372
|
return { events, finished, stop: () => entry.listeners.delete(listener) };
|
|
290
373
|
}
|
|
291
374
|
/**
|
|
292
|
-
* The run once it is over, or as it is after `ms` or when
|
|
293
|
-
* that can not hold a stream of events open (a tool call of
|
|
375
|
+
* The run once it is over (a service run: once it is up), or as it is after `ms` or when
|
|
376
|
+
* `signal` aborts, for clients that can not hold a stream of events open (a tool call of
|
|
377
|
+
* an agent).
|
|
294
378
|
*/
|
|
295
379
|
async wait(owner, id, ms, canUse, signal) {
|
|
296
380
|
const entry = this.find(owner, id, canUse);
|
|
297
381
|
if (!entry) {
|
|
298
382
|
return undefined;
|
|
299
383
|
}
|
|
300
|
-
if (!isFinal(entry.run.state) && ms > 0 && !this.closed && !signal?.aborted) {
|
|
384
|
+
if (!isFinal(entry.run.state) && entry.run.state !== 'up' && ms > 0 && !this.closed && !signal?.aborted) {
|
|
301
385
|
await new Promise((resolve) => {
|
|
302
386
|
const listener = (event) => {
|
|
303
|
-
|
|
387
|
+
// a service run is there once it is up
|
|
388
|
+
if (event.type === 'state' && (isFinal(event.state) || event.state === 'up')) {
|
|
304
389
|
done();
|
|
305
390
|
}
|
|
306
391
|
};
|
|
@@ -335,10 +420,19 @@ class RunRegistry {
|
|
|
335
420
|
async close() {
|
|
336
421
|
this.closed = true;
|
|
337
422
|
for (const entry of this.queue.splice(0)) {
|
|
338
|
-
this.
|
|
423
|
+
if (this.maySleep(entry) && this.options.store) {
|
|
424
|
+
// a woken service run sleeps again, the next start keeps it
|
|
425
|
+
this.sleep(entry);
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
this.finish(entry, 'cancelled');
|
|
429
|
+
}
|
|
339
430
|
}
|
|
340
431
|
for (const entry of this.entries.values()) {
|
|
341
|
-
if (entry.run.state === '
|
|
432
|
+
if (entry.run.state === 'asleep') {
|
|
433
|
+
this.clearTimer(entry);
|
|
434
|
+
}
|
|
435
|
+
else if (entry.run.state === 'awaiting-approval') {
|
|
342
436
|
if (this.options.store) {
|
|
343
437
|
// the record keeps it waiting, the next start takes it back
|
|
344
438
|
this.clearTimer(entry);
|
|
@@ -353,11 +447,15 @@ class RunRegistry {
|
|
|
353
447
|
done();
|
|
354
448
|
}
|
|
355
449
|
for (const entry of this.entries.values()) {
|
|
356
|
-
if (entry.run.state === 'running') {
|
|
450
|
+
if (entry.run.state === 'running' || entry.run.state === 'up') {
|
|
451
|
+
// a service run that may sleep is asleep in its record, the next request after the
|
|
452
|
+
// start wakes it
|
|
453
|
+
entry.sleeping = this.maySleep(entry) && !!this.options.store;
|
|
357
454
|
entry.abort.abort();
|
|
358
455
|
}
|
|
359
456
|
}
|
|
360
|
-
while (this.running > 0
|
|
457
|
+
while (this.running > 0 ||
|
|
458
|
+
[...this.entries.values()].some((entry) => entry.run.state === 'running' || entry.run.state === 'up')) {
|
|
361
459
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
362
460
|
}
|
|
363
461
|
// the runs that were stopped are written down before the server is gone
|
|
@@ -388,6 +486,8 @@ class RunRegistry {
|
|
|
388
486
|
copy(run) {
|
|
389
487
|
return {
|
|
390
488
|
...run,
|
|
489
|
+
...(run.up ? { up: JSON.parse(JSON.stringify(run.up)) } : {}),
|
|
490
|
+
...(run.stoppedBy && typeof run.stoppedBy === 'object' ? { stoppedBy: { ...run.stoppedBy } } : {}),
|
|
391
491
|
tasks: [...run.tasks],
|
|
392
492
|
source: { ...run.source },
|
|
393
493
|
options: JSON.parse(JSON.stringify(run.options)),
|
|
@@ -415,6 +515,37 @@ class RunRegistry {
|
|
|
415
515
|
throw new QuotaError(`you have ${limits.queued} runs in the queue, wait for one to start or cancel one`);
|
|
416
516
|
}
|
|
417
517
|
}
|
|
518
|
+
// Service runs that are not over, of the caller and of everybody, against what may be:
|
|
519
|
+
// one that is asleep counts, it wakes without asking for a place.
|
|
520
|
+
checkUpLimits(owner) {
|
|
521
|
+
const limits = this.options.up;
|
|
522
|
+
if (!limits) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const open = [...this.entries.values()].filter((entry) => entry.run.up && !isFinal(entry.run.state));
|
|
526
|
+
if (open.filter((entry) => this.owns(owner, entry)).length >= limits.perCaller) {
|
|
527
|
+
throw new QuotaError(`you have ${limits.perCaller} service runs starting, up or asleep, stop one or wait for its end`);
|
|
528
|
+
}
|
|
529
|
+
if (open.length >= limits.total) {
|
|
530
|
+
throw new RunError(`the server has ${limits.total} service runs starting, up or asleep, try again later`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// A host is one run's, or a run's that replaces it (same owner and name) once it is up:
|
|
534
|
+
// two labels that come out the same are refused, they would reach each other's services.
|
|
535
|
+
checkHosts(owner, up) {
|
|
536
|
+
const hosts = new Set(up.endpoints.map((endpoint) => endpoint.host));
|
|
537
|
+
for (const entry of this.entries.values()) {
|
|
538
|
+
if (isFinal(entry.run.state) || !entry.run.up) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const taken = entry.run.up.endpoints.find((endpoint) => hosts.has(endpoint.host));
|
|
542
|
+
if (taken && !(this.owns(owner, entry) && entry.run.up.name === up.name)) {
|
|
543
|
+
const error = new RunError(`the address ${taken.host} is another service run's, pick another name`);
|
|
544
|
+
error.status = 409;
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
418
549
|
runningOf(owner) {
|
|
419
550
|
return [...this.entries.values()].filter((entry) => entry.run.state === 'running' && this.owns(owner, entry)).length;
|
|
420
551
|
}
|
|
@@ -424,20 +555,30 @@ class RunRegistry {
|
|
|
424
555
|
return (source.type === 'sandbox' &&
|
|
425
556
|
[...this.entries.values()].some((entry) => entry.run.state === 'running' && onSandbox(entry.run, source.id)));
|
|
426
557
|
}
|
|
558
|
+
// Whether a run that executes holds what the run would (RunExecutor.exclusive)
|
|
559
|
+
held(run) {
|
|
560
|
+
const exclusive = this.options.executor.exclusive?.bind(this.options.executor);
|
|
561
|
+
const key = exclusive?.(run);
|
|
562
|
+
return (key !== undefined &&
|
|
563
|
+
[...this.entries.values()].some((entry) => entry.run.state === 'running' && exclusive?.(entry.run) === key));
|
|
564
|
+
}
|
|
427
565
|
// The first run in the queue whose caller has a place left: one caller with many runs
|
|
428
566
|
// does not keep the others from the machine. A run on a sandbox another run works on
|
|
429
|
-
// waits for it.
|
|
567
|
+
// waits for it, as does one that needs what an executing run holds.
|
|
430
568
|
dispatch() {
|
|
431
569
|
while (this.running < this.maxConcurrent) {
|
|
432
|
-
const perCaller =
|
|
433
|
-
const index = this.queue.findIndex((entry) => this.runningOf(entry.run.owner) < perCaller && !this.sandboxBusy(entry.run));
|
|
570
|
+
const perCaller = this.options.limits?.running ?? Infinity;
|
|
571
|
+
const index = this.queue.findIndex((entry) => this.runningOf(entry.run.owner) < perCaller && !this.sandboxBusy(entry.run) && !this.held(entry.run));
|
|
434
572
|
if (index < 0) {
|
|
435
573
|
return;
|
|
436
574
|
}
|
|
437
575
|
const [entry] = this.queue.splice(index, 1);
|
|
438
576
|
this.running++;
|
|
439
577
|
this.execute(entry).finally(() => {
|
|
440
|
-
|
|
578
|
+
// a service run that is up gave its place back already
|
|
579
|
+
if (!entry.released) {
|
|
580
|
+
this.running--;
|
|
581
|
+
}
|
|
441
582
|
this.dispatch();
|
|
442
583
|
});
|
|
443
584
|
}
|
|
@@ -446,27 +587,190 @@ class RunRegistry {
|
|
|
446
587
|
entry.run.state = 'running';
|
|
447
588
|
entry.run.startedAt = this.timestamp();
|
|
448
589
|
this.record(entry, { type: 'state', state: 'running' });
|
|
590
|
+
// a service run gets this long to be up, then its ttl counts
|
|
449
591
|
const limit = this.options.maxDuration;
|
|
450
|
-
|
|
592
|
+
entry.durationTimer = limit
|
|
451
593
|
? setTimeout(() => {
|
|
452
594
|
entry.timedOut = true;
|
|
453
595
|
entry.abort.abort();
|
|
454
596
|
}, limit)
|
|
455
597
|
: undefined;
|
|
456
|
-
|
|
598
|
+
entry.durationTimer?.unref();
|
|
457
599
|
try {
|
|
458
600
|
await this.options.executor.execute(this.copy(entry.run), {
|
|
459
601
|
emit: (event) => this.record(entry, event),
|
|
460
602
|
signal: entry.abort.signal,
|
|
603
|
+
...(entry.run.up ? { up: (targets) => this.markUp(entry, targets) } : {}),
|
|
461
604
|
});
|
|
462
|
-
|
|
605
|
+
if (entry.sleeping) {
|
|
606
|
+
this.sleep(entry);
|
|
607
|
+
}
|
|
608
|
+
else if (entry.run.upAt) {
|
|
609
|
+
// services stay until they are stopped: ones that stopped on their own failed
|
|
610
|
+
this.endServices(entry, 'the services stopped on their own');
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
|
|
614
|
+
}
|
|
463
615
|
}
|
|
464
616
|
catch (e) {
|
|
465
|
-
const
|
|
466
|
-
|
|
617
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
618
|
+
if (entry.sleeping) {
|
|
619
|
+
this.sleep(entry);
|
|
620
|
+
}
|
|
621
|
+
else if (entry.run.upAt) {
|
|
622
|
+
this.endServices(entry, message);
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
const cancelled = entry.abort.signal.aborted && !entry.timedOut;
|
|
626
|
+
this.finish(entry, cancelled ? 'cancelled' : 'failed', entry.timedOut ? this.tooLong() : cancelled ? undefined : message);
|
|
627
|
+
}
|
|
467
628
|
}
|
|
468
629
|
finally {
|
|
469
|
-
clearTimeout(
|
|
630
|
+
clearTimeout(entry.durationTimer);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// The services of a service run are ready: its ttl counts from now, its place of
|
|
634
|
+
// maxConcurrent goes to the next run, and an older run of its owner with the same name
|
|
635
|
+
// stops, so its hosts are this run's.
|
|
636
|
+
markUp(entry, targets) {
|
|
637
|
+
const spec = entry.run.up;
|
|
638
|
+
if (!spec || entry.run.state !== 'running' || entry.abort.signal.aborted) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
clearTimeout(entry.durationTimer);
|
|
642
|
+
entry.durationTimer = undefined;
|
|
643
|
+
entry.targets = targets.map((target) => ({ ...target, address: { ...target.address } }));
|
|
644
|
+
entry.run.state = 'up';
|
|
645
|
+
// one reading of the clock, so the ttl is exactly expiresAt - upAt
|
|
646
|
+
const now = this.now();
|
|
647
|
+
// the ttl counts from the first time, not from a wake
|
|
648
|
+
if (!entry.run.upAt) {
|
|
649
|
+
entry.run.upAt = new Date(now).toISOString();
|
|
650
|
+
entry.run.expiresAt = new Date(now + spec.ttl).toISOString();
|
|
651
|
+
}
|
|
652
|
+
if (spec.sleepTtl) {
|
|
653
|
+
entry.run.sleepsAt = new Date(now + spec.sleepTtl).toISOString();
|
|
654
|
+
}
|
|
655
|
+
this.record(entry, { type: 'state', state: 'up' });
|
|
656
|
+
this.expireLater(entry);
|
|
657
|
+
entry.released = true;
|
|
658
|
+
this.running--;
|
|
659
|
+
const entries = [...this.entries.values()];
|
|
660
|
+
for (const other of entries.slice(0, entries.indexOf(entry))) {
|
|
661
|
+
if (other.run.up?.name === spec.name &&
|
|
662
|
+
this.owns(entry.run.owner, other) &&
|
|
663
|
+
!isFinal(other.run.state) &&
|
|
664
|
+
other.run.state !== 'awaiting-approval') {
|
|
665
|
+
other.run.stoppedBy = 'replaced';
|
|
666
|
+
this.stop(other, `replaced by ${entry.run.id}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
this.dispatch();
|
|
670
|
+
}
|
|
671
|
+
// Stops a service run at its expiry, and puts one that is up to sleep at its sleepsAt. A
|
|
672
|
+
// timer can't wait longer than about 24 days, so a longer one looks again then.
|
|
673
|
+
expireLater(entry) {
|
|
674
|
+
this.clearTimer(entry);
|
|
675
|
+
const expiresAt = Date.parse(entry.run.expiresAt ?? '');
|
|
676
|
+
const sleepsAt = entry.run.state === 'up' ? Date.parse(entry.run.sleepsAt ?? '') : NaN;
|
|
677
|
+
const next = Number.isFinite(sleepsAt) ? Math.min(expiresAt, sleepsAt) : expiresAt;
|
|
678
|
+
entry.timer = setTimeout(() => {
|
|
679
|
+
var _a;
|
|
680
|
+
if (isFinal(entry.run.state)) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (Date.parse(entry.run.expiresAt ?? '') <= this.now()) {
|
|
684
|
+
(_a = entry.run).stoppedBy ?? (_a.stoppedBy = 'ttl');
|
|
685
|
+
entry.sleeping = false;
|
|
686
|
+
if (entry.run.state === 'asleep') {
|
|
687
|
+
this.finish(entry, 'succeeded');
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
this.stop(entry);
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (entry.run.state === 'up' && Date.parse(entry.run.sleepsAt ?? '') <= this.now()) {
|
|
695
|
+
entry.sleeping = true;
|
|
696
|
+
entry.abort.abort();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
this.expireLater(entry);
|
|
700
|
+
}, Math.max(0, Math.min(next - this.now(), 2 ** 31 - 1)));
|
|
701
|
+
entry.timer.unref();
|
|
702
|
+
}
|
|
703
|
+
// Whether a service run sleeps instead of ending: it has a sleepTtl, its ttl has not
|
|
704
|
+
// ended, and it starts from a commit (the files of a sandbox change under it).
|
|
705
|
+
maySleep(entry) {
|
|
706
|
+
return (!!entry.run.up?.sleepTtl &&
|
|
707
|
+
!!entry.run.upAt &&
|
|
708
|
+
entry.run.source.type === 'ref' &&
|
|
709
|
+
Date.parse(entry.run.expiresAt ?? '') > this.now());
|
|
710
|
+
}
|
|
711
|
+
// The services stopped, the run waits for a request to wake it, or for its end.
|
|
712
|
+
sleep(entry) {
|
|
713
|
+
entry.sleeping = false;
|
|
714
|
+
entry.targets = undefined;
|
|
715
|
+
entry.run.state = 'asleep';
|
|
716
|
+
delete entry.run.sleepsAt;
|
|
717
|
+
this.record(entry, { type: 'state', state: 'asleep' });
|
|
718
|
+
this.expireLater(entry);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* A request to an endpoint of a service run: one that is up stays up for its sleepTtl from
|
|
722
|
+
* now, one that is asleep is started again (it holds its place of runs.up.limits while it
|
|
723
|
+
* sleeps), if `mayWake` finds its owner may still run it: else it ends. Answers why it was
|
|
724
|
+
* not woken, or undefined.
|
|
725
|
+
*/
|
|
726
|
+
visit(id, mayWake) {
|
|
727
|
+
const entry = this.entries.get(id);
|
|
728
|
+
if (!entry?.run.up) {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
const sleepTtl = entry.run.up.sleepTtl;
|
|
732
|
+
if (entry.run.state === 'up' && sleepTtl) {
|
|
733
|
+
// only kept in memory: a server that stops puts the run to sleep anyway
|
|
734
|
+
entry.run.sleepsAt = new Date(this.now() + sleepTtl).toISOString();
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
if (entry.run.state !== 'asleep' || this.closed) {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
if (!this.maySleep(entry)) {
|
|
741
|
+
this.expireLater(entry);
|
|
742
|
+
return 'its time ran out';
|
|
743
|
+
}
|
|
744
|
+
const refused = mayWake?.(this.copy(entry.run));
|
|
745
|
+
if (refused) {
|
|
746
|
+
this.finish(entry, 'failed', refused);
|
|
747
|
+
return refused;
|
|
748
|
+
}
|
|
749
|
+
this.clearTimer(entry);
|
|
750
|
+
entry.abort = new AbortController();
|
|
751
|
+
entry.released = false;
|
|
752
|
+
entry.timedOut = false;
|
|
753
|
+
entry.run.wakes = (entry.run.wakes ?? 0) + 1;
|
|
754
|
+
entry.run.state = 'queued';
|
|
755
|
+
this.record(entry, { type: 'state', state: 'queued' });
|
|
756
|
+
this.queue.push(entry);
|
|
757
|
+
this.dispatch();
|
|
758
|
+
return undefined;
|
|
759
|
+
}
|
|
760
|
+
// The end of a run that was up: stopped by somebody, its ttl or a newer run it succeeded,
|
|
761
|
+
// stopped by the server going away it failed, as its record says after a restart.
|
|
762
|
+
endServices(entry, failure) {
|
|
763
|
+
if (entry.timedOut) {
|
|
764
|
+
this.finish(entry, 'failed', this.tooLong());
|
|
765
|
+
}
|
|
766
|
+
else if (!entry.abort.signal.aborted) {
|
|
767
|
+
this.finish(entry, 'failed', failure);
|
|
768
|
+
}
|
|
769
|
+
else if (entry.run.stoppedBy) {
|
|
770
|
+
this.finish(entry, 'succeeded');
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
this.finish(entry, 'failed', 'the server stopped while the services were up, start them again');
|
|
470
774
|
}
|
|
471
775
|
}
|
|
472
776
|
tooLong() {
|
|
@@ -474,6 +778,8 @@ class RunRegistry {
|
|
|
474
778
|
}
|
|
475
779
|
finish(entry, state, error) {
|
|
476
780
|
this.clearTimer(entry);
|
|
781
|
+
clearTimeout(entry.durationTimer);
|
|
782
|
+
entry.targets = undefined;
|
|
477
783
|
entry.run.state = state;
|
|
478
784
|
entry.run.finishedAt = this.timestamp();
|
|
479
785
|
if (error) {
|
|
@@ -488,9 +794,23 @@ class RunRegistry {
|
|
|
488
794
|
}
|
|
489
795
|
this.record(entry, { type: 'state', state, ...(error ? { error } : {}) }, true);
|
|
490
796
|
entry.listeners.clear();
|
|
797
|
+
try {
|
|
798
|
+
this.options.onFinished?.(this.copy(entry.run), { ...entry.keys });
|
|
799
|
+
}
|
|
800
|
+
catch (e) {
|
|
801
|
+
this.options.log?.(`run ${entry.run.id} ended, but what it passed was not kept: ${e instanceof Error ? e.message : e}`);
|
|
802
|
+
}
|
|
491
803
|
this.evict();
|
|
492
804
|
}
|
|
493
805
|
record(entry, recorded, force = false) {
|
|
806
|
+
if (recorded.type === 'key') {
|
|
807
|
+
// a task name is the executor's; the first key it reports for a task is the one
|
|
808
|
+
// computed before anything of the run had run
|
|
809
|
+
if (!(recorded.task in entry.keys) && Object.keys(entry.keys).length < MAX_KEYED_TASKS) {
|
|
810
|
+
entry.keys[recorded.task] = recorded.key;
|
|
811
|
+
}
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
494
814
|
const event = recorded.type === 'task' ? bounded(recorded) : recorded;
|
|
495
815
|
if (event.type === 'log') {
|
|
496
816
|
this.keepTail(entry, event.task, event.text);
|
|
@@ -572,7 +892,7 @@ class RunRegistry {
|
|
|
572
892
|
}
|
|
573
893
|
// what a restart must not lose: the runs that wait, and how a run ended. A running
|
|
574
894
|
// run is not kept up to date, it fails with the server.
|
|
575
|
-
if (event.type === 'state' || event.type === 'approval') {
|
|
895
|
+
if (event.type === 'state' || event.type === 'approval' || event.type === 'extended') {
|
|
576
896
|
this.persist(entry);
|
|
577
897
|
}
|
|
578
898
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.sandboxPodMain = exports.decodePodError = exports.encodeEditOps = exports.INCOMING_REF = exports.INCOMING = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
4
5
|
const promises_1 = require("fs/promises");
|
|
5
6
|
const path_1 = require("path");
|
|
6
7
|
const url_1 = require("url");
|
|
7
8
|
const git_checkout_1 = require("./git-checkout");
|
|
8
9
|
const sandbox_files_1 = require("./sandbox-files");
|
|
9
10
|
const sandbox_storage_1 = require("./sandbox-storage");
|
|
11
|
+
const tar_1 = require("../utils/tar");
|
|
10
12
|
// On a cluster a sandbox is a claim, and what the server does with its files runs in a
|
|
11
13
|
// short pod that mounts it (see KubernetesSandboxStorage): `hammerkit server-sandbox
|
|
12
14
|
// <directory>` reads one request on its standard input, does it with the code of a host
|
|
@@ -91,6 +93,32 @@ async function answer(storage, directory, request, out) {
|
|
|
91
93
|
}
|
|
92
94
|
return send({ files: plan.files, bytes: plan.bytes });
|
|
93
95
|
}
|
|
96
|
+
case 'archive': {
|
|
97
|
+
// written beside the sandbox on the claim, sent, and removed
|
|
98
|
+
const into = (0, path_1.join)(directory, `${request.id}-archive-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
|
|
99
|
+
try {
|
|
100
|
+
await storage.exportTree(key, request.tree, into, AbortSignal.timeout(10 * 60000));
|
|
101
|
+
await write(out, JSON.stringify({ ok: true, result: null, stream: true }) + '\n');
|
|
102
|
+
const pack = (0, tar_1.packArchive)({ cwd: into, portable: true }, ['.']);
|
|
103
|
+
try {
|
|
104
|
+
for await (const chunk of pack) {
|
|
105
|
+
await write(out, chunk);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
throw new AnswerStarted(e instanceof Error ? e.message : String(e));
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
// a reader that went away (over its size limit) leaves the pack reading files
|
|
113
|
+
// that are removed next: what fails then is no answer of anybody's
|
|
114
|
+
pack.on('error', () => undefined);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await (0, promises_1.rm)(into, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
94
122
|
case 'raw': {
|
|
95
123
|
const raw = await files().raw(request.path);
|
|
96
124
|
const header = {
|
|
@@ -276,6 +276,29 @@ class HostSandboxStorage {
|
|
|
276
276
|
// files change, and what it wrote to the git data is pruned again. Files a task made
|
|
277
277
|
// unreadable are made readable first: git would leave them out and call the sandbox
|
|
278
278
|
// clean.
|
|
279
|
+
async exportTree({ id }, tree, into, signal) {
|
|
280
|
+
if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(tree)) {
|
|
281
|
+
throw new Error(`${JSON.stringify(tree.slice(0, 80))} is not a git tree`);
|
|
282
|
+
}
|
|
283
|
+
return this.withIndex(id, signal, async (run) => {
|
|
284
|
+
const now = (await run(['write-tree'], 'read')).trim();
|
|
285
|
+
if (now !== tree) {
|
|
286
|
+
throw new Error(`the files of the sandbox ${id} are ${now} now, not ${tree}`);
|
|
287
|
+
}
|
|
288
|
+
// an index of its own holds the tree alone, whatever the sandbox's index says of its
|
|
289
|
+
// entries; the files are written as git has them, through no filter of the server's
|
|
290
|
+
const index = (0, path_1.join)(this.root(id), `export-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
|
|
291
|
+
await (0, promises_1.mkdir)(into, { recursive: true });
|
|
292
|
+
try {
|
|
293
|
+
const env = this.readEnv({ GIT_INDEX_FILE: index });
|
|
294
|
+
await this.git(id, ['read-tree', tree], env, signal, 'read');
|
|
295
|
+
await this.git(id, ['checkout-index', '--all', '--force', `--prefix=${(0, path_1.join)(into, '/')}`], env, signal, 'read');
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
await (0, promises_1.rm)(index, { force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
279
302
|
async state({ id }, signal = AbortSignal.timeout(5 * 60 * 1000)) {
|
|
280
303
|
return this.withIndex(id, signal, async (run) => {
|
|
281
304
|
const tree = (await run(['write-tree'], 'read')).trim();
|
|
@@ -175,7 +175,8 @@ function parse(schema, body) {
|
|
|
175
175
|
/**
|
|
176
176
|
* Who the commit of a push is by: the caller, as their token names them (`name` and
|
|
177
177
|
* `email`, unless the issuer says the address is not verified), else by their subject at the
|
|
178
|
-
* host of their issuer.
|
|
178
|
+
* host of their issuer. Without a name, the local part of the address names them, then the
|
|
179
|
+
* subject. Never what the request says, so a commit can not claim to be
|
|
179
180
|
* somebody else's.
|
|
180
181
|
*/
|
|
181
182
|
function author(principal) {
|
|
@@ -183,11 +184,16 @@ function author(principal) {
|
|
|
183
184
|
.replace(/[<>\0\r\n]/g, '')
|
|
184
185
|
.trim()
|
|
185
186
|
.slice(0, 200);
|
|
186
|
-
const name = clean(principal.claims.name) || clean(principal.claims.preferred_username) || clean(principal.sub);
|
|
187
187
|
// an address the issuer says it did not verify is whatever the caller typed in
|
|
188
188
|
const claimed = principal.emailVerified === false ? '' : clean(principal.claims.email);
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
const email = /^[^\s@]+@[^\s@]+$/.test(claimed) ? claimed : '';
|
|
190
|
+
// a Google service account's token has an address but no name: its local part reads better than the number in sub
|
|
191
|
+
const name = clean(principal.claims.name) ||
|
|
192
|
+
clean(principal.claims.preferred_username) ||
|
|
193
|
+
email.slice(0, email.indexOf('@')) ||
|
|
194
|
+
clean(principal.sub);
|
|
195
|
+
if (email) {
|
|
196
|
+
return { name, email };
|
|
191
197
|
}
|
|
192
198
|
let host = 'hammerkit';
|
|
193
199
|
try {
|