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.
Files changed (128) hide show
  1. package/build.schema.json +62 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/backends/registry-cache-backend.js +2 -2
  4. package/dist/cache/explain.js +13 -11
  5. package/dist/cli.js +3 -0
  6. package/dist/docker/item-network.js +471 -0
  7. package/dist/docker/pull.js +4 -2
  8. package/dist/docker/remove-orphaned-containers.js +13 -6
  9. package/dist/environment/replace-env-variables.js +8 -6
  10. package/dist/executer/await-completed-dependencies.js +2 -1
  11. package/dist/executer/check-readiness.js +2 -2
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +24 -2
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +411 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +42 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/claim-layout.js +71 -0
  34. package/dist/kubernetes/ensure-http-route.js +1 -1
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +46 -28
  36. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +132 -5
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +27 -11
  38. package/dist/kubernetes/ensure-persistent-data.js +75 -24
  39. package/dist/kubernetes/get-service-ip.js +16 -2
  40. package/dist/kubernetes/job-placement.js +99 -0
  41. package/dist/kubernetes/job-secrets.js +320 -0
  42. package/dist/kubernetes/kept-claims.js +203 -0
  43. package/dist/kubernetes/kubernetes-instance.js +10 -2
  44. package/dist/kubernetes/network-gate.js +71 -0
  45. package/dist/kubernetes/network-policy.js +583 -0
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/resources.js +37 -4
  48. package/dist/kubernetes/restore-kubernetes-data.js +28 -7
  49. package/dist/kubernetes/secrets.js +7 -9
  50. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  51. package/dist/kubernetes/volumes.js +104 -12
  52. package/dist/log.js +1 -1
  53. package/dist/main.js +41 -0
  54. package/dist/network/egress-proxy.js +407 -0
  55. package/dist/network/port-relay.js +35 -0
  56. package/dist/optimizer/get-work-cache-stats.js +16 -0
  57. package/dist/optimizer/work-task-cache-description.js +5 -2
  58. package/dist/planner/service-definition.js +5 -0
  59. package/dist/planner/utils/append-work-dependencies.js +5 -2
  60. package/dist/planner/utils/append-work-environment.js +2 -0
  61. package/dist/planner/utils/append-work-service.js +5 -0
  62. package/dist/planner/utils/append-work-task.js +20 -1
  63. package/dist/planner/utils/assert-item-paths.js +50 -0
  64. package/dist/planner/utils/hosted-volumes.js +35 -0
  65. package/dist/planner/utils/task-arguments.js +3 -0
  66. package/dist/planner/work-network.js +193 -0
  67. package/dist/planner/work-runtime-docker.js +43 -1
  68. package/dist/planner/work-runtime-kubernetes.js +370 -111
  69. package/dist/planner/work-runtime-local.js +2 -1
  70. package/dist/program.js +33 -5
  71. package/dist/remote/annotations.js +115 -0
  72. package/dist/remote/bundle-text.js +24 -0
  73. package/dist/remote/bundle.js +9 -1
  74. package/dist/remote/remote-commands.js +15 -3
  75. package/dist/remote/remote-work-commands.js +272 -71
  76. package/dist/remote/server-client.js +18 -5
  77. package/dist/remote/source-manifest.js +15 -2
  78. package/dist/remote/token-source.js +67 -0
  79. package/dist/schema/build-file-base-task-schema.js +7 -0
  80. package/dist/schema/build-file-container-service-schema.js +8 -0
  81. package/dist/schema/build-file-container-task-schema.js +3 -0
  82. package/dist/schema/build-file-environment-schema.js +9 -0
  83. package/dist/schema/network-schema.js +78 -0
  84. package/dist/schema/reference-parser.js +2 -0
  85. package/dist/schema/schema-parser.js +16 -3
  86. package/dist/schema/work-scope-parser.js +5 -0
  87. package/dist/secrets/command-secret-provider.js +73 -10
  88. package/dist/secrets/github-app-token-command.js +26 -0
  89. package/dist/secrets/github-app-token.js +191 -0
  90. package/dist/server/accounts-api.js +9 -2
  91. package/dist/server/definitions-api.js +69 -47
  92. package/dist/server/definitions.js +76 -51
  93. package/dist/server/executor.js +156 -41
  94. package/dist/server/git-checkout.js +72 -8
  95. package/dist/server/kubernetes-backend.js +603 -0
  96. package/dist/server/kubernetes-sandbox-storage.js +997 -0
  97. package/dist/server/oauth.js +12 -0
  98. package/dist/server/plan.js +88 -2
  99. package/dist/server/policy.js +104 -15
  100. package/dist/server/proposals-api.js +24 -40
  101. package/dist/server/proposals.js +12 -25
  102. package/dist/server/repo-access.js +37 -6
  103. package/dist/server/run-logs.js +217 -0
  104. package/dist/server/run-plan.js +48 -2
  105. package/dist/server/run-request.js +29 -10
  106. package/dist/server/run-store.js +5 -0
  107. package/dist/server/run-worker-command.js +13 -1
  108. package/dist/server/run-worker.js +121 -24
  109. package/dist/server/runs.js +191 -10
  110. package/dist/server/sandbox-docker.js +62 -0
  111. package/dist/server/sandbox-files-api.js +269 -0
  112. package/dist/server/sandbox-files.js +1054 -0
  113. package/dist/server/sandbox-patch.js +481 -0
  114. package/dist/server/sandbox-pod-command.js +15 -0
  115. package/dist/server/sandbox-pod.js +146 -0
  116. package/dist/server/sandbox-storage.js +462 -0
  117. package/dist/server/sandboxes-api.js +216 -0
  118. package/dist/server/sandboxes.js +519 -0
  119. package/dist/server/server-command.js +39 -20
  120. package/dist/server/server-config.js +443 -22
  121. package/dist/server/server.js +219 -40
  122. package/dist/utils/abort-on-signals.js +46 -6
  123. package/dist/utils/run-id.js +12 -0
  124. package/dist/utils/tar.js +25 -0
  125. package/harness/opencode.ts +11 -0
  126. package/harness/pi.ts +7 -0
  127. package/package.json +8 -2
  128. package/dist/remote/text-diff.js +0 -89
@@ -0,0 +1,471 @@
1
+ "use strict";
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;
4
+ const readline_1 = require("readline");
5
+ const stream_1 = require("stream");
6
+ const work_service_1 = require("../planner/work-service");
7
+ const work_network_1 = require("../planner/work-network");
8
+ const work_instance_id_1 = require("../planner/work-instance-id");
9
+ const get_service_containers_1 = require("../executer/get-service-containers");
10
+ const service_dns_1 = require("../executer/service-dns");
11
+ const run_labels_1 = require("./run-labels");
12
+ const run_id_1 = require("../utils/run-id");
13
+ const pull_1 = require("./pull");
14
+ const remove_container_1 = require("./remove-container");
15
+ const egress_proxy_1 = require("../network/egress-proxy");
16
+ const port_relay_1 = require("../network/port-relay");
17
+ const log_1 = require("../log");
18
+ const sleep_1 = require("../utils/sleep");
19
+ const remove_orphaned_containers_1 = require("./remove-orphaned-containers");
20
+ const PROXY_READY_TIMEOUT_MS = 60000;
21
+ const NETWORK_REMOVE_ATTEMPTS = 20;
22
+ // the first release with com.docker.network.bridge.inhibit_ipv4, which keeps
23
+ // an internal network from reaching the host through its gateway; it also
24
+ // has the fix for CVE-2024-29018 (DNS forwarded from internal networks)
25
+ const MIN_DOCKER_MAJOR = 28;
26
+ // The internal network of a task or service with a limited network. Services
27
+ // always have one when limited, since what needs them joins it; a task only
28
+ // when it needs a service or allows hosts. The name carries the run, so
29
+ // concurrent runs never share or remove each other's networks.
30
+ function getItemNetworkName(item) {
31
+ return `hammerkit-${(0, work_instance_id_1.getWorkInstanceId)(item)}-${(0, run_id_1.getRunId)()}`;
32
+ }
33
+ exports.getItemNetworkName = getItemNetworkName;
34
+ // On a server, the network the open tasks and services of a run share, in place of the
35
+ // default bridge: Docker keeps user-defined bridges apart, so the containers of one run
36
+ // can't reach those of another, or of another caller. What lies beyond the host's own
37
+ // interfaces (the LAN, the cloud's metadata address) is the host firewall's to block.
38
+ function getRunNetworkName() {
39
+ return `hammerkit-run-${(0, run_id_1.getRunId)()}`;
40
+ }
41
+ exports.getRunNetworkName = getRunNetworkName;
42
+ const runNetworks = new WeakMap();
43
+ function withRunNetwork(docker, fn) {
44
+ let state = runNetworks.get(docker);
45
+ if (!state) {
46
+ state = { users: 0, lock: Promise.resolve() };
47
+ runNetworks.set(docker, state);
48
+ }
49
+ const current = state;
50
+ const result = current.lock.then(() => fn(current));
51
+ current.lock = result.catch(() => undefined);
52
+ return result;
53
+ }
54
+ // The run's network for an open item, made by its first user and removed after the last
55
+ // one: null off a server, for a daemon (it outlives the run) and on a daemon that has no
56
+ // Linux bridges, where the item stays on the default bridge as before.
57
+ async function acquireRunNetwork(docker, environment, options) {
58
+ if (!environment.hosted || options.daemon || (await getDaemonVersion(docker)).Os !== 'linux') {
59
+ return null;
60
+ }
61
+ return withRunNetwork(docker, async (state) => {
62
+ const name = getRunNetworkName();
63
+ if (state.users === 0) {
64
+ try {
65
+ await docker.createNetwork({
66
+ Name: name,
67
+ Labels: { app: 'hammerkit', ...(0, run_labels_1.getRunLabels)(), 'hammerkit-daemon': 'false' },
68
+ });
69
+ }
70
+ catch (e) {
71
+ // left by an item whose release failed
72
+ if (e?.statusCode !== 409) {
73
+ throw e;
74
+ }
75
+ }
76
+ }
77
+ state.users++;
78
+ return name;
79
+ });
80
+ }
81
+ async function releaseRunNetwork(docker) {
82
+ await withRunNetwork(docker, async (state) => {
83
+ state.users--;
84
+ if (state.users === 0) {
85
+ await removeItemNetwork(docker, getRunNetworkName());
86
+ }
87
+ });
88
+ }
89
+ async function getAddressOn(docker, containerId, network) {
90
+ const info = await docker.getContainer(containerId).inspect();
91
+ const address = info.NetworkSettings.Networks[network]?.IPAddress;
92
+ if (!address) {
93
+ throw new Error(`${info.Name.replace(/^\//, '')} has no address on the network ${network} of the run`);
94
+ }
95
+ return address;
96
+ }
97
+ // The network between an `allow` item and its proxy, which nothing else joins:
98
+ // the services the item needs don't get to use its proxy.
99
+ function getProxyNetworkName(networkName) {
100
+ return `${networkName}-proxy`;
101
+ }
102
+ exports.getProxyNetworkName = getProxyNetworkName;
103
+ const daemonVersions = new WeakMap();
104
+ function getDaemonVersion(docker) {
105
+ let version = daemonVersions.get(docker);
106
+ if (!version) {
107
+ version = docker.version();
108
+ daemonVersions.set(docker, version);
109
+ }
110
+ return version;
111
+ }
112
+ // Limits rely on internal networks without a gateway on the host, which only
113
+ // Linux containers on Docker 28 or later have. Anything else would run the
114
+ // item with a hole, so it is refused.
115
+ async function checkNetworkLimitSupport(docker, item) {
116
+ const { Version, Os } = await getDaemonVersion(docker);
117
+ const level = (0, work_network_1.getEffectiveNetwork)(item.data).type;
118
+ if (Os !== 'linux') {
119
+ throw new Error(`${item.name}: network ${level} needs Linux containers, the docker daemon runs ${Os} containers`);
120
+ }
121
+ const major = parseInt(Version, 10);
122
+ if (!(major >= MIN_DOCKER_MAJOR)) {
123
+ throw new Error(`${item.name}: network ${level} needs Docker ${MIN_DOCKER_MAJOR} or later, the daemon is ${Version}`);
124
+ }
125
+ }
126
+ exports.checkNetworkLimitSupport = checkNetworkLimitSupport;
127
+ // Internal (no route out) and without an address on the host, so the host's
128
+ // services can't be reached through it either.
129
+ async function createInternalNetwork(docker, name, labels) {
130
+ await docker.createNetwork({
131
+ Name: name,
132
+ Internal: true,
133
+ Options: { 'com.docker.network.bridge.inhibit_ipv4': 'true' },
134
+ Labels: labels,
135
+ });
136
+ }
137
+ // What the proxy and relay may do: serve, nothing else.
138
+ const HELPER_HOST_CONFIG = {
139
+ CapDrop: ['ALL'],
140
+ SecurityOpt: ['no-new-privileges'],
141
+ ReadonlyRootfs: true,
142
+ Memory: 256 * 1024 * 1024,
143
+ PidsLimit: 64,
144
+ };
145
+ // Where the container of a service is reached: on the default bridge (open)
146
+ // or on its own internal network (limited), which may be of an earlier run
147
+ // (`up --daemon`).
148
+ async function getNeedNetwork(docker, need, containerId) {
149
+ const service = need.service;
150
+ if (!(0, work_service_1.isContainerWorkService)(service.data) || !(0, work_network_1.isLimitedNetwork)((0, work_network_1.getEffectiveNetwork)(service.data))) {
151
+ return null;
152
+ }
153
+ const info = await docker.getContainer(containerId).inspect();
154
+ return info.HostConfig.NetworkMode ?? null;
155
+ }
156
+ // Docker options for the network of a task or service. `open` is the default
157
+ // bridge with links to the needed services, as before network limits, and on a
158
+ // server the run's own network (see getRunNetworkName). `none`
159
+ // and `allow` put the container on an internal network of its own (or none at
160
+ // all for a task that needs nothing) that the needed services join under the
161
+ // names it needs them by; `allow` adds the egress proxy to it.
162
+ async function prepareItemNetwork(docker, item, environment,
163
+ // `publish`: the ports a service publishes on the host
164
+ options) {
165
+ const network = (0, work_network_1.getEffectiveNetwork)(item.data);
166
+ const dns = (0, get_service_containers_1.getServiceContainers)(item.needs);
167
+ const hosts = [];
168
+ const containers = [];
169
+ for (const need of item.needs) {
170
+ const target = dns[need.name];
171
+ if (!target || (!(0, service_dns_1.isHostServiceDns)(target) && !target.containerId)) {
172
+ throw new Error(`service ${need.name} is not running`);
173
+ }
174
+ if ((0, service_dns_1.isHostServiceDns)(target)) {
175
+ hosts.push(`${need.name}:${target.host}`);
176
+ }
177
+ else {
178
+ containers.push({
179
+ need,
180
+ containerId: target.containerId,
181
+ network: await getNeedNetwork(docker, need, target.containerId),
182
+ });
183
+ }
184
+ }
185
+ if (!(0, work_network_1.isLimitedNetwork)(network)) {
186
+ const linked = containers.filter((c) => !c.network);
187
+ const shared = await acquireRunNetwork(docker, environment, options);
188
+ if (shared) {
189
+ try {
190
+ // the services it needs are on the same network, found under the names it needs them by
191
+ for (const target of linked) {
192
+ hosts.push(`${target.need.name}:${await getAddressOn(docker, target.containerId, shared)}`);
193
+ }
194
+ }
195
+ catch (e) {
196
+ await releaseRunNetwork(docker);
197
+ throw e;
198
+ }
199
+ }
200
+ return {
201
+ options: shared
202
+ ? { mode: shared, hosts }
203
+ : { links: linked.map((c) => `${c.containerId}:${c.need.name}`), hosts },
204
+ // a limited service is on its internal network only, so an open item
205
+ // joins that network to reach it
206
+ async connect(container) {
207
+ for (const target of containers) {
208
+ if (target.network) {
209
+ // a link on a user-defined network resolves by container name only
210
+ const info = await docker.getContainer(target.containerId).inspect();
211
+ await docker.getNetwork(target.network).connect({
212
+ Container: container.id,
213
+ EndpointConfig: { Links: [`${info.Name.replace(/^\//, '')}:${target.need.name}`] },
214
+ });
215
+ }
216
+ }
217
+ },
218
+ async release() {
219
+ if (shared) {
220
+ await releaseRunNetwork(docker);
221
+ }
222
+ },
223
+ };
224
+ }
225
+ if (network.type === 'none' && containers.length === 0 && !(0, work_service_1.isContainerWorkService)(item.data)) {
226
+ return {
227
+ options: { mode: 'none' },
228
+ async connect() { },
229
+ async release() { },
230
+ };
231
+ }
232
+ await checkNetworkLimitSupport(docker, item);
233
+ const name = getItemNetworkName(item);
234
+ const labels = {
235
+ app: 'hammerkit',
236
+ 'hammerkit-network-of': (0, work_instance_id_1.getWorkInstanceId)(item),
237
+ ...(0, run_labels_1.getRunLabels)(),
238
+ 'hammerkit-daemon': options.daemon ? 'true' : 'false',
239
+ };
240
+ await createInternalNetwork(docker, name, labels);
241
+ try {
242
+ for (const target of containers) {
243
+ await docker.getNetwork(name).connect({
244
+ Container: target.containerId,
245
+ EndpointConfig: { Aliases: [target.need.name] },
246
+ });
247
+ }
248
+ if (network.type === 'allow') {
249
+ await createInternalNetwork(docker, getProxyNetworkName(name), labels);
250
+ const proxyIp = await startEgressProxy(docker, item, environment, name, network, options, containers.length > 0);
251
+ // the item reaches each TCP forward under its host's name, at the proxy
252
+ for (const forward of (0, work_network_1.getTcpForwards)(network)) {
253
+ hosts.push(`${forward.host}:${proxyIp}`);
254
+ }
255
+ }
256
+ }
257
+ catch (e) {
258
+ await removeItemNetwork(docker, name);
259
+ throw e;
260
+ }
261
+ const publish = (options.publish ?? []).filter((port) => !!port.hostPort);
262
+ return {
263
+ options: {
264
+ mode: name,
265
+ hosts,
266
+ env: network.type === 'allow' ? (0, egress_proxy_1.getProxyClientEnvironment)(item.needs.map((need) => need.name)) : {},
267
+ relayPorts: (0, work_service_1.isContainerWorkService)(item.data),
268
+ },
269
+ async connect(container) {
270
+ try {
271
+ if (network.type === 'allow') {
272
+ await docker.getNetwork(getProxyNetworkName(name)).connect({ Container: container.id });
273
+ }
274
+ if (publish.length > 0) {
275
+ await startPortRelay(docker, item, environment, name, container, publish, options);
276
+ }
277
+ }
278
+ catch (e) {
279
+ await removeItemNetwork(docker, name);
280
+ throw e;
281
+ }
282
+ },
283
+ async release() {
284
+ await removeItemNetwork(docker, name);
285
+ },
286
+ };
287
+ }
288
+ exports.prepareItemNetwork = prepareItemNetwork;
289
+ function getHelperLabels(networkName, options) {
290
+ return {
291
+ app: 'hammerkit',
292
+ // goes with the task or service it serves
293
+ 'hammerkit-type': 'proxy',
294
+ 'hammerkit-network': networkName,
295
+ ...(0, run_labels_1.getRunLabels)(),
296
+ 'hammerkit-daemon': options.daemon ? 'true' : 'false',
297
+ };
298
+ }
299
+ // On the default bridge for the way out, and on the proxy network under
300
+ // EGRESS_PROXY_HOST, the only address it listens on: neither the bridge nor
301
+ // the services on the item's network reach it. It joins the item's network
302
+ // to reach the needed services (for tools that ignore NO_PROXY), but doesn't
303
+ // listen there. Refused hosts show up in the item's log. Returns its address
304
+ // on the proxy network, where it also listens for the TCP forwards.
305
+ async function startEgressProxy(docker, item, environment, networkName, network, options, reachNeeds) {
306
+ await (0, pull_1.pull)(item.status, docker, egress_proxy_1.EGRESS_PROXY_IMAGE, environment);
307
+ const createOptions = {
308
+ Image: egress_proxy_1.EGRESS_PROXY_IMAGE,
309
+ Cmd: (0, egress_proxy_1.getEgressProxyCommand)(),
310
+ User: 'node',
311
+ Env: Object.entries((0, egress_proxy_1.getItemProxyEnvironment)(item, egress_proxy_1.EGRESS_PROXY_HOST)).map(([key, value]) => `${key}=${value}`),
312
+ Labels: getHelperLabels(networkName, options),
313
+ // unprivileged, it still listens on the port of a TCP forward below 1024
314
+ HostConfig: { ...HELPER_HOST_CONFIG, Sysctls: { 'net.ipv4.ip_unprivileged_port_start': '0' } },
315
+ };
316
+ const container = await docker.createContainer(createOptions);
317
+ await docker.getNetwork(getProxyNetworkName(networkName)).connect({
318
+ Container: container.id,
319
+ EndpointConfig: { Aliases: [egress_proxy_1.EGRESS_PROXY_HOST] },
320
+ });
321
+ if (reachNeeds) {
322
+ await docker.getNetwork(networkName).connect({ Container: container.id });
323
+ }
324
+ const stream = await container.attach({ stream: true, stdout: true, stderr: true });
325
+ const stdout = new stream_1.PassThrough();
326
+ const stderr = new stream_1.PassThrough();
327
+ docker.modem.demuxStream(stream, stdout, stderr);
328
+ stream.on('end', () => {
329
+ stdout.end();
330
+ stderr.end();
331
+ });
332
+ stderr.on('data', (data) => item.status.write('debug', `proxy: ${data.toString().trim()}`));
333
+ const ready = new Promise((resolve, reject) => {
334
+ const timeout = setTimeout(() => reject(new Error('the egress proxy did not start')), PROXY_READY_TIMEOUT_MS);
335
+ const lines = (0, readline_1.createInterface)({ input: stdout });
336
+ lines.on('line', (line) => {
337
+ const event = (0, egress_proxy_1.parseEgressProxyEvent)(line);
338
+ if (event?.type === 'ready') {
339
+ clearTimeout(timeout);
340
+ resolve();
341
+ }
342
+ else if (event) {
343
+ item.status.write('warn', (0, egress_proxy_1.formatEgressProxyEvent)(event));
344
+ }
345
+ });
346
+ lines.on('close', () => {
347
+ clearTimeout(timeout);
348
+ reject(new Error('the egress proxy stopped'));
349
+ });
350
+ });
351
+ // once ready, a later close only means the proxy was removed with the item
352
+ ready.catch(() => undefined);
353
+ await container.start();
354
+ await ready;
355
+ const ip = (await container.inspect()).NetworkSettings.Networks[getProxyNetworkName(networkName)]?.IPAddress;
356
+ if (!ip) {
357
+ throw new Error(`the egress proxy of ${item.name} has no address on its network`);
358
+ }
359
+ item.status.write('debug', `egress proxy allows ${network.hosts.join(', ') || 'no host'}`);
360
+ return ip;
361
+ }
362
+ // On the default bridge, where Docker publishes ports, and on the service's
363
+ // network, from where it passes connections on to the service.
364
+ async function startPortRelay(docker, item, environment, networkName, service, ports, options) {
365
+ await (0, pull_1.pull)(item.status, docker, egress_proxy_1.EGRESS_PROXY_IMAGE, environment);
366
+ // a container is found by its name on a user-defined network
367
+ const host = (await service.inspect()).Name.replace(/^\//, '');
368
+ const container = await docker.createContainer({
369
+ Image: egress_proxy_1.EGRESS_PROXY_IMAGE,
370
+ Cmd: (0, port_relay_1.getPortRelayCommand)(),
371
+ User: 'node',
372
+ Env: Object.entries((0, port_relay_1.getPortRelayEnvironment)(ports.map((port) => ({ port: port.containerPort, host, targetPort: port.containerPort })))).map(([key, value]) => `${key}=${value}`),
373
+ Labels: getHelperLabels(networkName, options),
374
+ ExposedPorts: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, {}])),
375
+ HostConfig: {
376
+ ...HELPER_HOST_CONFIG,
377
+ PortBindings: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, [{ HostPort: `${port.hostPort}` }]])),
378
+ },
379
+ });
380
+ await docker.getNetwork(networkName).connect({ Container: container.id });
381
+ await container.start();
382
+ item.status.write('debug', `ports ${ports.map((port) => port.hostPort).join(', ')} published by a relay`);
383
+ }
384
+ // The network, its proxy network, its proxy and relay, and every endpoint on
385
+ // them: what an earlier run left behind, or what the item used.
386
+ async function removeItemNetwork(docker, name) {
387
+ const helpers = await docker.listContainers({ all: true, filters: { label: [`hammerkit-network=${name}`] } });
388
+ for (const helper of helpers) {
389
+ await (0, remove_container_1.removeContainer)(docker.getContainer(helper.Id));
390
+ }
391
+ await removeNetwork(docker, getProxyNetworkName(name));
392
+ await removeNetwork(docker, name);
393
+ }
394
+ exports.removeItemNetwork = removeItemNetwork;
395
+ async function removeNetwork(docker, name) {
396
+ const network = docker.getNetwork(name);
397
+ let info;
398
+ try {
399
+ info = await network.inspect();
400
+ }
401
+ catch (e) {
402
+ if (e?.statusCode === 404) {
403
+ return;
404
+ }
405
+ throw e;
406
+ }
407
+ for (const containerId of Object.keys(info.Containers ?? {})) {
408
+ try {
409
+ await network.disconnect({ Container: containerId, Force: true });
410
+ }
411
+ catch {
412
+ // gone already, or on its way out with its container
413
+ }
414
+ }
415
+ // a container being removed leaves its endpoints a moment later
416
+ for (let attempt = 1;; attempt++) {
417
+ try {
418
+ await network.remove();
419
+ return;
420
+ }
421
+ catch (e) {
422
+ if (e?.statusCode === 404) {
423
+ return;
424
+ }
425
+ if (attempt >= NETWORK_REMOVE_ATTEMPTS) {
426
+ throw new Error(`unable to remove network ${name}: ${(0, log_1.getErrorMessage)(e)}`);
427
+ }
428
+ await (0, sleep_1.sleep)(500);
429
+ }
430
+ }
431
+ }
432
+ function getNetworkHostConfig(network) {
433
+ return {
434
+ ...(network.mode ? { NetworkMode: network.mode } : {}),
435
+ Links: network.links ?? [],
436
+ ExtraHosts: network.hosts ?? [],
437
+ };
438
+ }
439
+ exports.getNetworkHostConfig = getNetworkHostConfig;
440
+ // Every network of the item, of this run or another one: what `down` and
441
+ // `clean` remove.
442
+ async function removeItemNetworks(docker, item) {
443
+ const networks = await docker.listNetworks({
444
+ filters: { label: [`hammerkit-network-of=${(0, work_instance_id_1.getWorkInstanceId)(item)}`] },
445
+ });
446
+ for (const network of networks) {
447
+ await removeItemNetwork(docker, network.Name);
448
+ }
449
+ }
450
+ exports.removeItemNetworks = removeItemNetworks;
451
+ // The internal networks of tasks and services a run left behind when it was
452
+ // killed. Networks of `up --daemon` services stay until `down`.
453
+ async function removeOrphanedNetworks(docker) {
454
+ let networks;
455
+ try {
456
+ networks = await docker.listNetworks({ filters: { label: ['app=hammerkit'] } });
457
+ }
458
+ catch (e) {
459
+ if ((0, remove_orphaned_containers_1.isDockerUnreachable)(e)) {
460
+ return [];
461
+ }
462
+ throw e;
463
+ }
464
+ const orphaned = networks.filter((network) => (0, remove_orphaned_containers_1.isOfStoppedRun)(network.Labels ?? {}, remove_orphaned_containers_1.isProcessAlive));
465
+ for (const network of orphaned) {
466
+ await removeItemNetwork(docker, network.Name);
467
+ }
468
+ return orphaned.map((network) => network.Name);
469
+ }
470
+ exports.removeOrphanedNetworks = removeOrphanedNetworks;
471
+ //# sourceMappingURL=item-network.js.map
@@ -19,11 +19,13 @@ async function pull(status, docker, imageName, environment) {
19
19
  exports.pull = pull;
20
20
  // A tag is present when an image carries it; an image pinned by digest
21
21
  // (`repo:tag@sha256:…`) when an image was pulled with that digest, whatever
22
- // tag it has now.
22
+ // tag it has now. The containerd image store lists an image pulled by digest
23
+ // under that name in RepoTags rather than in RepoDigests.
23
24
  function isImagePresent(images, imageName) {
24
25
  const { repository, tag, digest } = splitImageName(imageName);
25
26
  if (digest) {
26
- return images.some((i) => i.RepoDigests?.some((repoDigest) => repoDigest === `${repository}@${digest}`));
27
+ const name = `${repository}@${digest}`;
28
+ return images.some((i) => i.RepoDigests?.includes(name) || i.RepoTags?.includes(name));
27
29
  }
28
30
  return images.some((i) => i.RepoTags?.some((repoTag) => repoTag === `${repository}:${tag ?? 'latest'}`));
29
31
  }
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.removeOrphanedContainers = exports.isDockerUnreachable = exports.isProcessAlive = exports.isOrphanedContainer = void 0;
3
+ exports.removeOrphanedContainers = exports.isDockerUnreachable = exports.isProcessAlive = exports.isOfStoppedRun = exports.isOrphanedContainer = void 0;
4
4
  const os_1 = require("os");
5
5
  const remove_container_1 = require("./remove-container");
6
6
  // A hammerkit container nothing will remove any more: a task container (they
7
- // never outlive their run) or a service of a run that is gone, started by a
8
- // hammerkit process of this machine that no longer exists, and the paused
9
- // state records hammerkit kept before 1.9. Services of `up --daemon` and
7
+ // never outlive their run), a service or an egress proxy of a run that is
8
+ // gone, started by a hammerkit process of this machine that no longer exists,
9
+ // and the paused state records hammerkit kept before 1.9. Services of `up --daemon` and
10
10
  // containers of other machines or of running processes stay.
11
11
  function isOrphanedContainer(container, isProcessAlive) {
12
12
  const labels = container.Labels;
@@ -14,9 +14,16 @@ function isOrphanedContainer(container, isProcessAlive) {
14
14
  if (type === 'task' && container.State === 'paused') {
15
15
  return true;
16
16
  }
17
- if (type !== 'task' && type !== 'service') {
17
+ // a proxy goes with the task or service it serves
18
+ if (type !== 'task' && type !== 'service' && type !== 'proxy') {
18
19
  return false;
19
20
  }
21
+ return isOfStoppedRun(labels, isProcessAlive);
22
+ }
23
+ exports.isOrphanedContainer = isOrphanedContainer;
24
+ // Labeled with the run labels of a hammerkit process of this machine that no
25
+ // longer exists, and not kept for `up --daemon`.
26
+ function isOfStoppedRun(labels, isProcessAlive) {
20
27
  if (labels['hammerkit-daemon'] === 'true') {
21
28
  return false;
22
29
  }
@@ -29,7 +36,7 @@ function isOrphanedContainer(container, isProcessAlive) {
29
36
  }
30
37
  return !isProcessAlive(pid);
31
38
  }
32
- exports.isOrphanedContainer = isOrphanedContainer;
39
+ exports.isOfStoppedRun = isOfStoppedRun;
33
40
  function isProcessAlive(pid) {
34
41
  try {
35
42
  process.kill(pid, 0);
@@ -6,9 +6,10 @@ const INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
6
6
  // The shell / process environment first, then the .env files. A variable set to
7
7
  // an empty string is set, as in a shell: CI providers leave many empty (a merge
8
8
  // request id on a branch pipeline).
9
- function resolveReference(name, environment, context) {
9
+ function resolveReference(name, environment, context, fromProcess) {
10
10
  const processValue = environment.processEnvs[name];
11
11
  if (processValue !== undefined && processValue !== null) {
12
+ fromProcess?.add(name);
12
13
  return processValue;
13
14
  }
14
15
  for (const envFile of Object.values(context.envFiles)) {
@@ -22,10 +23,11 @@ function resolveReference(name, environment, context) {
22
23
  function buildEnvironmentVariables(envs, environment, context) {
23
24
  const replacements = [];
24
25
  const variables = {};
26
+ const fromProcess = new Set();
25
27
  for (const [key, value] of Object.entries(envs)) {
26
28
  if (value.startsWith('$') && !value.startsWith('${')) {
27
29
  const name = value.substring(1);
28
- const nameValue = resolveReference(name, environment, context);
30
+ const nameValue = resolveReference(name, environment, context, fromProcess);
29
31
  if (nameValue !== null) {
30
32
  replacements.push({ key, name, available: true, value: nameValue });
31
33
  }
@@ -34,7 +36,7 @@ function buildEnvironmentVariables(envs, environment, context) {
34
36
  }
35
37
  }
36
38
  else if (value.includes('${')) {
37
- const interpolated = interpolate(value, envs, environment, context);
39
+ const interpolated = interpolate(value, envs, environment, context, fromProcess);
38
40
  if (interpolated.missing) {
39
41
  replacements.push({ key, name: interpolated.missing, available: false, value: null });
40
42
  }
@@ -46,16 +48,16 @@ function buildEnvironmentVariables(envs, environment, context) {
46
48
  variables[key] = value;
47
49
  }
48
50
  }
49
- return { variables, replacements };
51
+ return { variables, replacements, processNames: [...fromProcess].sort() };
50
52
  }
51
53
  exports.buildEnvironmentVariables = buildEnvironmentVariables;
52
54
  // Each `${NAME}` comes from the process environment or a .env file, else from a
53
55
  // literal value of the same build file / task `envs`, else from its
54
56
  // `:-default`. A reference without any of them is missing.
55
- function interpolate(value, envs, environment, context) {
57
+ function interpolate(value, envs, environment, context, fromProcess) {
56
58
  let missing = null;
57
59
  const result = value.replace(INTERPOLATION, (match, name, fallback) => {
58
- const resolved = resolveReference(name, environment, context) ?? getLiteral(envs, name) ?? fallback ?? null;
60
+ const resolved = resolveReference(name, environment, context, fromProcess) ?? getLiteral(envs, name) ?? fallback ?? null;
59
61
  if (resolved === null) {
60
62
  missing = missing ?? name;
61
63
  return match;
@@ -77,7 +77,8 @@ exports.awaitCompleted = awaitCompleted;
77
77
  async function awaitRunningNeed(work, dep, abort) {
78
78
  const state = await (0, state_resolver_1.isState)('await-running-need-' + work.name, dep.state, isRunningOrDone, abort);
79
79
  if (state.type !== 'running') {
80
- throw new Error(`needed service ${dep.name} ${state.type === 'end' ? 'stopped' : 'failed'} before it was ready`);
80
+ const why = state.type === 'error' ? `: ${state.errorMessage}` : '';
81
+ throw new Error(`needed service ${dep.name} ${state.type === 'end' ? 'stopped' : 'failed'} before it was ready${why}`);
81
82
  }
82
83
  return state;
83
84
  }
@@ -2,8 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.checkReadiness = void 0;
4
4
  const execute_docker_1 = require("./execute-docker");
5
- async function checkReadiness(status, command, environment, container, user, abort) {
6
- const result = await (0, execute_docker_1.execCommand)(status, environment, container, undefined, command, user, 2000, abort);
5
+ async function checkReadiness(status, command, environment, container, user, abort, env) {
6
+ const result = await (0, execute_docker_1.execCommand)(status, environment, container, undefined, command, user, 2000, abort, env);
7
7
  if (result.type === 'timeout') {
8
8
  return false;
9
9
  }