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.
@@ -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) => !!port.hostPort);
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`, [{ HostPort: `${port.hostPort}` }]])),
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) => !!p.hostPort)
63
- .reduce((map, port) => {
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
  }
@@ -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;
@@ -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
@@ -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
- return { containerPort: parsePort(parts[1], false), hostPort: parsePort(parts[0], true) };
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 checkout = options.repo && options.ref ? {} : await (0, annotations_1.readCheckout)((0, path_1.dirname)(buildFile));
151
- const repo = options.repo ?? checkout.repo;
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: { type: 'ref', repo, ref, ...(commit ? { commit } : {}) },
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