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,407 @@
1
+ "use strict";
2
+ // The egress proxy of a task or service with `network: { allow: [hosts] }`.
3
+ // It runs in its own container (a pod on Kubernetes) next to the item, on a
4
+ // network only the item and the proxy join, and is the item's only way out: HTTP requests and
5
+ // `CONNECT` tunnels to an allowed host pass, everything else gets a 403 and a
6
+ // `refused` event on stdout. For each `host:port` entry it also listens on that
7
+ // port and forwards raw TCP to that host, which the item reaches under the
8
+ // host's name. On a server's cluster it also lets the init containers that read
9
+ // the task's secrets reach the endpoints of their providers: a tunnel to port 443
10
+ // that carries the proxy's token, which those containers get and the task not. The script is passed to `node -e`, so the proxy
11
+ // needs no image of its own, only a stock Node image.
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.formatEgressProxyEvent = exports.formatRefusedHost = exports.parseEgressProxyEvent = exports.getProxylessEnvironment = exports.getProxyClientEnvironment = exports.getEgressProxyPorts = exports.getItemProxyEnvironment = exports.getSecretProxyEnvironment = exports.getEgressProxyEnvironment = exports.getEgressProxyCommand = exports.EGRESS_PROXY_SCRIPT = exports.EGRESS_PROXY_HOST = exports.EGRESS_PROXY_PORT = exports.EGRESS_PROXY_IMAGE = void 0;
14
+ const work_network_1 = require("../planner/work-network");
15
+ const network_schema_1 = require("../schema/network-schema");
16
+ // Pinned, so the code behind the boundary is the same on every host. To bump
17
+ // it, take the index digest of the tag (`docker buildx imagetools inspect
18
+ // node:24-alpine`). The port relay runs in it too.
19
+ exports.EGRESS_PROXY_IMAGE = 'node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1';
20
+ exports.EGRESS_PROXY_PORT = network_schema_1.PROXY_PORT;
21
+ // the name the item reaches the proxy under
22
+ exports.EGRESS_PROXY_HOST = 'hammerkit-proxy';
23
+ // Kept free of anything but Node's own modules. The host rule is the one of
24
+ // matchesHost in planner/work-network.ts. A name a wildcard allows must not
25
+ // resolve to the host or a private network, and none to link-local.
26
+ exports.EGRESS_PROXY_SCRIPT = `
27
+ const http = require('http')
28
+ const net = require('net')
29
+ const dns = require('dns')
30
+ const crypto = require('crypto')
31
+ const entries = JSON.parse(process.env.HAMMERKIT_ALLOW || '[]').map((h) => h.toLowerCase())
32
+ // names and patterns for HTTP(S)
33
+ const allow = entries.filter((e) => !e.includes(':'))
34
+ // names and ports for raw TCP, each forwarded on its port
35
+ const forwards = entries
36
+ .filter((e) => e.includes(':'))
37
+ .map((e) => ({ host: e.substring(0, e.lastIndexOf(':')), port: parseInt(e.substring(e.lastIndexOf(':') + 1), 10) }))
38
+ // entries of both kinds that a ceiling allows only through a pattern: they
39
+ // reach no private address, like a pattern
40
+ const publicOnly = JSON.parse(process.env.HAMMERKIT_PUBLIC_ONLY || '[]').map((h) => h.toLowerCase())
41
+ // the names of the needed services, for tools that ignore NO_PROXY: exact
42
+ // names only, never patterns
43
+ const needs = JSON.parse(process.env.HAMMERKIT_NEEDS || '[]').map((h) => h.toLowerCase())
44
+ // the endpoints of secret providers, for a request with the token only
45
+ const secretAllow = JSON.parse(process.env.HAMMERKIT_SECRET_ALLOW || '[]').map((h) => h.toLowerCase())
46
+ const secretToken = process.env.HAMMERKIT_SECRET_TOKEN || ''
47
+ const port = parseInt(process.env.HAMMERKIT_PROXY_PORT || '${exports.EGRESS_PROXY_PORT}', 10)
48
+ // the name whose address the proxy listens on, every address when unset
49
+ const bind = process.env.HAMMERKIT_PROXY_BIND
50
+ const refused = new Set()
51
+ const httpsUrls = new Set()
52
+ function emit(event) {
53
+ process.stdout.write(JSON.stringify(event) + '\\n')
54
+ }
55
+ // the rule that allows a host and port: its exact name, a wildcard, or none
56
+ function allowedBy(host, port) {
57
+ const name = host.toLowerCase().replace(/\\.$/, '')
58
+ if (needs.includes(name)) {
59
+ return 'exact'
60
+ }
61
+ const named = []
62
+ if (allow.includes(name)) {
63
+ named.push(name)
64
+ }
65
+ if (forwards.some((f) => f.host === name && f.port === port)) {
66
+ named.push(name + ':' + port)
67
+ }
68
+ if (named.length > 0) {
69
+ return named.some((entry) => !publicOnly.includes(entry)) ? 'exact' : 'wildcard'
70
+ }
71
+ return allow.some((rule) => rule.startsWith('*.') && name.endsWith(rule.substring(1))) ? 'wildcard' : null
72
+ }
73
+ // Whether the request carries the token as the password of Proxy-Authorization (Basic).
74
+ function hasSecretToken(req) {
75
+ if (secretToken.length === 0) {
76
+ return false
77
+ }
78
+ const match = /^basic\\s+(\\S+)$/i.exec(String(req.headers['proxy-authorization'] || ''))
79
+ if (!match) {
80
+ return false
81
+ }
82
+ const credentials = Buffer.from(match[1], 'base64').toString('utf8')
83
+ const colon = credentials.indexOf(':')
84
+ if (colon < 0) {
85
+ return false
86
+ }
87
+ const given = Buffer.from(credentials.substring(colon + 1), 'utf8')
88
+ const expected = Buffer.from(secretToken, 'utf8')
89
+ return given.length === expected.length && crypto.timingSafeEqual(given, expected)
90
+ }
91
+ // the rule of a tunnel to an endpoint of a secret provider: like a wildcard, it reaches
92
+ // no private address
93
+ function allowedForSecrets(req, host, port) {
94
+ const name = host.toLowerCase().replace(/\\.$/, '')
95
+ const listed = secretAllow.some((rule) => (rule.startsWith('*.') ? name.endsWith(rule.substring(1)) : name === rule))
96
+ return listed && port === 443 && hasSecretToken(req) ? 'wildcard' : null
97
+ }
98
+ function ipv4(address) {
99
+ const mapped = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/i.exec(address)
100
+ const value = mapped ? mapped[1] : address
101
+ return net.isIPv4(value) ? value.split('.').map(Number) : null
102
+ }
103
+ // never allowed: the cloud metadata service lives there
104
+ function isLinkLocal(address) {
105
+ const v4 = ipv4(address)
106
+ return v4 ? v4[0] === 169 && v4[1] === 254 : /^fe[89ab]/i.test(address)
107
+ }
108
+ // the host, its private networks (Docker's bridges among them) and the
109
+ // cluster: only for a name listed exactly, such as a needed service
110
+ function isInternal(address) {
111
+ const v4 = ipv4(address)
112
+ if (v4) {
113
+ const [a, b] = v4
114
+ return (
115
+ a === 0 ||
116
+ a === 10 ||
117
+ a === 127 ||
118
+ (a === 100 && b >= 64 && b < 128) ||
119
+ (a === 172 && b >= 16 && b < 32) ||
120
+ (a === 192 && b === 168)
121
+ )
122
+ }
123
+ return address === '::' || address === '::1' || /^f[cd]/i.test(address)
124
+ }
125
+ // The proxy resolves the name itself and connects only to the addresses it
126
+ // checked, each in turn (a name can have an IPv6 address the network has no
127
+ // route to).
128
+ function resolve(host, rule, callback) {
129
+ dns.lookup(host, { all: true }, (err, addresses) => {
130
+ if (err || addresses.length === 0) {
131
+ callback(err || new Error('no address'), null)
132
+ return
133
+ }
134
+ const blocked = addresses.some((a) => isLinkLocal(a.address) || (rule !== 'exact' && isInternal(a.address)))
135
+ callback(null, blocked ? null : addresses)
136
+ })
137
+ }
138
+ // connection options that reach the checked addresses and nothing else
139
+ function connectTo(host, addresses) {
140
+ const lookup = (hostname, options, callback) => {
141
+ const cb = typeof options === 'function' ? options : callback
142
+ if (typeof options === 'object' && options && options.all) {
143
+ cb(null, addresses)
144
+ } else {
145
+ cb(null, addresses[0].address, addresses[0].family)
146
+ }
147
+ }
148
+ return { host, lookup, autoSelectFamily: true }
149
+ }
150
+ function refuse(host) {
151
+ if (!refused.has(host)) {
152
+ refused.add(host)
153
+ emit({ type: 'refused', host })
154
+ }
155
+ }
156
+ function httpsUrl(host) {
157
+ if (!httpsUrls.has(host)) {
158
+ httpsUrls.add(host)
159
+ emit({ type: 'https-url', host })
160
+ }
161
+ }
162
+ function parseAuthority(value) {
163
+ const match = /^\\[([^\\]]+)\\](?::(\\d+))?$/.exec(value) || /^([^:]+)(?::(\\d+))?$/.exec(value)
164
+ return match ? { host: match[1], port: match[2] ? parseInt(match[2], 10) : 443 } : null
165
+ }
166
+ const server = http.createServer((req, res) => {
167
+ let url
168
+ try {
169
+ url = new URL(req.url)
170
+ } catch (e) {
171
+ res.writeHead(400, { 'content-type': 'text/plain' })
172
+ res.end('hammerkit: only proxy requests are served\\n')
173
+ return
174
+ }
175
+ // BusyBox wget sends an https URL to the proxy as it is: TLS belongs between
176
+ // the client and the host, so only a CONNECT tunnel reaches an https host
177
+ if (url.protocol === 'https:' && allowedBy(url.hostname, parseInt(url.port || '443', 10))) {
178
+ httpsUrl(url.hostname)
179
+ res.writeHead(501, { 'content-type': 'text/plain' })
180
+ res.end('hammerkit: ' + url.hostname + ' is allowed through a CONNECT tunnel only, not by its https URL\\n')
181
+ return
182
+ }
183
+ const rule = url.protocol === 'http:' ? allowedBy(url.hostname, parseInt(url.port || '80', 10)) : null
184
+ if (!rule) {
185
+ refuse(url.hostname)
186
+ res.writeHead(403, { 'content-type': 'text/plain' })
187
+ res.end('hammerkit: network access to ' + url.hostname + ' is not allowed\\n')
188
+ return
189
+ }
190
+ resolve(url.hostname, rule, (err, addresses) => {
191
+ if (err) {
192
+ res.writeHead(502, { 'content-type': 'text/plain' })
193
+ res.end()
194
+ return
195
+ }
196
+ if (!addresses) {
197
+ refuse(url.hostname)
198
+ res.writeHead(403, { 'content-type': 'text/plain' })
199
+ res.end('hammerkit: ' + url.hostname + ' resolves to an address that is not allowed\\n')
200
+ return
201
+ }
202
+ const headers = Object.assign({}, req.headers)
203
+ delete headers['proxy-connection']
204
+ delete headers['proxy-authorization']
205
+ // the absolute URL wins over a Host header that disagrees
206
+ headers.host = url.host
207
+ const upstream = http.request(
208
+ Object.assign(connectTo(url.hostname, addresses), {
209
+ port: url.port || 80,
210
+ method: req.method,
211
+ path: url.pathname + url.search,
212
+ headers,
213
+ }),
214
+ (response) => {
215
+ res.writeHead(response.statusCode || 502, response.headers)
216
+ response.pipe(res)
217
+ }
218
+ )
219
+ upstream.on('error', () => {
220
+ if (!res.headersSent) {
221
+ res.writeHead(502, { 'content-type': 'text/plain' })
222
+ }
223
+ res.end()
224
+ })
225
+ req.pipe(upstream)
226
+ })
227
+ })
228
+ server.on('connect', (req, socket, head) => {
229
+ const target = parseAuthority(req.url || '')
230
+ socket.on('error', () => socket.destroy())
231
+ const rule = target ? allowedBy(target.host, target.port) || allowedForSecrets(req, target.host, target.port) : null
232
+ if (!rule) {
233
+ refuse(target ? target.host : String(req.url))
234
+ socket.end('HTTP/1.1 403 Forbidden\\r\\ncontent-type: text/plain\\r\\n\\r\\nhammerkit: network access is not allowed\\n')
235
+ return
236
+ }
237
+ resolve(target.host, rule, (err, addresses) => {
238
+ if (err) {
239
+ socket.end('HTTP/1.1 502 Bad Gateway\\r\\n\\r\\n')
240
+ return
241
+ }
242
+ if (!addresses) {
243
+ refuse(target.host)
244
+ socket.end('HTTP/1.1 403 Forbidden\\r\\ncontent-type: text/plain\\r\\n\\r\\nhammerkit: network access is not allowed\\n')
245
+ return
246
+ }
247
+ let established = false
248
+ const upstream = net.connect(Object.assign(connectTo(target.host, addresses), { port: target.port }), () => {
249
+ established = true
250
+ socket.write('HTTP/1.1 200 Connection Established\\r\\n\\r\\n')
251
+ if (head && head.length > 0) {
252
+ upstream.write(head)
253
+ }
254
+ upstream.pipe(socket)
255
+ socket.pipe(upstream)
256
+ })
257
+ upstream.on('error', () => {
258
+ if (established) {
259
+ socket.destroy()
260
+ } else {
261
+ socket.end('HTTP/1.1 502 Bad Gateway\\r\\n\\r\\n')
262
+ }
263
+ })
264
+ socket.on('close', () => upstream.destroy())
265
+ })
266
+ })
267
+ // A TCP forward: each connection goes to the checked addresses of its host,
268
+ // on the same port.
269
+ function forward(target) {
270
+ return net.createServer({ allowHalfOpen: true }, (client) => {
271
+ client.on('error', () => client.destroy())
272
+ resolve(target.host, allowedBy(target.host, target.port), (err, addresses) => {
273
+ if (err || !addresses) {
274
+ if (!err) {
275
+ refuse(target.host)
276
+ }
277
+ client.destroy()
278
+ return
279
+ }
280
+ const upstream = net.connect(Object.assign(connectTo(target.host, addresses), { port: target.port }))
281
+ upstream.on('error', () => client.destroy())
282
+ client.on('close', () => upstream.destroy())
283
+ client.pipe(upstream)
284
+ upstream.pipe(client)
285
+ })
286
+ })
287
+ }
288
+ function listen(address) {
289
+ const servers = [{ server, port }].concat(forwards.map((f) => ({ server: forward(f), port: f.port })))
290
+ let pending = servers.length
291
+ for (const entry of servers) {
292
+ entry.server.on('error', (err) => {
293
+ process.stderr.write('hammerkit: unable to listen on port ' + entry.port + ': ' + err.message + '\\n')
294
+ process.exit(1)
295
+ })
296
+ entry.server.listen(entry.port, address, () => {
297
+ pending--
298
+ if (pending === 0) {
299
+ emit({ type: 'ready' })
300
+ }
301
+ })
302
+ }
303
+ }
304
+ if (bind) {
305
+ dns.lookup(bind, { family: 4 }, (err, address) => {
306
+ if (err) {
307
+ process.stderr.write('hammerkit: unable to resolve ' + bind + ': ' + err.message + '\\n')
308
+ process.exit(1)
309
+ }
310
+ listen(address)
311
+ })
312
+ } else {
313
+ listen(undefined)
314
+ }
315
+ process.on('SIGTERM', () => process.exit(0))
316
+ `;
317
+ function getEgressProxyCommand() {
318
+ return ['node', '-e', exports.EGRESS_PROXY_SCRIPT];
319
+ }
320
+ exports.getEgressProxyCommand = getEgressProxyCommand;
321
+ // `needs` are the names of the needed services, allowed as exactly those
322
+ // names. `bind` is the name the proxy listens on alone, so that nothing on its
323
+ // other networks reaches it. `publicOnly` are the entries of `hosts` that reach
324
+ // no private address (see WorkNetwork). The endpoints of secret providers and
325
+ // the token they need are set by the caller (getSecretProxyEnvironment).
326
+ function getEgressProxyEnvironment(hosts, needs, bind, publicOnly = []) {
327
+ return {
328
+ HAMMERKIT_ALLOW: JSON.stringify(hosts),
329
+ ...(publicOnly.length > 0 ? { HAMMERKIT_PUBLIC_ONLY: JSON.stringify(publicOnly) } : {}),
330
+ HAMMERKIT_NEEDS: JSON.stringify(needs),
331
+ HAMMERKIT_PROXY_PORT: `${exports.EGRESS_PROXY_PORT}`,
332
+ ...(bind ? { HAMMERKIT_PROXY_BIND: bind } : {}),
333
+ };
334
+ }
335
+ exports.getEgressProxyEnvironment = getEgressProxyEnvironment;
336
+ // The endpoints of secret providers the proxy lets a tunnel with its token reach; the
337
+ // token itself is set where the proxy runs, see HAMMERKIT_SECRET_TOKEN.
338
+ function getSecretProxyEnvironment(hosts) {
339
+ return hosts.length > 0 ? { HAMMERKIT_SECRET_ALLOW: JSON.stringify(hosts) } : {};
340
+ }
341
+ exports.getSecretProxyEnvironment = getSecretProxyEnvironment;
342
+ // The proxy of a task or service: the hosts of its network in this run (under
343
+ // the run's ceiling), and the names of the services it needs, on every runtime.
344
+ function getItemProxyEnvironment(item, bind) {
345
+ const network = (0, work_network_1.getEffectiveNetwork)(item.data);
346
+ return getEgressProxyEnvironment(network.type === 'allow' ? network.hosts : [], item.needs.map((need) => need.name), bind, network.type === 'allow' ? network.publicOnly ?? [] : []);
347
+ }
348
+ exports.getItemProxyEnvironment = getItemProxyEnvironment;
349
+ // The variables that send the item's HTTP(S) through the proxy, both spellings
350
+ // since tools read either. The services it needs are reached directly.
351
+ // The ports the proxy of a network listens on: its own, and one per TCP forward.
352
+ function getEgressProxyPorts(network) {
353
+ return [exports.EGRESS_PROXY_PORT, ...(0, work_network_1.getTcpForwards)(network).map((forward) => forward.port)];
354
+ }
355
+ exports.getEgressProxyPorts = getEgressProxyPorts;
356
+ // `token` is sent as the password, for the endpoints of secret providers.
357
+ function getProxyClientEnvironment(directHosts, token) {
358
+ const proxy = `http://${token !== undefined ? `hammerkit:${token}@` : ''}${exports.EGRESS_PROXY_HOST}:${exports.EGRESS_PROXY_PORT}`;
359
+ const noProxy = ['localhost', '127.0.0.1', ...directHosts].join(',');
360
+ return {
361
+ HTTP_PROXY: proxy,
362
+ HTTPS_PROXY: proxy,
363
+ NO_PROXY: noProxy,
364
+ http_proxy: proxy,
365
+ https_proxy: proxy,
366
+ no_proxy: noProxy,
367
+ };
368
+ }
369
+ exports.getProxyClientEnvironment = getProxyClientEnvironment;
370
+ // The proxy variables emptied: a healthcheck checks the service itself, and
371
+ // tools that ignore NO_PROXY (busybox wget) would ask the proxy for localhost.
372
+ function getProxylessEnvironment(env) {
373
+ const proxyKeys = Object.keys(env).filter((key) => /^(https?|no)_proxy$/i.test(key));
374
+ return proxyKeys.length > 0 ? Object.fromEntries(proxyKeys.map((key) => [key, ''])) : undefined;
375
+ }
376
+ exports.getProxylessEnvironment = getProxylessEnvironment;
377
+ // One line of the proxy's stdout, null for anything else.
378
+ function parseEgressProxyEvent(line) {
379
+ try {
380
+ const value = JSON.parse(line);
381
+ if (value?.type === 'ready') {
382
+ return { type: 'ready' };
383
+ }
384
+ if ((value?.type === 'refused' || value?.type === 'https-url') && typeof value.host === 'string') {
385
+ return { type: value.type, host: value.host };
386
+ }
387
+ }
388
+ catch {
389
+ // not an event
390
+ }
391
+ return null;
392
+ }
393
+ exports.parseEgressProxyEvent = parseEgressProxyEvent;
394
+ function formatRefusedHost(host) {
395
+ return `network: refused ${host}`;
396
+ }
397
+ exports.formatRefusedHost = formatRefusedHost;
398
+ // The line of an event other than `ready` in the item's log.
399
+ function formatEgressProxyEvent(event) {
400
+ if (event.type === 'https-url') {
401
+ return (`network: ${event.host} is allowed, but the client asked the proxy for its https URL instead of ` +
402
+ 'tunneling with CONNECT (BusyBox wget does), use curl or GNU wget');
403
+ }
404
+ return formatRefusedHost(event.host);
405
+ }
406
+ exports.formatEgressProxyEvent = formatEgressProxyEvent;
407
+ //# sourceMappingURL=egress-proxy.js.map
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ // The published ports of a service with a limited network. Docker publishes
3
+ // ports only on a network with a way out, which a limited service must not
4
+ // have, so a relay container on the default bridge publishes them and passes
5
+ // each connection on to the service over the service's internal network.
6
+ // Like the egress proxy, it is a script for `node -e` in a stock Node image.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.getPortRelayEnvironment = exports.getPortRelayCommand = exports.PORT_RELAY_SCRIPT = void 0;
9
+ // Kept free of anything but Node's own modules.
10
+ exports.PORT_RELAY_SCRIPT = `
11
+ const net = require('net')
12
+ const targets = JSON.parse(process.env.HAMMERKIT_RELAY || '[]')
13
+ for (const target of targets) {
14
+ // a client that ends its side still gets the answer
15
+ net
16
+ .createServer({ allowHalfOpen: true }, (client) => {
17
+ const upstream = net.connect(target.targetPort, target.host)
18
+ client.on('error', () => upstream.destroy())
19
+ upstream.on('error', () => client.destroy())
20
+ client.pipe(upstream)
21
+ upstream.pipe(client)
22
+ })
23
+ .listen(target.port)
24
+ }
25
+ process.on('SIGTERM', () => process.exit(0))
26
+ `;
27
+ function getPortRelayCommand() {
28
+ return ['node', '-e', exports.PORT_RELAY_SCRIPT];
29
+ }
30
+ exports.getPortRelayCommand = getPortRelayCommand;
31
+ function getPortRelayEnvironment(targets) {
32
+ return { HAMMERKIT_RELAY: JSON.stringify(targets) };
33
+ }
34
+ exports.getPortRelayEnvironment = getPortRelayEnvironment;
35
+ //# sourceMappingURL=port-relay.js.map
@@ -4,6 +4,7 @@ exports.getStateKey = exports.getWorkItemCacheStats = exports.getWorkCacheStats
4
4
  const calculate_checksum_1 = require("./calculate-checksum");
5
5
  const path_1 = require("path");
6
6
  const crypto_1 = require("crypto");
7
+ const promises_1 = require("fs/promises");
7
8
  function isExcluded(path, excluded) {
8
9
  return excluded.some((excludedPath) => path === excludedPath || path.startsWith(excludedPath + path_1.sep));
9
10
  }
@@ -11,6 +12,21 @@ async function addWorkCacheStats(result, cwd, path, matcher, context, root, excl
11
12
  if (isExcluded(path, excluded)) {
12
13
  return 0;
13
14
  }
15
+ // On a server the files were written by a commit or a task: a link is a source as it is,
16
+ // its target, and never followed. A `**` would walk `x -> /` through the server's disk.
17
+ if (context.hosted) {
18
+ const link = await (0, promises_1.lstat)(path).catch(() => undefined);
19
+ if (link?.isSymbolicLink()) {
20
+ if (!matcher(path)) {
21
+ return 0;
22
+ }
23
+ const checksum = (0, crypto_1.createHash)('sha1')
24
+ .update(`link:${await (0, promises_1.readlink)(path)}`)
25
+ .digest('hex');
26
+ result.files[(0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep)] = { lastModified: link.mtimeMs, checksum };
27
+ return 1;
28
+ }
29
+ }
14
30
  const exists = await context.file.exists(path);
15
31
  if (!exists) {
16
32
  return 0;
@@ -6,6 +6,7 @@ const crypto_1 = require("crypto");
6
6
  const replace_env_variables_1 = require("../environment/replace-env-variables");
7
7
  const portable_path_1 = require("../planner/utils/portable-path");
8
8
  const work_secret_1 = require("../planner/work-secret");
9
+ const claim_layout_1 = require("../kubernetes/claim-layout");
9
10
  // Every path is made project-relative (portablePath) so the description, and the
10
11
  // task id hashed from it, is identical wherever the project is checked out.
11
12
  //
@@ -24,8 +25,9 @@ function getWorkTaskCacheDescription(item) {
24
25
  shell: task.shell ?? undefined,
25
26
  platform: task.type === 'container-task' ? task.image : (0, os_1.platform)(),
26
27
  // outputs hold native binaries, and docker pulls images for the host
27
- // architecture, so container tasks are keyed by it too
28
- arch: process.arch,
28
+ // architecture, so container tasks are keyed by it too; on a cluster it is the
29
+ // one of the nodes the task runs on
30
+ arch: (task.type === 'container-task' && task.arch) || process.arch,
29
31
  generates: task.generates.map((g) => portable(g.path)).sort() ?? undefined,
30
32
  src: task.src.map((s) => s.source).sort() ?? undefined,
31
33
  envs: Object.keys(envs)
@@ -39,6 +41,7 @@ function getWorkTaskCacheDescription(item) {
39
41
  cwd: portable(task.cwd),
40
42
  deps: item.deps.length > 0 ? item.deps.map((dep) => dep.id()).sort() : undefined,
41
43
  secrets: cachedSecrets.length > 0 ? cachedSecrets : undefined,
44
+ claimLayout: (0, claim_layout_1.hasPathsEndingAlike)(item) || (0, claim_layout_1.hasOutputsFlatteningAlike)(item) ? 2 : undefined,
42
45
  };
43
46
  }
44
47
  exports.getWorkTaskCacheDescription = getWorkTaskCacheDescription;
@@ -4,6 +4,7 @@ exports.getServiceDefinitionHash = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const work_secret_1 = require("./work-secret");
6
6
  const work_service_1 = require("./work-service");
7
+ const work_network_1 = require("./work-network");
7
8
  const work_cache_id_1 = require("./work-cache-id");
8
9
  // Everything a running container of the service was created from, plus the
9
10
  // definitions of the services it needs: a dependent links to the container it
@@ -12,6 +13,7 @@ const work_cache_id_1 = require("./work-cache-id");
12
13
  // service whose definition changed instead of silently reusing it.
13
14
  function getServiceDefinitionHash(item) {
14
15
  const service = item.data;
16
+ const network = (0, work_network_1.getEffectiveNetwork)(service);
15
17
  const definition = {
16
18
  image: service.image,
17
19
  cmd: service.cmd?.cmd ?? null,
@@ -38,6 +40,9 @@ function getServiceDefinitionHash(item) {
38
40
  .map((need) => `${need.name}=${getServiceDefinitionHash(need.service)}`),
39
41
  // only when declared, so a service without keeps its hash
40
42
  ...(service.resources ? { resources: service.resources } : {}),
43
+ // in this run, under its `--network` ceiling: a service started open is
44
+ // recreated for a run that limits it
45
+ ...((0, work_network_1.isLimitedNetwork)(network) ? { network } : {}),
41
46
  };
42
47
  return (0, crypto_1.createHash)('sha1').update(JSON.stringify(definition)).digest('hex');
43
48
  }
@@ -19,7 +19,10 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
19
19
  }
20
20
  if (((0, work_task_1.isContainerWorkTask)(depNode.data) || (0, work_service_1.isContainerWorkService)(depNode.data)) &&
21
21
  ((0, work_task_1.isContainerWorkTask)(item.data) || (0, work_service_1.isContainerWorkService)(item.data))) {
22
+ // the outputs of the dep's own deps too, which stay those of the task that made them
23
+ // (on Kubernetes, they are in its claim)
22
24
  for (const generate of depNode.data.generates) {
25
+ const inherited = generate.inherited ?? depNode;
23
26
  if (generate.isFile) {
24
27
  // TODO check if thats correct
25
28
  continue;
@@ -31,7 +34,7 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
31
34
  export: false,
32
35
  resetOnChange: false,
33
36
  containerPath: generate.path,
34
- inherited: depNode,
37
+ inherited,
35
38
  readOnly: false,
36
39
  });
37
40
  }
@@ -44,7 +47,7 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
44
47
  exportAlways: false,
45
48
  resetOnChange: false,
46
49
  path: generate.path,
47
- inherited: depNode,
50
+ inherited,
48
51
  isFile: false,
49
52
  });
50
53
  }
@@ -15,6 +15,8 @@ function appendWorkEnvironment(referenced) {
15
15
  kubeConfig: referenced.schema.kubernetes.kubeconfig,
16
16
  httpRoutes: referenced.schema.kubernetes.httpRoutes || [],
17
17
  namespace: referenced.schema.kubernetes.namespace ?? 'default',
18
+ networkPolicyEnforced: referenced.schema.kubernetes.networkPolicy === 'enforced',
19
+ ...(referenced.schema.kubernetes.volumeSize ? { volumeSize: referenced.schema.kubernetes.volumeSize } : {}),
18
20
  };
19
21
  }
20
22
  }
@@ -23,6 +23,7 @@ const resolve_cache_1 = require("../../cache/resolve-cache");
23
23
  const units_1 = require("../../utils/units");
24
24
  const log_1 = require("../../log");
25
25
  const work_resources_1 = require("../work-resources");
26
+ const work_network_1 = require("../work-network");
26
27
  function appendWorkService(workTree, service, environment, context) {
27
28
  const workService = parseService(workTree, service, environment, context);
28
29
  if (!workTree.services[workService.name]) {
@@ -116,6 +117,10 @@ function parseService(workTree, service, environment, context) {
116
117
  init: null,
117
118
  secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment, context.secrets, service.schema.account),
118
119
  resources: (0, work_resources_1.parseWorkResources)(service.schema.resources),
120
+ volumeSize: service.schema.volumeSize ?? null,
121
+ network: (0, work_network_1.parseWorkNetwork)(service.schema.network),
122
+ networkCeiling: null,
123
+ args: service.schema.args ?? null,
119
124
  };
120
125
  }
121
126
  }
@@ -19,8 +19,17 @@ const get_work_runtime_1 = require("./get-work-runtime");
19
19
  const resolve_cache_1 = require("../../cache/resolve-cache");
20
20
  const units_1 = require("../../utils/units");
21
21
  const work_resources_1 = require("../work-resources");
22
+ const job_placement_1 = require("../../kubernetes/job-placement");
23
+ const work_network_1 = require("../work-network");
22
24
  function appendWorkTask(workTree, cwd, referenceTask, environment, context) {
23
- const task = parseTask(cwd, referenceTask, environment, context);
25
+ const parsed = parseTask(cwd, referenceTask, environment, context);
26
+ // on a server's cluster a local task runs in a tool image like a container task
27
+ const server = workTree.environment.type === 'kubernetes' ? workTree.environment.server : undefined;
28
+ const task = !server
29
+ ? parsed
30
+ : parsed.type === 'local-task'
31
+ ? (0, job_placement_1.runInToolImage)(parsed, server, referenceTask.schema.platform)
32
+ : (0, job_placement_1.placeOnPool)(parsed, server);
24
33
  if (!workTree.tasks[task.name]) {
25
34
  const workItem = {
26
35
  // A dependency cycle is rejected before anything runs (checkForLoop), but
@@ -72,6 +81,9 @@ function parseTask(cwd, task, environment, context) {
72
81
  timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
73
82
  secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment, context.secrets, task.schema.account),
74
83
  resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
84
+ network: (0, work_network_1.parseWorkNetwork)(task.schema.network),
85
+ networkCeiling: null,
86
+ args: task.schema.args ?? null,
75
87
  runner: task.schema.runner ?? task.scope.schema.runner ?? null,
76
88
  };
77
89
  if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
@@ -81,6 +93,7 @@ function parseTask(cwd, task, environment, context) {
81
93
  user: (0, get_container_user_1.getContainerUser)(),
82
94
  image: (0, template_value_1.templateValue)(task.schema.image, envs),
83
95
  mounts: (0, parse_work_mounts_1.parseWorkMounts)(cwd, task.schema, envs),
96
+ volumeSize: task.schema.volumeSize ?? null,
84
97
  };
85
98
  }
86
99
  else {
@@ -88,6 +101,12 @@ function parseTask(cwd, task, environment, context) {
88
101
  if (fileSecret) {
89
102
  throw new Error(`task ${task.relativeName}: a local task takes secrets as env only (${(0, work_secret_1.getSecretName)(fileSecret)})`);
90
103
  }
104
+ // on a server's cluster it runs in a pod of a pool's tool image (runInToolImage)
105
+ const contained = !!environment.hosted?.kubernetes || !!environment.hosted?.localTasksInContainers;
106
+ if (baseWorkNode.network.type !== 'open' && !contained) {
107
+ throw new Error(`task ${task.relativeName}: network ${(0, work_network_1.formatNetwork)(baseWorkNode.network)} needs a container, ` +
108
+ `hammerkit can't limit the network of a local task`);
109
+ }
91
110
  return {
92
111
  ...baseWorkNode,
93
112
  type: 'local-task',