hammerkit 1.25.0 → 1.27.0-next.1

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 (123) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/container-task-state.js +8 -6
  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 +4 -0
  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 +373 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +31 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
  34. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  35. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  36. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  37. package/dist/kubernetes/get-service-ip.js +15 -1
  38. package/dist/kubernetes/job-placement.js +99 -0
  39. package/dist/kubernetes/job-secrets.js +306 -0
  40. package/dist/kubernetes/kept-claims.js +191 -0
  41. package/dist/kubernetes/kubernetes-instance.js +10 -2
  42. package/dist/kubernetes/network-gate.js +71 -0
  43. package/dist/kubernetes/network-policy.js +526 -0
  44. package/dist/kubernetes/resources.js +13 -2
  45. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  46. package/dist/kubernetes/secrets.js +2 -0
  47. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  48. package/dist/kubernetes/volumes.js +100 -11
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +8 -2
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/utils/task-arguments.js +155 -0
  61. package/dist/planner/work-cache-id.js +15 -1
  62. package/dist/planner/work-network.js +193 -0
  63. package/dist/planner/work-runtime-docker.js +43 -0
  64. package/dist/planner/work-runtime-kubernetes.js +336 -102
  65. package/dist/planner/work-runtime-local.js +16 -8
  66. package/dist/planner/work-service.js +7 -3
  67. package/dist/planner/work-state-record.js +30 -0
  68. package/dist/program.js +80 -7
  69. package/dist/remote/annotations.js +103 -0
  70. package/dist/remote/bundle-text.js +24 -0
  71. package/dist/remote/bundle.js +9 -1
  72. package/dist/remote/remote-commands.js +4 -1
  73. package/dist/remote/remote-work-commands.js +182 -69
  74. package/dist/remote/server-client.js +18 -5
  75. package/dist/remote/source-manifest.js +15 -2
  76. package/dist/remote/token-source.js +67 -0
  77. package/dist/schema/build-file-base-task-schema.js +3 -0
  78. package/dist/schema/build-file-container-service-schema.js +6 -0
  79. package/dist/schema/build-file-container-task-schema.js +3 -0
  80. package/dist/schema/build-file-environment-schema.js +9 -0
  81. package/dist/schema/network-schema.js +78 -0
  82. package/dist/schema/reference-parser.js +1 -0
  83. package/dist/schema/schema-parser.js +16 -3
  84. package/dist/schema/work-scope-parser.js +13 -0
  85. package/dist/secrets/command-secret-provider.js +73 -10
  86. package/dist/secrets/github-app-token-command.js +23 -0
  87. package/dist/secrets/github-app-token.js +191 -0
  88. package/dist/server/accounts-api.js +9 -2
  89. package/dist/server/definitions-api.js +69 -47
  90. package/dist/server/definitions.js +76 -51
  91. package/dist/server/executor.js +156 -41
  92. package/dist/server/git-checkout.js +72 -8
  93. package/dist/server/kubernetes-backend.js +554 -0
  94. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  95. package/dist/server/oauth.js +1 -0
  96. package/dist/server/plan.js +51 -1
  97. package/dist/server/policy.js +104 -15
  98. package/dist/server/proposals-api.js +24 -40
  99. package/dist/server/proposals.js +12 -25
  100. package/dist/server/repo-access.js +37 -6
  101. package/dist/server/run-logs.js +217 -0
  102. package/dist/server/run-plan.js +51 -2
  103. package/dist/server/run-request.js +23 -10
  104. package/dist/server/run-store.js +5 -0
  105. package/dist/server/run-worker-command.js +13 -1
  106. package/dist/server/run-worker.js +121 -24
  107. package/dist/server/runs.js +184 -10
  108. package/dist/server/sandbox-docker.js +62 -0
  109. package/dist/server/sandbox-files-api.js +269 -0
  110. package/dist/server/sandbox-files.js +1054 -0
  111. package/dist/server/sandbox-patch.js +481 -0
  112. package/dist/server/sandbox-pod-command.js +15 -0
  113. package/dist/server/sandbox-pod.js +146 -0
  114. package/dist/server/sandbox-storage.js +462 -0
  115. package/dist/server/sandboxes-api.js +210 -0
  116. package/dist/server/sandboxes.js +519 -0
  117. package/dist/server/server-config.js +412 -22
  118. package/dist/server/server.js +204 -39
  119. package/dist/utils/run-id.js +12 -0
  120. package/harness/opencode.ts +11 -0
  121. package/harness/pi.ts +7 -0
  122. package/package.json +8 -2
  123. package/dist/remote/text-diff.js +0 -89
package/build.schema.json CHANGED
@@ -261,6 +261,34 @@
261
261
  "additionalProperties": false,
262
262
  "description": "cpu and memory requests and limits"
263
263
  },
264
+ "network": {
265
+ "anyOf": [
266
+ {
267
+ "type": "string",
268
+ "const": "open"
269
+ },
270
+ {
271
+ "type": "string",
272
+ "const": "none"
273
+ },
274
+ {
275
+ "type": "object",
276
+ "properties": {
277
+ "allow": {
278
+ "type": "array",
279
+ "items": {
280
+ "type": "string"
281
+ }
282
+ }
283
+ },
284
+ "required": [
285
+ "allow"
286
+ ],
287
+ "additionalProperties": false
288
+ }
289
+ ],
290
+ "description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
291
+ },
264
292
  "runner": {
265
293
  "type": "string",
266
294
  "minLength": 1
@@ -273,6 +301,10 @@
273
301
  "items": {
274
302
  "type": "string"
275
303
  }
304
+ },
305
+ "volumeSize": {
306
+ "type": "string",
307
+ "description": "size of its claim on Kubernetes, e.g. 10Gi"
276
308
  }
277
309
  },
278
310
  "required": [
@@ -335,6 +367,9 @@
335
367
  "resources": {
336
368
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources"
337
369
  },
370
+ "network": {
371
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network"
372
+ },
338
373
  "runner": {
339
374
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
340
375
  },
@@ -468,6 +503,14 @@
468
503
  "resources": {
469
504
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources",
470
505
  "description": "cpu and memory requests and limits"
506
+ },
507
+ "volumeSize": {
508
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/volumeSize",
509
+ "description": "size of its claim on Kubernetes, e.g. 10Gi"
510
+ },
511
+ "network": {
512
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network",
513
+ "description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
471
514
  }
472
515
  },
473
516
  "required": [
@@ -630,6 +673,15 @@
630
673
  "additionalProperties": false
631
674
  }
632
675
  },
676
+ "networkPolicy": {
677
+ "type": "string",
678
+ "const": "enforced",
679
+ "description": "the cluster enforces NetworkPolicy, which limited networks need"
680
+ },
681
+ "volumeSize": {
682
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/volumeSize",
683
+ "description": "size of the claim of each task and service, 1Gi by default"
684
+ },
633
685
  "ingresses": {
634
686
  "not": {}
635
687
  }
@@ -750,7 +802,7 @@
750
802
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/timeout"
751
803
  },
752
804
  "maxSize": {
753
- "type": "string"
805
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/volumeSize"
754
806
  },
755
807
  "keepPerTask": {
756
808
  "type": "integer",
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createLocalCacheBackend = void 0;
3
+ exports.createLocalCacheBackend = exports.getDefaultLocalCachePath = void 0;
4
4
  const path_1 = require("path");
5
5
  const os_1 = require("os");
6
6
  const promises_1 = require("fs/promises");
@@ -16,8 +16,16 @@ async function directories(path) {
16
16
  return [];
17
17
  }
18
18
  }
19
+ // Below the home directory, or below `HAMMERKIT_DIR` when that is set: a server sets
20
+ // it per source trust, so the runs of one repository share their cache and nobody
21
+ // else's run finds it (a run's home directory is its own and goes with it).
22
+ function getDefaultLocalCachePath() {
23
+ const directory = process.env.HAMMERKIT_DIR;
24
+ return directory ? (0, path_1.join)(directory, 'remote-cache') : (0, path_1.join)((0, os_1.homedir)(), '.hammerkit', 'remote-cache');
25
+ }
26
+ exports.getDefaultLocalCachePath = getDefaultLocalCachePath;
19
27
  function createLocalCacheBackend(spec) {
20
- const root = spec.path ?? (0, path_1.join)((0, os_1.homedir)(), '.hammerkit', 'remote-cache');
28
+ const root = spec.path ?? getDefaultLocalCachePath();
21
29
  async function readEntry(taskId, stateKey) {
22
30
  const dir = (0, path_1.join)(root, taskId, stateKey);
23
31
  let created;
@@ -121,6 +121,19 @@ async function explainTask(item, defaultCacheMethod, environment, memo = new Map
121
121
  return { taskId: item.id(), taskName: item.name, status: 'miss', causes: [{ kind: 'never-cached' }] };
122
122
  }
123
123
  visiting.add(item.id());
124
+ // Like the scheduler, a task whose cache is off is never looked up, so its
125
+ // sources aren't hashed for it.
126
+ if ((0, state_key_1.resolveEffective)(item, defaultCacheMethod).method === 'none') {
127
+ const explanation = {
128
+ taskId: item.id(),
129
+ taskName: item.name,
130
+ status: 'uncacheable',
131
+ causes: [{ kind: 'caching-disabled' }],
132
+ };
133
+ memo.set(item.id(), explanation);
134
+ visiting.delete(item.id());
135
+ return explanation;
136
+ }
124
137
  const { stateKey, stats, resolved, provable, unmatched } = await (0, state_key_1.computeStateKey)(item, defaultCacheMethod, environment);
125
138
  // Mirrors the scheduler: a task that cannot be proven up to date always runs
126
139
  // (see computeStateKey), whatever a cache holds for it.
@@ -144,17 +157,6 @@ async function explainTask(item, defaultCacheMethod, environment, memo = new Map
144
157
  visiting.delete(item.id());
145
158
  return explanation;
146
159
  }
147
- if (resolved.method === 'none') {
148
- const explanation = {
149
- taskId: item.id(),
150
- taskName: item.name,
151
- status: 'uncacheable',
152
- causes: [{ kind: 'caching-disabled' }],
153
- };
154
- memo.set(item.id(), explanation);
155
- visiting.delete(item.id());
156
- return explanation;
157
- }
158
160
  const runtimeStateKey = await safeCurrentStateKey(item, environment);
159
161
  const hasEntry = await safeHas(item, stateKey, environment);
160
162
  const predictedHit = runtimeStateKey === stateKey || hasEntry;
package/dist/cli.js CHANGED
@@ -23,6 +23,7 @@ const dry_run_1 = require("./executer/dry-run");
23
23
  const cache_sync_1 = require("./cache/cache-sync");
24
24
  const cache_inventory_1 = require("./cache/cache-inventory");
25
25
  const prefetch_secrets_1 = require("./secrets/prefetch-secrets");
26
+ const work_network_1 = require("./planner/work-network");
26
27
  const isCliTask = (val) => val.type === 'task';
27
28
  exports.isCliTask = isCliTask;
28
29
  const isCliService = (val) => val.type === 'service';
@@ -50,6 +51,7 @@ class Cli {
50
51
  return {
51
52
  state: processWorkTree,
52
53
  start: async () => {
54
+ (0, work_network_1.applyNetworkCeiling)(workTree, type === 'down' ? null : options?.network ?? null);
53
55
  if (type !== 'down') {
54
56
  await this.prepareSecrets(true);
55
57
  }
@@ -80,6 +82,7 @@ class Cli {
80
82
  skipDeps: options?.skipDeps ?? true,
81
83
  wait: options?.wait ?? 'ready',
82
84
  resourceCheck: options?.resourceCheck ?? true,
85
+ network: options?.network ?? null,
83
86
  });
84
87
  }
85
88
  const result = (0, get_scheduler_execute_result_1.getSchedulerExecuteResult)(workTree);
@@ -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
  }