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
package/dist/server/server.js
CHANGED
|
@@ -13,7 +13,9 @@ const runs_1 = require("./runs");
|
|
|
13
13
|
const git_checkout_1 = require("./git-checkout");
|
|
14
14
|
const repo_access_1 = require("./repo-access");
|
|
15
15
|
const executor_1 = require("./executor");
|
|
16
|
+
const kubernetes_backend_1 = require("./kubernetes-backend");
|
|
16
17
|
const run_plan_1 = require("./run-plan");
|
|
18
|
+
const work_network_1 = require("../planner/work-network");
|
|
17
19
|
const run_environment_1 = require("./run-environment");
|
|
18
20
|
const accounts_api_1 = require("./accounts-api");
|
|
19
21
|
const run_request_1 = require("./run-request");
|
|
@@ -25,7 +27,13 @@ const proposals_api_1 = require("./proposals-api");
|
|
|
25
27
|
const proposals_1 = require("./proposals");
|
|
26
28
|
const sources_1 = require("./sources");
|
|
27
29
|
const sources_api_1 = require("./sources-api");
|
|
30
|
+
const sandboxes_1 = require("./sandboxes");
|
|
31
|
+
const sandbox_storage_1 = require("./sandbox-storage");
|
|
32
|
+
const kubernetes_sandbox_storage_1 = require("./kubernetes-sandbox-storage");
|
|
33
|
+
const sandbox_docker_1 = require("./sandbox-docker");
|
|
34
|
+
const sandboxes_api_1 = require("./sandboxes-api");
|
|
28
35
|
const units_1 = require("../utils/units");
|
|
36
|
+
const run_logs_1 = require("./run-logs");
|
|
29
37
|
const get_cache_directory_1 = require("../optimizer/get-cache-directory");
|
|
30
38
|
const path_1 = require("path");
|
|
31
39
|
var discovery_2 = require("./discovery");
|
|
@@ -42,11 +50,20 @@ function getDiscoveryDocument(config) {
|
|
|
42
50
|
...(i.clientId ? { clientId: i.clientId } : {}),
|
|
43
51
|
scope: i.scope,
|
|
44
52
|
})),
|
|
45
|
-
backend:
|
|
53
|
+
backend: config.backend.type === 'kubernetes'
|
|
54
|
+
? {
|
|
55
|
+
type: 'kubernetes',
|
|
56
|
+
platform: config.backend.pools[0].platform,
|
|
57
|
+
pools: config.backend.pools.map((pool) => ({ name: pool.name, platform: pool.platform })),
|
|
58
|
+
}
|
|
59
|
+
: { type: 'host', platform: config.backend.platform ?? (0, server_config_1.detectPlatform)() },
|
|
46
60
|
};
|
|
47
61
|
}
|
|
48
62
|
exports.getDiscoveryDocument = getDiscoveryDocument;
|
|
49
63
|
const HEARTBEAT_MS = 15000;
|
|
64
|
+
// the longest `?wait=` of a run: below the 60 seconds after which a proxy in front of the
|
|
65
|
+
// server (nginx by default) closes a connection that says nothing
|
|
66
|
+
const MAX_WAIT_MS = 50000;
|
|
50
67
|
// Server-sent events: `id` is the sequence number, so a client that lost the
|
|
51
68
|
// connection continues with Last-Event-ID (or ?after=) and misses nothing.
|
|
52
69
|
function streamEvents(registry, config, principal, id, req, res, url) {
|
|
@@ -79,8 +96,8 @@ function streamEvents(registry, config, principal, id, req, res, url) {
|
|
|
79
96
|
watch.stop();
|
|
80
97
|
});
|
|
81
98
|
}
|
|
82
|
-
async function handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url) {
|
|
83
|
-
const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events))?)?$/.exec(url.pathname);
|
|
99
|
+
async function handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url) {
|
|
100
|
+
const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events|logs))?)?$/.exec(url.pathname);
|
|
84
101
|
if (!match) {
|
|
85
102
|
return false;
|
|
86
103
|
}
|
|
@@ -88,10 +105,17 @@ async function handleRuns(config, registry, definitions, sources, planning, repo
|
|
|
88
105
|
const method = req.method;
|
|
89
106
|
if (!id) {
|
|
90
107
|
if (method === 'GET') {
|
|
91
|
-
|
|
108
|
+
// the results of each task are read one run at a time
|
|
109
|
+
(0, http_2.send)(res, 200, {
|
|
110
|
+
runs: registry.list(principal).map((run) => {
|
|
111
|
+
const { results, ...listed } = toRunResponse(run);
|
|
112
|
+
void results;
|
|
113
|
+
return listed;
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
92
116
|
}
|
|
93
117
|
else if (method === 'POST') {
|
|
94
|
-
await submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res);
|
|
118
|
+
await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res);
|
|
95
119
|
}
|
|
96
120
|
else {
|
|
97
121
|
(0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
|
|
@@ -116,9 +140,39 @@ async function handleRuns(config, registry, definitions, sources, planning, repo
|
|
|
116
140
|
else if (action === 'events') {
|
|
117
141
|
streamEvents(registry, config, principal, id, req, res, url);
|
|
118
142
|
}
|
|
143
|
+
else if (action === 'logs') {
|
|
144
|
+
let query;
|
|
145
|
+
try {
|
|
146
|
+
query = (0, run_logs_1.parseLogQuery)(url.searchParams);
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
(0, http_2.send)(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
const logs = registry.logs(principal, id, accountsOf(config, principal));
|
|
153
|
+
logs
|
|
154
|
+
? (0, http_2.send)(res, 200, (0, run_logs_1.readLogs)(logs.events, logs.index, query, logs.truncated))
|
|
155
|
+
: (0, http_2.send)(res, 404, { error: 'not found' });
|
|
156
|
+
}
|
|
119
157
|
else {
|
|
120
|
-
|
|
121
|
-
|
|
158
|
+
let wait = 0;
|
|
159
|
+
const waitParam = url.searchParams.get('wait');
|
|
160
|
+
if (waitParam !== null) {
|
|
161
|
+
try {
|
|
162
|
+
wait = Math.min((0, units_1.parseDuration)(waitParam), MAX_WAIT_MS);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
(0, http_2.send)(res, 400, { error: `wait is a duration up to ${(0, units_1.formatDuration)(MAX_WAIT_MS)}, for example 30s` });
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// a caller that hangs up stops waiting
|
|
170
|
+
const gone = new AbortController();
|
|
171
|
+
res.on('close', () => gone.abort());
|
|
172
|
+
const run = await registry.wait(principal, id, wait, accountsOf(config, principal), gone.signal);
|
|
173
|
+
if (!gone.signal.aborted) {
|
|
174
|
+
run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
|
|
175
|
+
}
|
|
122
176
|
}
|
|
123
177
|
return true;
|
|
124
178
|
}
|
|
@@ -130,9 +184,9 @@ function accountsOf(config, principal) {
|
|
|
130
184
|
function toRunResponse(run) {
|
|
131
185
|
const { owner, ...visible } = run;
|
|
132
186
|
void owner;
|
|
133
|
-
return visible;
|
|
187
|
+
return { ...visible, results: run.results ?? {} };
|
|
134
188
|
}
|
|
135
|
-
async function submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res) {
|
|
189
|
+
async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res) {
|
|
136
190
|
const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
|
|
137
191
|
if (!parsed.success) {
|
|
138
192
|
const issue = parsed.error.issues[0];
|
|
@@ -147,21 +201,26 @@ async function submitRun(config, registry, definitions, sources, planning, repos
|
|
|
147
201
|
(0, http_2.send)(res, 403, { error: 'you are not entitled to run anything on this server' });
|
|
148
202
|
return;
|
|
149
203
|
}
|
|
150
|
-
const version = definitions.find(request.definition
|
|
204
|
+
const version = definitions.find(request.definition);
|
|
151
205
|
if (!version) {
|
|
152
|
-
(0, http_2.send)(res, 400, { error: `there is no build file ${request.definition
|
|
206
|
+
(0, http_2.send)(res, 400, { error: `there is no published build file ${request.definition}` });
|
|
153
207
|
return;
|
|
154
208
|
}
|
|
155
209
|
if (version.yanked) {
|
|
156
210
|
(0, http_2.send)(res, 409, {
|
|
157
|
-
error: `${version.
|
|
211
|
+
error: `${version.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`,
|
|
158
212
|
});
|
|
159
213
|
return;
|
|
160
214
|
}
|
|
161
215
|
const known = [...version.tasks, ...version.services];
|
|
162
216
|
const unknown = request.tasks.filter((task) => !known.includes(task));
|
|
163
217
|
if (unknown.length > 0) {
|
|
164
|
-
(0, http_2.send)(res, 400, { error: `${version.
|
|
218
|
+
(0, http_2.send)(res, 400, { error: `${version.hash} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const argued = (request.options.args ?? []).length > 0 ? request.tasks[0] : undefined;
|
|
222
|
+
if (argued !== undefined && version.services.includes(argued)) {
|
|
223
|
+
(0, http_2.send)(res, 400, { error: `${argued} is a service, arguments go to a task` });
|
|
165
224
|
return;
|
|
166
225
|
}
|
|
167
226
|
if (request.source.type === 'upload') {
|
|
@@ -180,29 +239,57 @@ async function submitRun(config, registry, definitions, sources, planning, repos
|
|
|
180
239
|
return;
|
|
181
240
|
}
|
|
182
241
|
}
|
|
242
|
+
const sandbox = request.source.type === 'sandbox' ? sandboxes.registry.get(principal, request.source.id) : undefined;
|
|
243
|
+
if (request.source.type === 'sandbox') {
|
|
244
|
+
if (!sandbox) {
|
|
245
|
+
(0, http_2.send)(res, 400, { error: `there is no sandbox ${request.source.id}, create one with POST /v1/sandboxes` });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (sandboxes.policy.runtimes?.length === 0) {
|
|
249
|
+
(0, http_2.send)(res, 403, {
|
|
250
|
+
error: 'this server runs only local tasks, which it runs on no sandbox unless sandboxes.runtimes names local',
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
await sandboxes.registry.measured(principal);
|
|
255
|
+
const over = sandboxes.registry.overLimit(principal);
|
|
256
|
+
if (over) {
|
|
257
|
+
(0, http_2.send)(res, 413, { error: over });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
183
261
|
// the accounts of a run are read from the files it is pinned to, with the engine
|
|
184
262
|
const bundle = version.plan ? await definitions.files(version) : undefined;
|
|
185
263
|
if (!version.plan || !bundle) {
|
|
186
264
|
(0, http_2.send)(res, 409, {
|
|
187
|
-
error: `${version.
|
|
265
|
+
error: `${version.hash} was published before accounts were recorded, publish it again`,
|
|
188
266
|
});
|
|
189
267
|
return;
|
|
190
268
|
}
|
|
191
269
|
let planned;
|
|
192
270
|
try {
|
|
193
|
-
|
|
271
|
+
// a run on a sandbox may only use the runtimes of `sandboxes.runtimes`
|
|
272
|
+
planned = await (0, run_plan_1.planRun)(bundle, request, planning.environment, sandbox ? sandboxes.policy : planning.policy);
|
|
194
273
|
}
|
|
195
274
|
catch (e) {
|
|
196
275
|
(0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
|
|
197
276
|
return;
|
|
198
277
|
}
|
|
278
|
+
const refused = (0, run_plan_1.refuseArguments)(request, planned.plan, sandbox ? sandboxes.policy : planning.policy);
|
|
279
|
+
if (refused) {
|
|
280
|
+
log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${refused}`);
|
|
281
|
+
(0, http_2.send)(res, 400, { error: refused });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
199
284
|
const decision = (0, policy_1.decideRun)({
|
|
200
285
|
grant,
|
|
201
286
|
accounts: planned.accounts,
|
|
202
287
|
catalog: config.accounts,
|
|
203
288
|
source: request.source.type === 'ref'
|
|
204
289
|
? { type: 'ref', repo: request.source.repo, ref: request.source.ref }
|
|
205
|
-
:
|
|
290
|
+
: sandbox
|
|
291
|
+
? { type: 'sandbox', repo: sandbox.repo, branch: sandbox.ref }
|
|
292
|
+
: { type: 'upload' },
|
|
206
293
|
approvable: (account) => config.entitlements.some((entitlement) => entitlement.accounts[account] === 'use'),
|
|
207
294
|
readable: (repo) => repos.readable(repo),
|
|
208
295
|
});
|
|
@@ -217,6 +304,12 @@ async function submitRun(config, registry, definitions, sources, planning, repos
|
|
|
217
304
|
if (request.source.type === 'ref') {
|
|
218
305
|
try {
|
|
219
306
|
const commit = await repos.resolve(request.source.repo, request.source.ref);
|
|
307
|
+
if (request.source.commit && commit !== request.source.commit) {
|
|
308
|
+
const error = `${request.source.ref} is at ${commit} now, not at ${request.source.commit}`;
|
|
309
|
+
log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${error}`);
|
|
310
|
+
(0, http_2.send)(res, 409, { error });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
220
313
|
source = { type: 'ref', repo: request.source.repo, ref: request.source.ref, commit };
|
|
221
314
|
}
|
|
222
315
|
catch (e) {
|
|
@@ -228,19 +321,34 @@ async function submitRun(config, registry, definitions, sources, planning, repos
|
|
|
228
321
|
return;
|
|
229
322
|
}
|
|
230
323
|
}
|
|
231
|
-
else {
|
|
324
|
+
else if (request.source.type === 'upload') {
|
|
232
325
|
source = request.source;
|
|
233
326
|
}
|
|
327
|
+
const submit = (runSource) => registry.submit(principal, {
|
|
328
|
+
definition: version.hash,
|
|
329
|
+
tasks: request.tasks,
|
|
330
|
+
accounts: planned.accounts,
|
|
331
|
+
plan: planned.plan,
|
|
332
|
+
source: runSource,
|
|
333
|
+
options: request.options,
|
|
334
|
+
}, approvals);
|
|
234
335
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
336
|
+
// a sandbox is read and the run submitted while nothing else changes it: the run
|
|
337
|
+
// records what the sandbox held, and holds it from now on
|
|
338
|
+
const run = source
|
|
339
|
+
? submit(source)
|
|
340
|
+
: await sandboxes.registry.withSandbox(principal, sandbox.id, async (current) => {
|
|
341
|
+
const state = await sandboxes.registry.state(current.id);
|
|
342
|
+
return submit({
|
|
343
|
+
type: 'sandbox',
|
|
344
|
+
id: current.id,
|
|
345
|
+
repo: current.repo,
|
|
346
|
+
commit: state.commit,
|
|
347
|
+
tree: state.tree,
|
|
348
|
+
clean: state.clean,
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type}${run.source.type === 'sandbox' ? ` sandbox=${run.source.id}` : ''} tasks=${run.tasks.length}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
|
|
244
352
|
(0, http_2.send)(res, 202, toRunResponse(run));
|
|
245
353
|
}
|
|
246
354
|
catch (e) {
|
|
@@ -248,6 +356,12 @@ async function submitRun(config, registry, definitions, sources, planning, repos
|
|
|
248
356
|
(0, http_2.send)(res, e.status, { error: e.message });
|
|
249
357
|
return;
|
|
250
358
|
}
|
|
359
|
+
if (e instanceof sandboxes_1.SandboxError) {
|
|
360
|
+
(0, http_2.send)(res, e.status === 404 ? 400 : e.status, {
|
|
361
|
+
error: e.status === 404 ? `there is no sandbox ${sandbox.id}` : e.message,
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
251
365
|
throw e;
|
|
252
366
|
}
|
|
253
367
|
}
|
|
@@ -271,7 +385,7 @@ async function authenticate(verifier, log, req, res) {
|
|
|
271
385
|
return null;
|
|
272
386
|
}
|
|
273
387
|
}
|
|
274
|
-
async function handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res) {
|
|
388
|
+
async function handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res) {
|
|
275
389
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
276
390
|
const path = url.pathname;
|
|
277
391
|
if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
|
|
@@ -295,7 +409,10 @@ async function handle(config, registry, definitions, proposals, sources, plannin
|
|
|
295
409
|
if (await (0, sources_api_1.handleSources)(config, sources, log, principal, req, res, url)) {
|
|
296
410
|
return;
|
|
297
411
|
}
|
|
298
|
-
if (await
|
|
412
|
+
if (await (0, sandboxes_api_1.handleSandboxes)(config, sandboxes.registry, repos, log, principal, req, res, url)) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url)) {
|
|
299
416
|
return;
|
|
300
417
|
}
|
|
301
418
|
if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
|
|
@@ -315,7 +432,7 @@ async function startServer(config, environment, options = {}) {
|
|
|
315
432
|
const write = options.log ?? (() => undefined);
|
|
316
433
|
// request urls and subjects come from callers: no control characters, so no forged log lines
|
|
317
434
|
const log = (line) => write(line.replace(/\p{Cc}/gu, '?').slice(0, 1000));
|
|
318
|
-
if (config.runtimes.includes('local')) {
|
|
435
|
+
if (config.runtimes.includes('local') && config.backend.type === 'host') {
|
|
319
436
|
log('warning: the local runtime is allowed, local tasks run as the user of this server and read all it can read; ' +
|
|
320
437
|
'an account is then an approval class, not a credential boundary');
|
|
321
438
|
}
|
|
@@ -325,9 +442,23 @@ async function startServer(config, environment, options = {}) {
|
|
|
325
442
|
environment: {
|
|
326
443
|
...environment,
|
|
327
444
|
processEnvs: (0, run_environment_1.runEnvironment)(config, environment.processEnvs),
|
|
328
|
-
|
|
445
|
+
// on a cluster a local task runs in a pod of a pool's tool image
|
|
446
|
+
hosted: { catalog: (0, server_config_1.getSecretCatalog)(config), localTasksInContainers: config.backend.type === 'kubernetes' },
|
|
447
|
+
},
|
|
448
|
+
policy: {
|
|
449
|
+
envs: config.runs.env.allow,
|
|
450
|
+
reserved: (0, server_config_1.getSourceAccounts)(config),
|
|
451
|
+
runtimes: config.runtimes,
|
|
452
|
+
backend: config.backend.type,
|
|
453
|
+
network: (0, work_network_1.parseWorkNetwork)(config.runs.network),
|
|
454
|
+
secretNetwork: (0, work_network_1.parseWorkNetwork)(config.runs.secretNetwork),
|
|
455
|
+
args: config.runs.args,
|
|
456
|
+
proxiedProviders: config.backend.type === 'kubernetes'
|
|
457
|
+
? Object.entries(config.backend.providers)
|
|
458
|
+
.filter(([, provider]) => provider.allow)
|
|
459
|
+
.map(([name]) => name)
|
|
460
|
+
: [],
|
|
329
461
|
},
|
|
330
|
-
policy: { envs: config.runs.env.allow, reserved: (0, server_config_1.getSourceAccounts)(config), runtimes: config.runtimes },
|
|
331
462
|
};
|
|
332
463
|
const storage = config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server');
|
|
333
464
|
const definitions = new definitions_1.DefinitionStore((0, path_1.join)(storage, 'definitions'), planning.environment, {
|
|
@@ -335,15 +466,59 @@ async function startServer(config, environment, options = {}) {
|
|
|
335
466
|
});
|
|
336
467
|
await definitions.load();
|
|
337
468
|
const repos = new repo_access_1.RepoAccess(config, planning.environment, options.repos);
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
469
|
+
const sandboxRuntimes = (0, server_config_1.getSandboxRuntimes)(config);
|
|
470
|
+
const sandboxLimits = {
|
|
471
|
+
count: config.sandboxes.limits.count,
|
|
472
|
+
bytes: (0, units_1.parseSize)(config.sandboxes.limits.bytes),
|
|
473
|
+
total: (0, units_1.parseSize)(config.sandboxes.total),
|
|
474
|
+
};
|
|
475
|
+
const sandboxes = {
|
|
476
|
+
registry: new sandboxes_1.SandboxRegistry({
|
|
477
|
+
// on a cluster a claim per sandbox in the caller's namespace, else a directory
|
|
478
|
+
storage: config.backend.type === 'kubernetes'
|
|
479
|
+
? new kubernetes_sandbox_storage_1.KubernetesSandboxStorage({ ...config, backend: config.backend }, sandboxLimits, planning.environment.processEnvs, (0, path_1.join)(storage, 'sandboxes', 'staging'))
|
|
480
|
+
: new sandbox_storage_1.HostSandboxStorage((0, path_1.join)(storage, 'sandboxes', 'files'), planning.environment.processEnvs,
|
|
481
|
+
// what containers of runs leave behind is given back and removed with a container
|
|
482
|
+
sandboxRuntimes.includes('docker')
|
|
483
|
+
? new sandbox_docker_1.DockerSandboxContainers(planning.environment.processEnvs.DOCKER_HOST)
|
|
484
|
+
: undefined),
|
|
485
|
+
records: (0, path_1.join)(storage, 'sandboxes', 'records'),
|
|
486
|
+
limits: sandboxLimits,
|
|
342
487
|
repos,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
488
|
+
log,
|
|
489
|
+
}),
|
|
490
|
+
// a sandbox holds code nobody reviewed: only the variables of `sandboxes.env`
|
|
491
|
+
policy: {
|
|
492
|
+
...planning.policy,
|
|
493
|
+
envs: config.sandboxes.env.allow,
|
|
494
|
+
envsFrom: 'sandboxes.env.allow',
|
|
495
|
+
runtimes: sandboxRuntimes,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
if (sandboxRuntimes.length === 0 && config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
|
|
499
|
+
log('warning: entitlements keep sandboxes, but this server runs only local tasks, which it runs on no sandbox; ' +
|
|
500
|
+
'runs on sandboxes are refused until sandboxes.runtimes names a runtime');
|
|
501
|
+
}
|
|
502
|
+
// on a cluster that lists the claims of sandboxes in the namespaces of callers, which a
|
|
503
|
+
// server without sandboxes need not be allowed to
|
|
504
|
+
if (config.backend.type === 'host' || config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
|
|
505
|
+
await sandboxes.registry.load();
|
|
506
|
+
}
|
|
507
|
+
const executorOptions = {
|
|
508
|
+
definitions,
|
|
509
|
+
planning,
|
|
510
|
+
repos,
|
|
511
|
+
directory: (0, path_1.join)(storage, 'runs'),
|
|
512
|
+
cache: (0, path_1.join)(storage, 'cache'),
|
|
513
|
+
worker: options.worker,
|
|
514
|
+
sandboxes: { ...sandboxes, network: config.sandboxes.network },
|
|
515
|
+
log,
|
|
516
|
+
};
|
|
517
|
+
const backend = config.backend;
|
|
518
|
+
const executor = options.executor ??
|
|
519
|
+
(backend.type === 'kubernetes'
|
|
520
|
+
? new kubernetes_backend_1.KubernetesExecutor({ ...config, backend }, environment.processEnvs, executorOptions)
|
|
521
|
+
: new executor_1.HostExecutor(executorOptions));
|
|
347
522
|
const sources = new sources_1.SourceStore((0, path_1.join)(storage, 'sources'));
|
|
348
523
|
const registry = new runs_1.RunRegistry({
|
|
349
524
|
executor,
|
|
@@ -358,13 +533,17 @@ async function startServer(config, environment, options = {}) {
|
|
|
358
533
|
log,
|
|
359
534
|
});
|
|
360
535
|
await registry.restore();
|
|
536
|
+
sandboxes.registry.useRuns({
|
|
537
|
+
holder: (id) => registry.holder(id),
|
|
538
|
+
stop: (id) => registry.stopSandbox(id, 'the sandbox was deleted'),
|
|
539
|
+
});
|
|
361
540
|
const proposals = new proposals_1.ProposalRegistry({
|
|
362
541
|
maxWaiting: config.approvals.maxWaiting,
|
|
363
542
|
timeout: (0, units_1.parseDuration)(config.approvals.timeout),
|
|
364
543
|
cooldown: (0, units_1.parseDuration)(config.approvals.cooldown),
|
|
365
544
|
});
|
|
366
545
|
const listener = (req, res) => {
|
|
367
|
-
handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res).catch((e) => {
|
|
546
|
+
handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res).catch((e) => {
|
|
368
547
|
if (e instanceof http_2.HttpError) {
|
|
369
548
|
(0, http_2.send)(res, e.status, { error: e.message });
|
|
370
549
|
return;
|
|
@@ -1,17 +1,57 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
|
|
3
|
+
exports.takeOverSignal = exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
|
|
4
4
|
// Ctrl-C (SIGINT), a CI job being cancelled or a supervisor stopping hammerkit
|
|
5
5
|
// (SIGTERM) and a closed terminal (SIGHUP) all stop the run the same way: abort,
|
|
6
6
|
// so services are removed and local tasks' process groups are killed. Without a
|
|
7
7
|
// handler Node exits on SIGTERM/SIGHUP at once and leaves both behind.
|
|
8
8
|
exports.ABORT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// signals a running command gives a meaning of its own (the server reloads its
|
|
10
|
+
// entitlements on SIGHUP), per source and counted by how many hold them: they don't
|
|
11
|
+
// abort the run
|
|
12
|
+
const takenOver = new WeakMap();
|
|
13
|
+
function heldOn(source) {
|
|
14
|
+
let held = takenOver.get(source);
|
|
15
|
+
if (!held) {
|
|
16
|
+
held = new Map();
|
|
17
|
+
takenOver.set(source, held);
|
|
14
18
|
}
|
|
19
|
+
return held;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Stops the run on the abort signals, all but those a command took over with
|
|
23
|
+
* `takeOverSignal`. Returns what removes the listeners again.
|
|
24
|
+
*/
|
|
25
|
+
function abortOnSignals(source, abortCtrl) {
|
|
26
|
+
const listeners = exports.ABORT_SIGNALS.map((signal) => {
|
|
27
|
+
const listener = () => {
|
|
28
|
+
if (!takenOver.get(source)?.get(signal)) {
|
|
29
|
+
abortCtrl.abort();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
source.on(signal, listener);
|
|
33
|
+
return { signal, listener };
|
|
34
|
+
});
|
|
35
|
+
return () => listeners.forEach(({ signal, listener }) => source.off(signal, listener));
|
|
15
36
|
}
|
|
16
37
|
exports.abortOnSignals = abortOnSignals;
|
|
38
|
+
/**
|
|
39
|
+
* Calls `handler` on `signal` instead of stopping the run, until the returned function
|
|
40
|
+
* gives the signal back.
|
|
41
|
+
*/
|
|
42
|
+
function takeOverSignal(source, signal, handler) {
|
|
43
|
+
const held = heldOn(source);
|
|
44
|
+
held.set(signal, (held.get(signal) ?? 0) + 1);
|
|
45
|
+
source.on(signal, handler);
|
|
46
|
+
let released = false;
|
|
47
|
+
return () => {
|
|
48
|
+
if (released) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
released = true;
|
|
52
|
+
source.off(signal, handler);
|
|
53
|
+
held.set(signal, (held.get(signal) ?? 1) - 1);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
exports.takeOverSignal = takeOverSignal;
|
|
17
57
|
//# sourceMappingURL=abort-on-signals.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getRunId = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const runId = (0, crypto_1.randomBytes)(4).toString('hex');
|
|
6
|
+
// Tells this process's run apart from a concurrent one of the same items, on
|
|
7
|
+
// this machine or another one using the same daemon or cluster.
|
|
8
|
+
function getRunId() {
|
|
9
|
+
return runId;
|
|
10
|
+
}
|
|
11
|
+
exports.getRunId = getRunId;
|
|
12
|
+
//# sourceMappingURL=run-id.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.packArchive = void 0;
|
|
4
|
+
const tar_1 = require("tar");
|
|
5
|
+
function packArchive(options, files) {
|
|
6
|
+
return (0, tar_1.create)({ ...options, jobs: 1, linkCache: new ExactLinkCache() }, files);
|
|
7
|
+
}
|
|
8
|
+
exports.packArchive = packArchive;
|
|
9
|
+
// node-tar packs a file with more than one link as a link to the first file it packed with
|
|
10
|
+
// the same `dev:ino`. On Windows a file id has 64 bits, and Node gives it as a number, so
|
|
11
|
+
// ids above 2^53 are rounded and files of two link groups can get the same one. Packed as
|
|
12
|
+
// a link, a file would be restored with the other file's content. This cache leaves out the
|
|
13
|
+
// ids that may be rounded, so those files are packed in full.
|
|
14
|
+
class ExactLinkCache extends Map {
|
|
15
|
+
get(key) {
|
|
16
|
+
return isExact(key) ? super.get(key) : undefined;
|
|
17
|
+
}
|
|
18
|
+
set(key, path) {
|
|
19
|
+
return isExact(key) ? super.set(key, path) : this;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isExact(key) {
|
|
23
|
+
return key.split(':').every((part) => Number.isSafeInteger(Number(part)));
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=tar.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The hammerkit plugin for opencode 2, and for opencode 1 by its `server`, re-exported by a plugin
|
|
2
|
+
// file of the project: `export { default } from 'hammerkit/harness/opencode'`, see
|
|
3
|
+
// https://hammerkit.dev/docs/remote-compute/agents
|
|
4
|
+
import { hammerkitOpencodeEntry } from '../dist/harness/opencode'
|
|
5
|
+
|
|
6
|
+
const plugin = hammerkitOpencodeEntry((specifier) => import(specifier))
|
|
7
|
+
|
|
8
|
+
export default plugin
|
|
9
|
+
|
|
10
|
+
// the name plugin files of opencode 1 re-exported, the same plugin so opencode 1 loads it once
|
|
11
|
+
export const HammerkitPlugin = plugin
|
package/harness/pi.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The hammerkit extension for pi: `pi install npm:hammerkit`. pi loads this file with its
|
|
2
|
+
// own module, whose tools hammerkit gives a sandbox as their backend, see
|
|
3
|
+
// https://hammerkit.dev/docs/remote-compute/agents
|
|
4
|
+
import * as pi from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { hammerkitPiExtension } from '../dist/harness/pi'
|
|
6
|
+
|
|
7
|
+
export default hammerkitPiExtension(pi)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hammerkit",
|
|
3
3
|
"description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.27.0-next.10",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"build",
|
|
7
7
|
"build-tool",
|
|
@@ -19,8 +19,14 @@
|
|
|
19
19
|
"!dist/**/*.spec.*",
|
|
20
20
|
"!dist/testing",
|
|
21
21
|
"!dist/**/*.map",
|
|
22
|
-
"build.schema.json"
|
|
22
|
+
"build.schema.json",
|
|
23
|
+
"harness"
|
|
23
24
|
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./harness/pi.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
24
30
|
"bugs": {
|
|
25
31
|
"url": "https://github.com/no0dles/hammerkit/issues"
|
|
26
32
|
},
|