hammerkit 1.27.0-next.14 → 1.27.0-next.15
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/dist/docker/item-network.js +13 -5
- package/dist/executer/docker-service.js +30 -8
- package/dist/harness/mcp.js +10 -0
- package/dist/harness/opencode.js +22 -0
- package/dist/harness/pi.js +14 -0
- package/dist/harness/sandbox-client.js +55 -1
- package/dist/harness/service-tools.js +141 -0
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/remote/remote-work-commands.js +98 -12
- package/dist/schema/work-scope-parser.js +6 -0
- package/dist/server/accounts-api.js +2 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +14 -1
- package/dist/server/kubernetes-backend.js +200 -33
- package/dist/server/policy.js +24 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +7 -3
- package/dist/server/run-request.js +44 -2
- package/dist/server/run-worker.js +130 -9
- package/dist/server/runs.js +317 -24
- package/dist/server/server-config.js +160 -9
- package/dist/server/server.js +210 -14
- package/dist/server/service-runs.js +82 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.removeOrphanedNetworks = exports.removeItemNetworks = exports.getNetworkHostConfig = exports.removeItemNetwork = exports.prepareItemNetwork = exports.checkNetworkLimitSupport = exports.getProxyNetworkName = exports.getRunNetworkName = exports.getItemNetworkName = void 0;
|
|
3
|
+
exports.removeOrphanedNetworks = exports.removeItemNetworks = exports.getNetworkHostConfig = exports.getPortBinding = exports.removeItemNetwork = exports.prepareItemNetwork = exports.checkNetworkLimitSupport = exports.getProxyNetworkName = exports.getRunNetworkName = exports.getItemNetworkName = void 0;
|
|
4
4
|
const readline_1 = require("readline");
|
|
5
5
|
const stream_1 = require("stream");
|
|
6
6
|
const work_service_1 = require("../planner/work-service");
|
|
@@ -258,7 +258,7 @@ options) {
|
|
|
258
258
|
await removeItemNetwork(docker, name);
|
|
259
259
|
throw e;
|
|
260
260
|
}
|
|
261
|
-
const publish = (options.publish ?? []).filter((port) =>
|
|
261
|
+
const publish = (options.publish ?? []).filter((port) => port.hostPort !== null);
|
|
262
262
|
return {
|
|
263
263
|
options: {
|
|
264
264
|
mode: name,
|
|
@@ -272,8 +272,9 @@ options) {
|
|
|
272
272
|
await docker.getNetwork(getProxyNetworkName(name)).connect({ Container: container.id });
|
|
273
273
|
}
|
|
274
274
|
if (publish.length > 0) {
|
|
275
|
-
await startPortRelay(docker, item, environment, name, container, publish, options);
|
|
275
|
+
return await startPortRelay(docker, item, environment, name, container, publish, options);
|
|
276
276
|
}
|
|
277
|
+
return undefined;
|
|
277
278
|
}
|
|
278
279
|
catch (e) {
|
|
279
280
|
await removeItemNetwork(docker, name);
|
|
@@ -374,12 +375,13 @@ async function startPortRelay(docker, item, environment, networkName, service, p
|
|
|
374
375
|
ExposedPorts: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, {}])),
|
|
375
376
|
HostConfig: {
|
|
376
377
|
...HELPER_HOST_CONFIG,
|
|
377
|
-
PortBindings: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, [
|
|
378
|
+
PortBindings: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, [getPortBinding(port)]])),
|
|
378
379
|
},
|
|
379
380
|
});
|
|
380
381
|
await docker.getNetwork(networkName).connect({ Container: container.id });
|
|
381
382
|
await container.start();
|
|
382
|
-
item.status.write('debug', `ports ${ports.map((port) => port.hostPort).join(', ')} published by a relay`);
|
|
383
|
+
item.status.write('debug', `ports ${ports.map((port) => port.hostPort || 'any').join(', ')} published by a relay`);
|
|
384
|
+
return container;
|
|
383
385
|
}
|
|
384
386
|
// The network, its proxy network, its proxy and relay, and every endpoint on
|
|
385
387
|
// them: what an earlier run left behind, or what the item used.
|
|
@@ -429,6 +431,12 @@ async function removeNetwork(docker, name) {
|
|
|
429
431
|
}
|
|
430
432
|
}
|
|
431
433
|
}
|
|
434
|
+
// A port of the host for a port of a container: a free one for 0, on one address of it
|
|
435
|
+
// when the port names one.
|
|
436
|
+
function getPortBinding(port) {
|
|
437
|
+
return { HostPort: port.hostPort ? `${port.hostPort}` : '', ...(port.hostIp ? { HostIp: port.hostIp } : {}) };
|
|
438
|
+
}
|
|
439
|
+
exports.getPortBinding = getPortBinding;
|
|
432
440
|
function getNetworkHostConfig(network) {
|
|
433
441
|
return {
|
|
434
442
|
...(network.mode ? { NetworkMode: network.mode } : {}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.dockerService = exports.buildServiceCreateOptions = void 0;
|
|
3
|
+
exports.dockerService = exports.buildServiceCreateOptions = exports.readPublishedPorts = void 0;
|
|
4
4
|
const work_service_1 = require("../planner/work-service");
|
|
5
5
|
const run_labels_1 = require("../docker/run-labels");
|
|
6
6
|
const service_hostname_1 = require("../planner/utils/service-hostname");
|
|
@@ -22,6 +22,28 @@ const work_healthcheck_1 = require("../planner/work-healthcheck");
|
|
|
22
22
|
const docker_resources_1 = require("./docker-resources");
|
|
23
23
|
const egress_proxy_1 = require("../network/egress-proxy");
|
|
24
24
|
const HEALTHCHECK_INTERVAL_MS = 1000;
|
|
25
|
+
// Where docker published the ports of a container (or of its relay), for those that asked
|
|
26
|
+
// for a free one.
|
|
27
|
+
async function readPublishedPorts(container, ports) {
|
|
28
|
+
const wanted = ports.filter((port) => port.hostPort === 0);
|
|
29
|
+
if (wanted.length === 0) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const bound = (await container.inspect()).NetworkSettings.Ports ?? {};
|
|
33
|
+
return wanted.flatMap((port) => {
|
|
34
|
+
const binding = bound[`${port.containerPort}/tcp`]?.find((candidate) => candidate.HostPort);
|
|
35
|
+
return binding
|
|
36
|
+
? [
|
|
37
|
+
{
|
|
38
|
+
containerPort: port.containerPort,
|
|
39
|
+
host: port.hostIp ?? (binding.HostIp && binding.HostIp !== '0.0.0.0' ? binding.HostIp : '127.0.0.1'),
|
|
40
|
+
port: Number(binding.HostPort),
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
: [];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
exports.readPublishedPorts = readPublishedPorts;
|
|
25
47
|
function buildServiceCreateOptions(item, options, network, secrets, resources = {}) {
|
|
26
48
|
const envs = (0, replace_env_variables_1.getEnvironmentVariables)(item.data.envs);
|
|
27
49
|
const command = (0, work_service_1.getServiceCommand)(item.data);
|
|
@@ -58,12 +80,9 @@ function buildServiceCreateOptions(item, options, network, secrets, resources =
|
|
|
58
80
|
// collide with another run on the same host. A limited service's are
|
|
59
81
|
// published by its relay.
|
|
60
82
|
PortBindings: options.publishPorts && !network.relayPorts
|
|
61
|
-
? item.data.ports
|
|
62
|
-
.filter((p) =>
|
|
63
|
-
.
|
|
64
|
-
map[`${port.containerPort}/tcp`] = [{ HostPort: `${port.hostPort}` }];
|
|
65
|
-
return map;
|
|
66
|
-
}, {})
|
|
83
|
+
? Object.fromEntries(item.data.ports
|
|
84
|
+
.filter((p) => p.hostPort !== null)
|
|
85
|
+
.map((port) => [`${port.containerPort}/tcp`, [(0, item_network_1.getPortBinding)(port)]]))
|
|
67
86
|
: {},
|
|
68
87
|
...resources,
|
|
69
88
|
},
|
|
@@ -90,16 +109,18 @@ async function dockerService(docker, item, options, environment) {
|
|
|
90
109
|
const resources = await (0, docker_resources_1.resolveDockerResources)(docker, item.data.resources, item.status);
|
|
91
110
|
await (0, execution_steps_1.refuseHostLinks)(item, environment);
|
|
92
111
|
container = await docker.createContainer(buildServiceCreateOptions(item, options, network.options, secrets, resources));
|
|
93
|
-
await network.connect(container);
|
|
112
|
+
const relay = await network.connect(container);
|
|
94
113
|
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
|
|
95
114
|
(0, stream_1.logStream)(item.status, stream);
|
|
96
115
|
await container.start();
|
|
116
|
+
const published = options.publishPorts ? await readPublishedPorts(relay ?? container, item.data.ports) : [];
|
|
97
117
|
if (!item.data.healthcheck || !options.waitForReady) {
|
|
98
118
|
options.state.set({
|
|
99
119
|
type: 'running',
|
|
100
120
|
dns: { containerId: container.id },
|
|
101
121
|
stateKey: options.stateKey,
|
|
102
122
|
remote: null,
|
|
123
|
+
...(published.length > 0 ? { published } : {}),
|
|
103
124
|
});
|
|
104
125
|
}
|
|
105
126
|
else {
|
|
@@ -125,6 +146,7 @@ async function dockerService(docker, item, options, environment) {
|
|
|
125
146
|
dns: { containerId: container.id },
|
|
126
147
|
stateKey: options.stateKey,
|
|
127
148
|
remote: null,
|
|
149
|
+
...(published.length > 0 ? { published } : {}),
|
|
128
150
|
});
|
|
129
151
|
}
|
|
130
152
|
}
|
package/dist/harness/mcp.js
CHANGED
|
@@ -5,6 +5,7 @@ const readline_1 = require("readline");
|
|
|
5
5
|
const format_1 = require("./format");
|
|
6
6
|
const sandbox_client_1 = require("./sandbox-client");
|
|
7
7
|
const task_tools_1 = require("./task-tools");
|
|
8
|
+
const service_tools_1 = require("./service-tools");
|
|
8
9
|
// the newest first; a client asking for an older one gets it
|
|
9
10
|
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
10
11
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
|
|
@@ -241,6 +242,15 @@ function createMcpTools(client, options = {}) {
|
|
|
241
242
|
return `pushed ${pushed.commit} to ${pushed.branch}`;
|
|
242
243
|
},
|
|
243
244
|
},
|
|
245
|
+
...service_tools_1.SERVICE_TOOLS.map((tool) => ({
|
|
246
|
+
name: tool.name,
|
|
247
|
+
description: tool.description,
|
|
248
|
+
inputSchema: (0, service_tools_1.serviceToolSchema)(tool),
|
|
249
|
+
annotations: { title: tool.title, readOnlyHint: tool.name === 'endpoint_get', openWorldHint: false },
|
|
250
|
+
async call(args, signal) {
|
|
251
|
+
return tool.call(await client(), args, signal);
|
|
252
|
+
},
|
|
253
|
+
})),
|
|
244
254
|
]);
|
|
245
255
|
}
|
|
246
256
|
exports.createMcpTools = createMcpTools;
|
package/dist/harness/opencode.js
CHANGED
|
@@ -10,6 +10,7 @@ const connect_1 = require("./connect");
|
|
|
10
10
|
const format_1 = require("./format");
|
|
11
11
|
const sandbox_client_1 = require("./sandbox-client");
|
|
12
12
|
const task_tools_1 = require("./task-tools");
|
|
13
|
+
const service_tools_1 = require("./service-tools");
|
|
13
14
|
const DEFAULT_READ_LINES = 2000;
|
|
14
15
|
const LIST_LIMIT = 100;
|
|
15
16
|
const SEARCH_LIMIT = 100;
|
|
@@ -66,6 +67,9 @@ exports.OPENCODE_TOOLS = [
|
|
|
66
67
|
'run_logs',
|
|
67
68
|
'push',
|
|
68
69
|
'list',
|
|
70
|
+
'services_up',
|
|
71
|
+
'endpoint_get',
|
|
72
|
+
'services_stop',
|
|
69
73
|
];
|
|
70
74
|
/**
|
|
71
75
|
* The plugin as the file `harness/opencode.ts` exports it: it takes the zod of opencode from
|
|
@@ -151,6 +155,9 @@ exports.OPENCODE_V2_TOOLS = [
|
|
|
151
155
|
'run_task',
|
|
152
156
|
'run_logs',
|
|
153
157
|
'push',
|
|
158
|
+
'services_up',
|
|
159
|
+
'endpoint_get',
|
|
160
|
+
'services_stop',
|
|
154
161
|
];
|
|
155
162
|
// the tools of opencode 2 that work on this machine and have no stand-in on a sandbox
|
|
156
163
|
exports.OPENCODE_V2_LOCAL_TOOLS = [
|
|
@@ -268,6 +275,7 @@ function createOpencodeV2Tools(client, directory, options = {}) {
|
|
|
268
275
|
branch: text('The branch to push to, created when it does not exist'),
|
|
269
276
|
message: text('The commit message'),
|
|
270
277
|
}, ['branch', 'message']), same),
|
|
278
|
+
...service_tools_1.SERVICE_TOOLS.map((service) => tool(service.name, service.name, { ...(0, service_tools_1.serviceToolSchema)(service), additionalProperties: false }, same)),
|
|
271
279
|
];
|
|
272
280
|
}
|
|
273
281
|
exports.createOpencodeV2Tools = createOpencodeV2Tools;
|
|
@@ -287,6 +295,19 @@ function createOpencodeTools(z, client, options = {}) {
|
|
|
287
295
|
]));
|
|
288
296
|
}
|
|
289
297
|
exports.createOpencodeTools = createOpencodeTools;
|
|
298
|
+
// services_up, endpoint_get and services_stop, with their parameters for the zod of opencode 1
|
|
299
|
+
function opencodeServiceTools(client) {
|
|
300
|
+
return Object.fromEntries(service_tools_1.SERVICE_TOOLS.map((tool) => [
|
|
301
|
+
tool.name,
|
|
302
|
+
{
|
|
303
|
+
description: tool.description,
|
|
304
|
+
args: (z) => (0, service_tools_1.serviceToolArgs)(tool, z),
|
|
305
|
+
async execute(args, context) {
|
|
306
|
+
return { title: tool.title, output: await tool.call(await client(), args, context.abort) };
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
]));
|
|
310
|
+
}
|
|
290
311
|
function opencodeTools(client, options = {}) {
|
|
291
312
|
const shell = options.shell ?? connect_1.DEFAULT_SHELL_TASK;
|
|
292
313
|
const tasks = Object.fromEntries((0, task_tools_1.createTaskTools)(options.tasks, shell || undefined).map((task) => [
|
|
@@ -553,6 +574,7 @@ function opencodeTools(client, options = {}) {
|
|
|
553
574
|
return lines.join('\n');
|
|
554
575
|
},
|
|
555
576
|
},
|
|
577
|
+
...opencodeServiceTools(client),
|
|
556
578
|
});
|
|
557
579
|
}
|
|
558
580
|
// opencode runs the tool calls of a step in parallel, they reach the sandbox one at a time in their order
|
package/dist/harness/pi.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.hammerkitPiExtension = exports.createPiOperations = void 0;
|
|
|
4
4
|
const path_1 = require("path");
|
|
5
5
|
const connect_1 = require("./connect");
|
|
6
6
|
const format_1 = require("./format");
|
|
7
|
+
const service_tools_1 = require("./service-tools");
|
|
7
8
|
const sandbox_client_1 = require("./sandbox-client");
|
|
8
9
|
const IMAGE_TYPES = {
|
|
9
10
|
'.png': 'image/png',
|
|
@@ -178,6 +179,19 @@ function hammerkitPiExtension(library, settings = {}) {
|
|
|
178
179
|
return { content: [{ type: 'text', text: `pushed ${pushed.commit} to ${pushed.branch}` }], details: pushed };
|
|
179
180
|
},
|
|
180
181
|
});
|
|
182
|
+
for (const tool of service_tools_1.SERVICE_TOOLS) {
|
|
183
|
+
register({
|
|
184
|
+
name: tool.name,
|
|
185
|
+
label: tool.name,
|
|
186
|
+
description: tool.description,
|
|
187
|
+
promptSnippet: tool.title,
|
|
188
|
+
parameters: (0, service_tools_1.serviceToolSchema)(tool),
|
|
189
|
+
execute: async (_id, params, signal) => {
|
|
190
|
+
const text = await tool.call(await client(), params, signal ?? new AbortController().signal);
|
|
191
|
+
return { content: [{ type: 'text', text }], details: undefined };
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
181
195
|
pi.on('session_start', async (_event, ctx) => {
|
|
182
196
|
try {
|
|
183
197
|
const sandbox = await client();
|
|
@@ -161,6 +161,60 @@ class SandboxClient {
|
|
|
161
161
|
exitCode: exitCodeOf(result),
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Starts services of the published build file on the sandbox, or on a branch of its
|
|
166
|
+
* repository, and follows them until they are up: the run then holds their endpoints
|
|
167
|
+
* until its ttl ends or it is stopped. A signal cancels the run.
|
|
168
|
+
*/
|
|
169
|
+
async up(request, options = {}) {
|
|
170
|
+
if (options.signal?.aborted) {
|
|
171
|
+
throw new SandboxClientError('the run was cancelled');
|
|
172
|
+
}
|
|
173
|
+
const submitted = await submitRun(this.connection, {
|
|
174
|
+
definition: this.build.hash,
|
|
175
|
+
services: request.services,
|
|
176
|
+
endpoints: request.endpoints ?? [],
|
|
177
|
+
...(request.ttl ? { ttl: request.ttl } : {}),
|
|
178
|
+
...(request.sleepTtl ? { sleepTtl: request.sleepTtl } : {}),
|
|
179
|
+
...(request.name ? { name: request.name } : {}),
|
|
180
|
+
source: request.branch
|
|
181
|
+
? { type: 'ref', repo: this.sandbox.repo, ref: request.branch }
|
|
182
|
+
: { type: 'sandbox', id: this.id },
|
|
183
|
+
});
|
|
184
|
+
return followRun(this.connection, submitted, { signal: options.signal, untilUp: true });
|
|
185
|
+
}
|
|
186
|
+
// Stops a run: a service run takes its services and endpoints down.
|
|
187
|
+
async stop(id) {
|
|
188
|
+
return json(await call(this.connection, 'POST', `/v1/runs/${encodeURIComponent(id)}/cancel`));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A page of an endpoint of a service run, as the caller sees it: only at an address the
|
|
192
|
+
* server gave the run, so the token goes to the server and nowhere else.
|
|
193
|
+
*/
|
|
194
|
+
async fetchEndpoint(id, path, service, signal) {
|
|
195
|
+
const run = await this.getRun(id);
|
|
196
|
+
const endpoint = run.up?.endpoints.find((candidate) => service === undefined || candidate.service === service);
|
|
197
|
+
if (!endpoint) {
|
|
198
|
+
throw new SandboxClientError(run.up
|
|
199
|
+
? `${id} has no endpoint${service ? ` of ${service}` : ''}, it has ${run.up.endpoints.map((candidate) => `${candidate.service}:${candidate.port}`).join(', ') || 'none'}`
|
|
200
|
+
: `${id} is not a service run`);
|
|
201
|
+
}
|
|
202
|
+
const url = new URL(path.startsWith('/') ? path : `/${path}`, endpoint.url);
|
|
203
|
+
if (url.origin !== new URL(endpoint.url).origin) {
|
|
204
|
+
throw new SandboxClientError(`${path} is not a path of ${endpoint.url}`);
|
|
205
|
+
}
|
|
206
|
+
const response = await fetch(url, {
|
|
207
|
+
headers: { 'hammerkit-authorization': `Bearer ${await this.connection.token()}` },
|
|
208
|
+
redirect: 'manual',
|
|
209
|
+
signal: AbortSignal.any([AbortSignal.timeout(TIMEOUT_MS), ...(signal ? [signal] : [])]),
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
url: url.toString(),
|
|
213
|
+
status: response.status,
|
|
214
|
+
type: response.headers.get('content-type') ?? '',
|
|
215
|
+
body: await response.text(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
164
218
|
/**
|
|
165
219
|
* A shell command on the sandbox: the shell task of the build file with the command as
|
|
166
220
|
* its argument. `cwd` is a directory of the sandbox, `''` for its root.
|
|
@@ -283,7 +337,7 @@ async function followRun(connection, submitted, options = {}) {
|
|
|
283
337
|
options.onWaiting?.(run);
|
|
284
338
|
}
|
|
285
339
|
await readLog();
|
|
286
|
-
if (exports.FINAL_RUN_STATES.includes(run.state)) {
|
|
340
|
+
if (exports.FINAL_RUN_STATES.includes(run.state) || (options.untilUp && run.state === 'up')) {
|
|
287
341
|
break;
|
|
288
342
|
}
|
|
289
343
|
run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SERVICE_TOOLS = exports.describeServiceRun = exports.parseEndpoint = exports.serviceToolArgs = exports.serviceToolSchema = void 0;
|
|
4
|
+
// the most of a page an agent gets back
|
|
5
|
+
const MAX_PAGE_CHARS = 20000;
|
|
6
|
+
// The parameters as a JSON schema, for MCP, pi and opencode 2.
|
|
7
|
+
function serviceToolSchema(tool) {
|
|
8
|
+
return {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: Object.fromEntries(Object.entries(tool.parameters).map(([name, parameter]) => [
|
|
11
|
+
name,
|
|
12
|
+
parameter.type === 'string'
|
|
13
|
+
? { type: 'string', description: parameter.description }
|
|
14
|
+
: { type: 'array', items: { type: 'string' }, description: parameter.description },
|
|
15
|
+
])),
|
|
16
|
+
required: tool.required,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
exports.serviceToolSchema = serviceToolSchema;
|
|
20
|
+
// The parameters with the zod of opencode 1.
|
|
21
|
+
function serviceToolArgs(tool, z) {
|
|
22
|
+
return Object.fromEntries(Object.entries(tool.parameters).map(([name, parameter]) => {
|
|
23
|
+
const type = parameter.type === 'string' ? z.string() : z.array(z.string());
|
|
24
|
+
return [name, (tool.required.includes(name) ? type : type.optional()).describe(parameter.description)];
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
exports.serviceToolArgs = serviceToolArgs;
|
|
28
|
+
// `web:8080` as the server takes an endpoint
|
|
29
|
+
function parseEndpoint(value) {
|
|
30
|
+
const index = value.lastIndexOf(':');
|
|
31
|
+
const port = Number(value.slice(index + 1));
|
|
32
|
+
if (index <= 0 || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
33
|
+
throw new Error(`${value} is not an endpoint, write it as service:port, such as web:8080`);
|
|
34
|
+
}
|
|
35
|
+
return { service: value.slice(0, index), port };
|
|
36
|
+
}
|
|
37
|
+
exports.parseEndpoint = parseEndpoint;
|
|
38
|
+
// How a service run stands, for an agent: where its endpoints are and until when.
|
|
39
|
+
function describeServiceRun(run) {
|
|
40
|
+
const lines = [`run ${run.id}: ${run.state}${run.up ? ` (${run.up.name})` : ''}`];
|
|
41
|
+
if (run.state === 'up') {
|
|
42
|
+
lines.push(`up until ${run.expiresAt}, stop it with services_stop when it is no longer needed`);
|
|
43
|
+
if (run.up?.sleepTtl) {
|
|
44
|
+
lines.push('without a request for a while the services sleep, the next request wakes them');
|
|
45
|
+
}
|
|
46
|
+
for (const endpoint of run.up?.endpoints ?? []) {
|
|
47
|
+
lines.push(`${endpoint.service}:${endpoint.port} -> ${endpoint.url}`);
|
|
48
|
+
}
|
|
49
|
+
if ((run.up?.endpoints ?? []).length > 0) {
|
|
50
|
+
lines.push('a person opens these addresses in a browser and signs in; read a page yourself with endpoint_get');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (run.error) {
|
|
54
|
+
lines.push(`error: ${run.error}`);
|
|
55
|
+
}
|
|
56
|
+
for (const [task, result] of Object.entries(run.results ?? {})) {
|
|
57
|
+
if (result.status === 'failed') {
|
|
58
|
+
lines.push(`${task} failed${result.error ? `: ${result.error}` : ''}`, ...result.lastLog);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (run.state !== 'up') {
|
|
62
|
+
lines.push(`read the log with run_logs { "run": "${run.id}" }`);
|
|
63
|
+
}
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
exports.describeServiceRun = describeServiceRun;
|
|
67
|
+
exports.SERVICE_TOOLS = [
|
|
68
|
+
{
|
|
69
|
+
name: 'services_up',
|
|
70
|
+
title: 'Start services',
|
|
71
|
+
description: 'Start services of the published build file on the hammerkit server, with the files of the sandbox (or of ' +
|
|
72
|
+
'a branch), and keep them up for a person to look at: each endpoint (service:port) gets an address of its ' +
|
|
73
|
+
'own behind the sign-in of the server. Answers once they are up, with the addresses and until when they ' +
|
|
74
|
+
'stay. A second start with the same name replaces the first, so its addresses stay the same. A server on ' +
|
|
75
|
+
'Kubernetes starts services from a branch only: push the sandbox and pass branch.',
|
|
76
|
+
parameters: {
|
|
77
|
+
services: { type: 'string[]', description: 'The services to start, as the build file names them' },
|
|
78
|
+
endpoints: { type: 'string[]', description: 'The ports to publish, as service:port, such as web:8080' },
|
|
79
|
+
ttl: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'How long they stay once ready, such as 2h, then they are removed; the server default when left out',
|
|
82
|
+
},
|
|
83
|
+
sleepTtl: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'How long they stay up without a request, such as 30m, then they sleep until the next request wakes ' +
|
|
86
|
+
'them (from a branch only); the server default when left out',
|
|
87
|
+
},
|
|
88
|
+
name: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'Lower case letters, digits and -; the addresses carry it. The branch when left out',
|
|
91
|
+
},
|
|
92
|
+
branch: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: 'Start from this pushed branch of the repository instead of the files of the sandbox',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ['services'],
|
|
98
|
+
async call(client, args, signal) {
|
|
99
|
+
const followed = await client.up({
|
|
100
|
+
services: args.services,
|
|
101
|
+
endpoints: (args.endpoints ?? []).map(parseEndpoint),
|
|
102
|
+
ttl: args.ttl || undefined,
|
|
103
|
+
sleepTtl: args.sleepTtl || undefined,
|
|
104
|
+
name: args.name || undefined,
|
|
105
|
+
branch: args.branch || undefined,
|
|
106
|
+
}, { signal });
|
|
107
|
+
return describeServiceRun(followed.run);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'endpoint_get',
|
|
112
|
+
title: 'Read a page of an endpoint',
|
|
113
|
+
description: 'GET a path of an endpoint of a service run started with services_up, signed in as you, and answer its ' +
|
|
114
|
+
'status and body: to check that the app answers before a person opens it.',
|
|
115
|
+
parameters: {
|
|
116
|
+
run: { type: 'string', description: 'The id of the service run' },
|
|
117
|
+
path: { type: 'string', description: 'The path, such as / or /api/health' },
|
|
118
|
+
service: { type: 'string', description: 'The service of the endpoint, the first endpoint when left out' },
|
|
119
|
+
},
|
|
120
|
+
required: ['run', 'path'],
|
|
121
|
+
async call(client, args, signal) {
|
|
122
|
+
const page = await client.fetchEndpoint(args.run, args.path, args.service || undefined, signal);
|
|
123
|
+
const body = page.body.length > MAX_PAGE_CHARS
|
|
124
|
+
? `${page.body.slice(0, MAX_PAGE_CHARS)}\n… (${page.body.length} characters, the first ${MAX_PAGE_CHARS} shown)`
|
|
125
|
+
: page.body;
|
|
126
|
+
return [`GET ${page.url}: ${page.status}${page.type ? ` (${page.type})` : ''}`, '', body].join('\n');
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'services_stop',
|
|
131
|
+
title: 'Stop services',
|
|
132
|
+
description: 'Stop a service run started with services_up: its services and endpoints go.',
|
|
133
|
+
parameters: { run: { type: 'string', description: 'The id of the service run' } },
|
|
134
|
+
required: ['run'],
|
|
135
|
+
async call(client, args) {
|
|
136
|
+
const run = await client.stop(args.run);
|
|
137
|
+
return `run ${run.id}: ${run.state === 'up' ? 'stopping' : run.state}`;
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
//# sourceMappingURL=service-tools.js.map
|
|
@@ -28,7 +28,9 @@ async function ensureKubernetesServiceExists(instance, env, service) {
|
|
|
28
28
|
...(headless
|
|
29
29
|
? {}
|
|
30
30
|
: {
|
|
31
|
+
// a Service of more than one port needs a name for each
|
|
31
32
|
ports: service.data.ports.map((port) => ({
|
|
33
|
+
name: `tcp-${port.hostPort ?? port.containerPort}`,
|
|
32
34
|
port: port.hostPort ?? port.containerPort,
|
|
33
35
|
targetPort: port.containerPort,
|
|
34
36
|
})),
|
|
@@ -8,7 +8,10 @@ function parseWorkPort(port) {
|
|
|
8
8
|
return { containerPort: port, hostPort: port };
|
|
9
9
|
}
|
|
10
10
|
else if (parts.length === 2) {
|
|
11
|
-
|
|
11
|
+
// `0:80` is not published, as an empty host port: 0 (a free port) is for the endpoints
|
|
12
|
+
// of a server only
|
|
13
|
+
const hostPort = parsePort(parts[0], true);
|
|
14
|
+
return { containerPort: parsePort(parts[1], false), hostPort: hostPort === 0 ? null : hostPort };
|
|
12
15
|
}
|
|
13
16
|
else {
|
|
14
17
|
throw new Error(`invalid port ${port}`);
|
|
@@ -13,6 +13,8 @@ const annotations_1 = require("./annotations");
|
|
|
13
13
|
const server_client_1 = require("./server-client");
|
|
14
14
|
const bundle_text_1 = require("./bundle-text");
|
|
15
15
|
const sandbox_client_1 = require("../harness/sandbox-client");
|
|
16
|
+
const units_1 = require("../utils/units");
|
|
17
|
+
const service_tools_1 = require("../harness/service-tools");
|
|
16
18
|
const path_1 = require("path");
|
|
17
19
|
const RUN_ID = /^run_[A-Za-z0-9_-]{16}$/;
|
|
18
20
|
// what the publisher said about where a version comes from, one line
|
|
@@ -97,6 +99,21 @@ function describePlan(plan) {
|
|
|
97
99
|
exports.describePlan = describePlan;
|
|
98
100
|
function registerWorkCommands(remote, context) {
|
|
99
101
|
const { environment, buildFile, getServer, guarded, passthrough, fail } = context;
|
|
102
|
+
// the ref a run starts from: what the options name, else the checkout here
|
|
103
|
+
const refSource = async (options) => {
|
|
104
|
+
const checkout = options.repo && options.ref ? {} : await (0, annotations_1.readCheckout)((0, path_1.dirname)(buildFile));
|
|
105
|
+
const repo = options.repo ?? checkout.repo;
|
|
106
|
+
const ref = options.ref ?? checkout.branch;
|
|
107
|
+
// the commit checked out here is the code meant, when the ref is the branch it is on
|
|
108
|
+
const commit = options.commit ?? (options.ref ? undefined : checkout.commit);
|
|
109
|
+
if (!repo) {
|
|
110
|
+
throw new Error('name the repository with --repo, the checkout here has no origin it can be told by');
|
|
111
|
+
}
|
|
112
|
+
if (!ref) {
|
|
113
|
+
throw new Error('name the branch or tag with --ref, the checkout here is on no branch');
|
|
114
|
+
}
|
|
115
|
+
return { type: 'ref', repo, ref, ...(commit ? { commit } : {}) };
|
|
116
|
+
};
|
|
100
117
|
const connect = async (name) => {
|
|
101
118
|
const chosen = await getServer(name);
|
|
102
119
|
// a CI job or an agent brings its own token, a person signs in
|
|
@@ -147,17 +164,8 @@ function registerWorkCommands(remote, context) {
|
|
|
147
164
|
if (!bundle_1.HASH_PATTERN.test(definition)) {
|
|
148
165
|
throw new Error(`${(0, server_client_1.printable)(definition)} is not the hash of a published version (sha256:...)`);
|
|
149
166
|
}
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
const ref = options.ref ?? checkout.branch;
|
|
153
|
-
// the commit checked out here is the code meant, when the ref is the branch it is on
|
|
154
|
-
const commit = options.commit ?? (options.ref ? undefined : checkout.commit);
|
|
155
|
-
if (!repo) {
|
|
156
|
-
throw new Error('name the repository with --repo, the checkout here has no origin it can be told by');
|
|
157
|
-
}
|
|
158
|
-
if (!ref) {
|
|
159
|
-
throw new Error('name the branch or tag with --ref, the checkout here is on no branch');
|
|
160
|
-
}
|
|
167
|
+
const source = await refSource(options);
|
|
168
|
+
const ref = source.ref;
|
|
161
169
|
const { name: serverName, server } = await getServer(options.server);
|
|
162
170
|
const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, serverName, server) };
|
|
163
171
|
const runOptions = {};
|
|
@@ -174,7 +182,7 @@ function registerWorkCommands(remote, context) {
|
|
|
174
182
|
submitted = await (0, sandbox_client_1.submitRun)(connection, {
|
|
175
183
|
definition,
|
|
176
184
|
tasks,
|
|
177
|
-
source
|
|
185
|
+
source,
|
|
178
186
|
options: runOptions,
|
|
179
187
|
});
|
|
180
188
|
}
|
|
@@ -211,6 +219,84 @@ function registerWorkCommands(remote, context) {
|
|
|
211
219
|
const exitCode = failed?.exitCode && failed.exitCode > 0 ? failed.exitCode : 1;
|
|
212
220
|
fail(`${(0, server_client_1.printable)(run.id)} ${run.state}${run.error ? `: ${(0, server_client_1.printable)(run.error)}` : ''}`, exitCode);
|
|
213
221
|
}));
|
|
222
|
+
remote
|
|
223
|
+
.command('up <services...>')
|
|
224
|
+
.description('start services of a published build file on a server from a git ref and keep them up until their ttl ends, ' +
|
|
225
|
+
'with their endpoints at addresses of their own behind the sign-in of the server')
|
|
226
|
+
.addOption(new commander_1.Option('--server <name>', 'the server to run on; the default of remote use when left out'))
|
|
227
|
+
.addOption(new commander_1.Option('--endpoint <service:port>', 'publish this port of a service, such as web:8080; may be given more than once').argParser(collectOption))
|
|
228
|
+
.addOption(new commander_1.Option('--ttl <duration>', 'how long they stay once ready, such as 2h, then they are removed; the server default when left out'))
|
|
229
|
+
.addOption(new commander_1.Option('--sleep-ttl <duration>', 'how long they stay up without a request, such as 30m, then they sleep until the next request wakes them; ' +
|
|
230
|
+
'the server default when left out'))
|
|
231
|
+
.addOption(new commander_1.Option('--name <name>', 'a later run of yours with the same name replaces this one and keeps its addresses; the branch when left out'))
|
|
232
|
+
.addOption(new commander_1.Option('--build <hash>', 'the published version to run (sha256:...), or HAMMERKIT_BUILD; the hash of the build file here when left out'))
|
|
233
|
+
.addOption(new commander_1.Option('--repo <host/path>', 'the repository; origin of the checkout here when left out'))
|
|
234
|
+
.addOption(new commander_1.Option('--ref <ref>', 'the branch or tag to start; the branch checked out here when left out'))
|
|
235
|
+
.addOption(new commander_1.Option('--commit <sha>', 'the commit the ref has to be at; the commit checked out here when --ref is left out'))
|
|
236
|
+
.addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
|
|
237
|
+
.action(guarded('Remote up was not successful', async (services, options) => {
|
|
238
|
+
const named = options.build ?? environment.processEnvs.HAMMERKIT_BUILD;
|
|
239
|
+
const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
|
|
240
|
+
if (!bundle_1.HASH_PATTERN.test(definition)) {
|
|
241
|
+
throw new Error(`${(0, server_client_1.printable)(definition)} is not the hash of a published version (sha256:...)`);
|
|
242
|
+
}
|
|
243
|
+
const endpoints = (options.endpoint ?? []).map(service_tools_1.parseEndpoint);
|
|
244
|
+
const source = await refSource(options);
|
|
245
|
+
const { name: serverName, server } = await getServer(options.server);
|
|
246
|
+
const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, serverName, server) };
|
|
247
|
+
const submitted = await (0, sandbox_client_1.submitRun)(connection, {
|
|
248
|
+
definition,
|
|
249
|
+
services,
|
|
250
|
+
endpoints,
|
|
251
|
+
...(options.ttl ? { ttl: options.ttl } : {}),
|
|
252
|
+
...(options.sleepTtl ? { sleepTtl: options.sleepTtl } : {}),
|
|
253
|
+
...(options.name ? { name: options.name } : {}),
|
|
254
|
+
source,
|
|
255
|
+
options: options.env ? { env: options.env } : {},
|
|
256
|
+
});
|
|
257
|
+
environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${services.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(source.ref)}${submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : ''}\n`);
|
|
258
|
+
const followed = await (0, sandbox_client_1.followRun)(connection, submitted, {
|
|
259
|
+
signal: environment.abortCtrl.signal,
|
|
260
|
+
untilUp: true,
|
|
261
|
+
onLine: (line) => environment.stdout.write(`[${(0, server_client_1.printable)(line.task)}] ${printableLine(line.text)}\n`),
|
|
262
|
+
onWaiting: (run) => {
|
|
263
|
+
for (const approval of run.approvals ?? []) {
|
|
264
|
+
environment.stdout.write(`waiting until somebody who uses ${(0, server_client_1.printable)(approval.account)} approves it, before ${(0, server_client_1.printable)(approval.expiresAt)}: hammerkit remote approve ${(0, server_client_1.printable)(run.id)}\n`);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
const run = followed.run;
|
|
269
|
+
if (run.state !== 'up') {
|
|
270
|
+
fail(`${(0, server_client_1.printable)(run.id)} ${run.state}${run.error ? `: ${(0, server_client_1.printable)(run.error)}` : ''}`, 1);
|
|
271
|
+
}
|
|
272
|
+
environment.stdout.write(`${(0, server_client_1.printable)(run.id)} is up until ${(0, server_client_1.printable)(run.expiresAt ?? '-')}${run.up?.sleepTtl ? `, asleep after ${(0, units_1.formatDuration)(run.up.sleepTtl)} without a request` : ''}\n`);
|
|
273
|
+
for (const endpoint of run.up?.endpoints ?? []) {
|
|
274
|
+
environment.stdout.write(` ${(0, server_client_1.printable)(endpoint.service)}:${endpoint.port} ${(0, server_client_1.printable)(endpoint.url)}\n`);
|
|
275
|
+
}
|
|
276
|
+
environment.stdout.write(`extend it with: hammerkit remote extend ${(0, server_client_1.printable)(run.id)} 8h, stop it with: hammerkit remote stop ${(0, server_client_1.printable)(run.id)}\n`);
|
|
277
|
+
}));
|
|
278
|
+
remote
|
|
279
|
+
.command('extend <run> <ttl> [server]')
|
|
280
|
+
.description('keep a service run that is up for this long from now, such as 8h, at most the maxTtl of the server')
|
|
281
|
+
.action(guarded('Remote extend was not successful', async (id, ttl, name) => {
|
|
282
|
+
if (!RUN_ID.test(id)) {
|
|
283
|
+
throw new Error(`${(0, server_client_1.printable)(id)} is not a run id (run_...)`);
|
|
284
|
+
}
|
|
285
|
+
const { name: serverName, server, token } = await connect(name);
|
|
286
|
+
const run = (await (0, server_client_1.callServer)(server, token, 'POST', `/v1/runs/${id}/extend`, { ttl })).body;
|
|
287
|
+
environment.stdout.write(`${(0, server_client_1.printable)(run.id)} on ${serverName} stays up until ${(0, server_client_1.printable)(run.expiresAt ?? '-')}\n`);
|
|
288
|
+
}));
|
|
289
|
+
remote
|
|
290
|
+
.command('stop <run> [server]')
|
|
291
|
+
.description('stop a run: a service run takes its services and endpoints down')
|
|
292
|
+
.action(guarded('Remote stop was not successful', async (id, name) => {
|
|
293
|
+
if (!RUN_ID.test(id)) {
|
|
294
|
+
throw new Error(`${(0, server_client_1.printable)(id)} is not a run id (run_...)`);
|
|
295
|
+
}
|
|
296
|
+
const { name: serverName, server, token } = await connect(name);
|
|
297
|
+
const run = (await (0, server_client_1.callServer)(server, token, 'POST', `/v1/runs/${id}/cancel`)).body;
|
|
298
|
+
environment.stdout.write(`${(0, server_client_1.printable)(run.id)} on ${serverName}: ${run.state === 'up' || run.state === 'running' ? 'stopping' : (0, server_client_1.printable)(run.state)}\n`);
|
|
299
|
+
}));
|
|
214
300
|
remote
|
|
215
301
|
.command('versions [server]')
|
|
216
302
|
.description('list the published versions of build files on a server, newest first, with where they come from')
|
|
@@ -117,12 +117,18 @@ function matchesTaskWorkScope(task, scope) {
|
|
|
117
117
|
if ((0, work_scope_1.isContextTaskFilter)(scope)) {
|
|
118
118
|
return task.name === scope.taskName;
|
|
119
119
|
}
|
|
120
|
+
if (scope.serviceNames) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
120
123
|
return (0, label_values_1.appliesToLabels)(task.data.labels, scope);
|
|
121
124
|
}
|
|
122
125
|
function matchesServiceWorkScope(service, scope) {
|
|
123
126
|
if ((0, work_scope_1.isContextTaskFilter)(scope)) {
|
|
124
127
|
return false;
|
|
125
128
|
}
|
|
129
|
+
if (scope.serviceNames) {
|
|
130
|
+
return scope.serviceNames.includes(service.name);
|
|
131
|
+
}
|
|
126
132
|
return (0, label_values_1.appliesToLabels)(service.labels, scope);
|
|
127
133
|
}
|
|
128
134
|
//# sourceMappingURL=work-scope-parser.js.map
|