hammerkit 1.26.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 (117) 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/docker-service.js +24 -13
  12. package/dist/executer/docker-task.js +18 -29
  13. package/dist/executer/event-cache.js +4 -0
  14. package/dist/executer/execute-command.js +18 -5
  15. package/dist/executer/execute-docker.js +4 -1
  16. package/dist/executer/execution-steps.js +33 -7
  17. package/dist/executer/get-docker-executor.js +14 -7
  18. package/dist/executer/scheduler/enqueue-next.js +17 -3
  19. package/dist/executer/watch-loop.js +2 -2
  20. package/dist/executer/watch-state-key.js +1 -1
  21. package/dist/file/within-root.js +42 -0
  22. package/dist/harness/connect.js +99 -0
  23. package/dist/harness/format.js +73 -0
  24. package/dist/harness/mcp-command.js +59 -0
  25. package/dist/harness/mcp.js +432 -0
  26. package/dist/harness/opencode.js +492 -0
  27. package/dist/harness/pi.js +217 -0
  28. package/dist/harness/sandbox-client.js +373 -0
  29. package/dist/index.js +33 -36
  30. package/dist/kubernetes/apply.js +31 -1
  31. package/dist/kubernetes/await-running-state.js +33 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  35. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  36. package/dist/kubernetes/get-service-ip.js +15 -1
  37. package/dist/kubernetes/job-placement.js +99 -0
  38. package/dist/kubernetes/job-secrets.js +306 -0
  39. package/dist/kubernetes/kept-claims.js +191 -0
  40. package/dist/kubernetes/kubernetes-instance.js +10 -2
  41. package/dist/kubernetes/network-gate.js +71 -0
  42. package/dist/kubernetes/network-policy.js +526 -0
  43. package/dist/kubernetes/resources.js +13 -2
  44. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  45. package/dist/kubernetes/secrets.js +2 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  47. package/dist/kubernetes/volumes.js +100 -11
  48. package/dist/main.js +41 -0
  49. package/dist/network/egress-proxy.js +368 -0
  50. package/dist/network/port-relay.js +35 -0
  51. package/dist/optimizer/get-work-cache-stats.js +16 -0
  52. package/dist/optimizer/work-task-cache-description.js +3 -2
  53. package/dist/planner/service-definition.js +5 -0
  54. package/dist/planner/utils/append-work-environment.js +2 -0
  55. package/dist/planner/utils/append-work-service.js +4 -0
  56. package/dist/planner/utils/append-work-task.js +17 -1
  57. package/dist/planner/utils/assert-item-paths.js +50 -0
  58. package/dist/planner/utils/hosted-volumes.js +35 -0
  59. package/dist/planner/work-network.js +193 -0
  60. package/dist/planner/work-runtime-docker.js +41 -0
  61. package/dist/planner/work-runtime-kubernetes.js +335 -102
  62. package/dist/program.js +21 -0
  63. package/dist/remote/annotations.js +103 -0
  64. package/dist/remote/bundle-text.js +24 -0
  65. package/dist/remote/bundle.js +9 -1
  66. package/dist/remote/remote-commands.js +4 -1
  67. package/dist/remote/remote-work-commands.js +182 -69
  68. package/dist/remote/server-client.js +18 -5
  69. package/dist/remote/source-manifest.js +15 -2
  70. package/dist/remote/token-source.js +67 -0
  71. package/dist/schema/build-file-base-task-schema.js +3 -0
  72. package/dist/schema/build-file-container-service-schema.js +6 -0
  73. package/dist/schema/build-file-container-task-schema.js +3 -0
  74. package/dist/schema/build-file-environment-schema.js +9 -0
  75. package/dist/schema/network-schema.js +78 -0
  76. package/dist/schema/reference-parser.js +1 -0
  77. package/dist/schema/schema-parser.js +16 -3
  78. package/dist/schema/work-scope-parser.js +5 -0
  79. package/dist/secrets/command-secret-provider.js +73 -10
  80. package/dist/secrets/github-app-token-command.js +23 -0
  81. package/dist/secrets/github-app-token.js +191 -0
  82. package/dist/server/accounts-api.js +9 -2
  83. package/dist/server/definitions-api.js +69 -47
  84. package/dist/server/definitions.js +76 -51
  85. package/dist/server/executor.js +156 -41
  86. package/dist/server/git-checkout.js +72 -8
  87. package/dist/server/kubernetes-backend.js +554 -0
  88. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  89. package/dist/server/oauth.js +1 -0
  90. package/dist/server/plan.js +51 -1
  91. package/dist/server/policy.js +104 -15
  92. package/dist/server/proposals-api.js +24 -40
  93. package/dist/server/proposals.js +12 -25
  94. package/dist/server/repo-access.js +37 -6
  95. package/dist/server/run-logs.js +217 -0
  96. package/dist/server/run-plan.js +51 -2
  97. package/dist/server/run-request.js +23 -10
  98. package/dist/server/run-store.js +5 -0
  99. package/dist/server/run-worker-command.js +13 -1
  100. package/dist/server/run-worker.js +121 -24
  101. package/dist/server/runs.js +184 -10
  102. package/dist/server/sandbox-docker.js +62 -0
  103. package/dist/server/sandbox-files-api.js +269 -0
  104. package/dist/server/sandbox-files.js +1054 -0
  105. package/dist/server/sandbox-patch.js +481 -0
  106. package/dist/server/sandbox-pod-command.js +15 -0
  107. package/dist/server/sandbox-pod.js +146 -0
  108. package/dist/server/sandbox-storage.js +462 -0
  109. package/dist/server/sandboxes-api.js +210 -0
  110. package/dist/server/sandboxes.js +519 -0
  111. package/dist/server/server-config.js +412 -22
  112. package/dist/server/server.js +204 -39
  113. package/dist/utils/run-id.js +12 -0
  114. package/harness/opencode.ts +11 -0
  115. package/harness/pi.ts +7 -0
  116. package/package.json +8 -2
  117. package/dist/remote/text-diff.js +0 -89
@@ -5,6 +5,7 @@ const work_secret_1 = require("../planner/work-secret");
5
5
  const apply_1 = require("./apply");
6
6
  const resources_1 = require("./resources");
7
7
  const version_1 = require("../version");
8
+ const job_placement_1 = require("./job-placement");
8
9
  const SECRET_VOLUME_NAME = 'hammerkit-secrets';
9
10
  function getSecretResourceName(item) {
10
11
  return (0, resources_1.getResourceName)(item, '-secrets');
@@ -55,6 +56,7 @@ async function ensureKubernetesSecret(instance, kubernetes, item, secrets, envir
55
56
  },
56
57
  labels: {
57
58
  'hammerkit.dev/id': item.id(),
59
+ ...(0, job_placement_1.getRunLabels)(kubernetes),
58
60
  },
59
61
  },
60
62
  type: 'Opaque',
@@ -6,7 +6,7 @@ const event_cache_1 = require("../executer/event-cache");
6
6
  const volumes_1 = require("./volumes");
7
7
  const ensure_persistent_data_1 = require("./ensure-persistent-data");
8
8
  async function storeKubernetesData(work, kubernetes, instance, environment, path) {
9
- const persistence = await (0, volumes_1.getKubernetesPersistence)(work);
9
+ const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes);
10
10
  await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'read', async (name) => {
11
11
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(work.data, path)) {
12
12
  const writerStream = environment.file.createWriteStream(generatedArchive.filename);
@@ -1,37 +1,88 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.appendVolume = exports.getKubernetesPersistence = exports.getVolumeName = void 0;
3
+ exports.getSandboxAffinity = exports.SANDBOX_LABEL = exports.getWorkspacePodSpec = exports.appendVolume = exports.getKubernetesPersistence = exports.getVolumeName = void 0;
4
4
  const path_1 = require("path");
5
- function getVolumeName(service) {
6
- return 'hammerkit-' + service.id();
5
+ const fs_1 = require("fs");
6
+ const kept_claims_1 = require("./kept-claims");
7
+ function getVolumeName(service, kubernetes) {
8
+ return (0, kept_claims_1.getClaimName)(service, kubernetes);
7
9
  }
8
10
  exports.getVolumeName = getVolumeName;
9
- async function getKubernetesPersistence(work) {
10
- const persistence = { volumes: [], sources: [], mounts: [], initCommands: [] };
11
+ const WORKSPACE_VOLUME = 'hammerkit-sandbox';
12
+ // The path of `path` below the root of a sandbox's files, or undefined when it is not
13
+ // below it
14
+ function workspacePath(workspace, path) {
15
+ const rel = (0, path_1.relative)(workspace.root, path);
16
+ if (rel.startsWith('..') || (0, path_1.isAbsolute)(rel)) {
17
+ return undefined;
18
+ }
19
+ return [workspace.subPath, ...rel.split(path_1.sep).filter((part) => part.length > 0)].join('/');
20
+ }
21
+ // A path of a sandbox is mounted from its claim, read and write as on a host's docker,
22
+ // so what the task writes is in the sandbox afterwards. A path that is not in the
23
+ // sandbox is left out: Kubernetes would make a directory of it in the claim.
24
+ function appendWorkspaceMount(workspace, persistence, containerPath, localPath, isFile, readOnly) {
25
+ const subPath = workspacePath(workspace, localPath);
26
+ if (subPath === undefined) {
27
+ return false;
28
+ }
29
+ if (!(0, fs_1.existsSync)(localPath)) {
30
+ return true;
31
+ }
32
+ if (persistence.workspace.length === 0) {
33
+ persistence.workspace.push({ name: WORKSPACE_VOLUME, persistentVolumeClaim: { claimName: workspace.claim } });
34
+ }
35
+ persistence.mounts.push({
36
+ mount: { readOnly, mountPath: containerPath, name: WORKSPACE_VOLUME, subPath },
37
+ uploadPath: '',
38
+ cwd: isFile ? (0, path_1.dirname)(containerPath) : containerPath,
39
+ workspace: true,
40
+ });
41
+ return true;
42
+ }
43
+ async function getKubernetesPersistence(work, kubernetes) {
44
+ const persistence = {
45
+ volumes: [],
46
+ sizes: {},
47
+ kept: [],
48
+ workspace: [],
49
+ sources: [],
50
+ mounts: [],
51
+ initCommands: [],
52
+ };
53
+ const workspace = kubernetes?.server?.workspace;
11
54
  for (const mount of work.data.mounts) {
12
- appendVolume(work, persistence, mount.containerPath, mount.localPath, mount.isFile, true);
55
+ if (workspace &&
56
+ appendWorkspaceMount(workspace, persistence, mount.containerPath, mount.localPath, mount.isFile, mount.readOnly)) {
57
+ continue;
58
+ }
59
+ appendVolume(work, persistence, mount.containerPath, mount.localPath, mount.isFile, true, kubernetes);
13
60
  }
14
61
  for (const src of work.data.src) {
15
62
  if (src.inherited) {
16
63
  continue;
17
64
  }
18
- appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true); // TODO matcher
65
+ if (workspace &&
66
+ appendWorkspaceMount(workspace, persistence, src.absolutePath, src.absolutePath, src.isFile, false)) {
67
+ continue;
68
+ }
69
+ appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes); // TODO matcher
19
70
  }
20
71
  if (work.data.type === 'container-task') {
21
72
  for (const generate of work.data.generates) {
22
- appendVolume(generate.inherited ?? work, persistence, generate.path, null, false, !!generate.inherited);
73
+ appendVolume(generate.inherited ?? work, persistence, generate.path, null, false, !!generate.inherited, kubernetes);
23
74
  }
24
75
  }
25
76
  else {
26
77
  for (const volume of work.data.volumes) {
27
- appendVolume(volume.inherited ?? work, persistence, volume.containerPath, null, false, !!volume.inherited || volume.readOnly);
78
+ appendVolume(volume.inherited ?? work, persistence, volume.containerPath, null, false, !!volume.inherited || volume.readOnly, kubernetes);
28
79
  }
29
80
  }
30
81
  return persistence;
31
82
  }
32
83
  exports.getKubernetesPersistence = getKubernetesPersistence;
33
- function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly) {
34
- const volumeName = getVolumeName(sourceWork);
84
+ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes) {
85
+ const volumeName = getVolumeName(sourceWork, kubernetes);
35
86
  const existVolume = persistence.volumes.some((v) => v.name === volumeName);
36
87
  if (!existVolume) {
37
88
  persistence.volumes.push({
@@ -40,6 +91,10 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
40
91
  claimName: volumeName,
41
92
  },
42
93
  });
94
+ persistence.sizes[volumeName] = 'volumeSize' in sourceWork.data ? sourceWork.data.volumeSize : null;
95
+ if (kubernetes && (0, kept_claims_1.isKeptClaim)(sourceWork, kubernetes)) {
96
+ persistence.kept.push(volumeName);
97
+ }
43
98
  }
44
99
  persistence.mounts.push({
45
100
  mount: {
@@ -61,4 +116,38 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
61
116
  }
62
117
  }
63
118
  exports.appendVolume = appendVolume;
119
+ /**
120
+ * What a pod that mounts the claim of a sandbox needs: its label, and to go where the
121
+ * other pods that mount it run. A claim is usually `ReadWriteOnce`, which several pods
122
+ * share only on one node; the first pod (no other one runs) goes anywhere. The pod of the
123
+ * sandbox's files runs on the first pool while a run works, so a task on another pool
124
+ * would never start: it is refused.
125
+ */
126
+ function getWorkspacePodSpec(kubernetes, persistence, task) {
127
+ const workspace = kubernetes.server?.workspace;
128
+ if (!workspace || persistence.workspace.length === 0) {
129
+ return { labels: {} };
130
+ }
131
+ const first = kubernetes.server?.pools[0]?.name;
132
+ if (task?.pool && first && task.pool !== first) {
133
+ throw new Error(`task ${task.name}: runs on the pool ${task.pool}, and the tasks of a run on a sandbox run on the first pool, ${first}, where its files are`);
134
+ }
135
+ return {
136
+ labels: { [exports.SANDBOX_LABEL]: workspace.label },
137
+ affinity: getSandboxAffinity(workspace.label),
138
+ };
139
+ }
140
+ exports.getWorkspacePodSpec = getWorkspacePodSpec;
141
+ // the label of the pods that mount the claim of a sandbox
142
+ exports.SANDBOX_LABEL = 'hammerkit.dev/sandbox';
143
+ function getSandboxAffinity(label) {
144
+ return {
145
+ podAffinity: {
146
+ requiredDuringSchedulingIgnoredDuringExecution: [
147
+ { labelSelector: { matchLabels: { [exports.SANDBOX_LABEL]: label } }, topologyKey: 'kubernetes.io/hostname' },
148
+ ],
149
+ },
150
+ };
151
+ }
152
+ exports.getSandboxAffinity = getSandboxAffinity;
64
153
  //# sourceMappingURL=volumes.js.map
package/dist/main.js ADDED
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // The CLI: what `hammerkit` runs, for all but the command of a sandbox's pod (see index.ts).
4
+ const log_1 = require("./log");
5
+ const get_file_context_1 = require("./file/get-file-context");
6
+ const work_item_status_1 = require("./planner/work-item-status");
7
+ const redact_1 = require("./utils/redact");
8
+ const empty_writable_1 = require("./utils/empty-writable");
9
+ const run_program_1 = require("./run-program");
10
+ const abort_on_signals_1 = require("./utils/abort-on-signals");
11
+ const abortCtrl = new AbortController();
12
+ (0, abort_on_signals_1.abortOnSignals)(process, abortCtrl);
13
+ let settled = false;
14
+ // A run that never settles (a task waiting on something that can't happen)
15
+ // lets the event loop drain, and Node then exits 0: CI would report success
16
+ // for a build that never finished. Fail loudly instead.
17
+ process.on('beforeExit', () => {
18
+ if (!settled) {
19
+ process.stderr.write('hammerkit stopped before the run finished\n');
20
+ process.exitCode = 1;
21
+ }
22
+ });
23
+ const secrets = (0, redact_1.createSecretRegistry)();
24
+ (0, run_program_1.runProgram)({
25
+ cwd: process.cwd(),
26
+ abortCtrl,
27
+ processEnvs: process.env,
28
+ file: (0, get_file_context_1.getFileContext)(process.cwd()),
29
+ console: (0, log_1.consoleContext)(process.stdout),
30
+ status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
31
+ secrets,
32
+ stdout: process.stdout,
33
+ stderr: process.stderr,
34
+ stdoutColumns: process.stdout.columns,
35
+ }, process.argv, false).then(() => {
36
+ settled = true;
37
+ }, () => {
38
+ settled = true;
39
+ process.exit(1);
40
+ });
41
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1,368 @@
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. The script is passed to `node -e`, so the proxy
9
+ // needs no image of its own, only a stock Node image.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.formatEgressProxyEvent = exports.formatRefusedHost = exports.parseEgressProxyEvent = exports.getProxylessEnvironment = exports.getProxyClientEnvironment = exports.getEgressProxyPorts = exports.getItemProxyEnvironment = exports.getEgressProxyEnvironment = exports.getEgressProxyCommand = exports.EGRESS_PROXY_SCRIPT = exports.EGRESS_PROXY_HOST = exports.EGRESS_PROXY_PORT = exports.EGRESS_PROXY_IMAGE = void 0;
12
+ const work_network_1 = require("../planner/work-network");
13
+ const network_schema_1 = require("../schema/network-schema");
14
+ // Pinned, so the code behind the boundary is the same on every host. To bump
15
+ // it, take the index digest of the tag (`docker buildx imagetools inspect
16
+ // node:24-alpine`). The port relay runs in it too.
17
+ exports.EGRESS_PROXY_IMAGE = 'node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1';
18
+ exports.EGRESS_PROXY_PORT = network_schema_1.PROXY_PORT;
19
+ // the name the item reaches the proxy under
20
+ exports.EGRESS_PROXY_HOST = 'hammerkit-proxy';
21
+ // Kept free of anything but Node's own modules. The host rule is the one of
22
+ // matchesHost in planner/work-network.ts. A name a wildcard allows must not
23
+ // resolve to the host or a private network, and none to link-local.
24
+ exports.EGRESS_PROXY_SCRIPT = `
25
+ const http = require('http')
26
+ const net = require('net')
27
+ const dns = require('dns')
28
+ const entries = JSON.parse(process.env.HAMMERKIT_ALLOW || '[]').map((h) => h.toLowerCase())
29
+ // names and patterns for HTTP(S)
30
+ const allow = entries.filter((e) => !e.includes(':'))
31
+ // names and ports for raw TCP, each forwarded on its port
32
+ const forwards = entries
33
+ .filter((e) => e.includes(':'))
34
+ .map((e) => ({ host: e.substring(0, e.lastIndexOf(':')), port: parseInt(e.substring(e.lastIndexOf(':') + 1), 10) }))
35
+ // entries of both kinds that a ceiling allows only through a pattern: they
36
+ // reach no private address, like a pattern
37
+ const publicOnly = JSON.parse(process.env.HAMMERKIT_PUBLIC_ONLY || '[]').map((h) => h.toLowerCase())
38
+ // the names of the needed services, for tools that ignore NO_PROXY: exact
39
+ // names only, never patterns
40
+ const needs = JSON.parse(process.env.HAMMERKIT_NEEDS || '[]').map((h) => h.toLowerCase())
41
+ const port = parseInt(process.env.HAMMERKIT_PROXY_PORT || '${exports.EGRESS_PROXY_PORT}', 10)
42
+ // the name whose address the proxy listens on, every address when unset
43
+ const bind = process.env.HAMMERKIT_PROXY_BIND
44
+ const refused = new Set()
45
+ const httpsUrls = new Set()
46
+ function emit(event) {
47
+ process.stdout.write(JSON.stringify(event) + '\\n')
48
+ }
49
+ // the rule that allows a host and port: its exact name, a wildcard, or none
50
+ function allowedBy(host, port) {
51
+ const name = host.toLowerCase().replace(/\\.$/, '')
52
+ if (needs.includes(name)) {
53
+ return 'exact'
54
+ }
55
+ const named = []
56
+ if (allow.includes(name)) {
57
+ named.push(name)
58
+ }
59
+ if (forwards.some((f) => f.host === name && f.port === port)) {
60
+ named.push(name + ':' + port)
61
+ }
62
+ if (named.length > 0) {
63
+ return named.some((entry) => !publicOnly.includes(entry)) ? 'exact' : 'wildcard'
64
+ }
65
+ return allow.some((rule) => rule.startsWith('*.') && name.endsWith(rule.substring(1))) ? 'wildcard' : null
66
+ }
67
+ function ipv4(address) {
68
+ const mapped = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/i.exec(address)
69
+ const value = mapped ? mapped[1] : address
70
+ return net.isIPv4(value) ? value.split('.').map(Number) : null
71
+ }
72
+ // never allowed: the cloud metadata service lives there
73
+ function isLinkLocal(address) {
74
+ const v4 = ipv4(address)
75
+ return v4 ? v4[0] === 169 && v4[1] === 254 : /^fe[89ab]/i.test(address)
76
+ }
77
+ // the host, its private networks (Docker's bridges among them) and the
78
+ // cluster: only for a name listed exactly, such as a needed service
79
+ function isInternal(address) {
80
+ const v4 = ipv4(address)
81
+ if (v4) {
82
+ const [a, b] = v4
83
+ return (
84
+ a === 0 ||
85
+ a === 10 ||
86
+ a === 127 ||
87
+ (a === 100 && b >= 64 && b < 128) ||
88
+ (a === 172 && b >= 16 && b < 32) ||
89
+ (a === 192 && b === 168)
90
+ )
91
+ }
92
+ return address === '::' || address === '::1' || /^f[cd]/i.test(address)
93
+ }
94
+ // The proxy resolves the name itself and connects only to the addresses it
95
+ // checked, each in turn (a name can have an IPv6 address the network has no
96
+ // route to).
97
+ function resolve(host, rule, callback) {
98
+ dns.lookup(host, { all: true }, (err, addresses) => {
99
+ if (err || addresses.length === 0) {
100
+ callback(err || new Error('no address'), null)
101
+ return
102
+ }
103
+ const blocked = addresses.some((a) => isLinkLocal(a.address) || (rule !== 'exact' && isInternal(a.address)))
104
+ callback(null, blocked ? null : addresses)
105
+ })
106
+ }
107
+ // connection options that reach the checked addresses and nothing else
108
+ function connectTo(host, addresses) {
109
+ const lookup = (hostname, options, callback) => {
110
+ const cb = typeof options === 'function' ? options : callback
111
+ if (typeof options === 'object' && options && options.all) {
112
+ cb(null, addresses)
113
+ } else {
114
+ cb(null, addresses[0].address, addresses[0].family)
115
+ }
116
+ }
117
+ return { host, lookup, autoSelectFamily: true }
118
+ }
119
+ function refuse(host) {
120
+ if (!refused.has(host)) {
121
+ refused.add(host)
122
+ emit({ type: 'refused', host })
123
+ }
124
+ }
125
+ function httpsUrl(host) {
126
+ if (!httpsUrls.has(host)) {
127
+ httpsUrls.add(host)
128
+ emit({ type: 'https-url', host })
129
+ }
130
+ }
131
+ function parseAuthority(value) {
132
+ const match = /^\\[([^\\]]+)\\](?::(\\d+))?$/.exec(value) || /^([^:]+)(?::(\\d+))?$/.exec(value)
133
+ return match ? { host: match[1], port: match[2] ? parseInt(match[2], 10) : 443 } : null
134
+ }
135
+ const server = http.createServer((req, res) => {
136
+ let url
137
+ try {
138
+ url = new URL(req.url)
139
+ } catch (e) {
140
+ res.writeHead(400, { 'content-type': 'text/plain' })
141
+ res.end('hammerkit: only proxy requests are served\\n')
142
+ return
143
+ }
144
+ // BusyBox wget sends an https URL to the proxy as it is: TLS belongs between
145
+ // the client and the host, so only a CONNECT tunnel reaches an https host
146
+ if (url.protocol === 'https:' && allowedBy(url.hostname, parseInt(url.port || '443', 10))) {
147
+ httpsUrl(url.hostname)
148
+ res.writeHead(501, { 'content-type': 'text/plain' })
149
+ res.end('hammerkit: ' + url.hostname + ' is allowed through a CONNECT tunnel only, not by its https URL\\n')
150
+ return
151
+ }
152
+ const rule = url.protocol === 'http:' ? allowedBy(url.hostname, parseInt(url.port || '80', 10)) : null
153
+ if (!rule) {
154
+ refuse(url.hostname)
155
+ res.writeHead(403, { 'content-type': 'text/plain' })
156
+ res.end('hammerkit: network access to ' + url.hostname + ' is not allowed\\n')
157
+ return
158
+ }
159
+ resolve(url.hostname, rule, (err, addresses) => {
160
+ if (err) {
161
+ res.writeHead(502, { 'content-type': 'text/plain' })
162
+ res.end()
163
+ return
164
+ }
165
+ if (!addresses) {
166
+ refuse(url.hostname)
167
+ res.writeHead(403, { 'content-type': 'text/plain' })
168
+ res.end('hammerkit: ' + url.hostname + ' resolves to an address that is not allowed\\n')
169
+ return
170
+ }
171
+ const headers = Object.assign({}, req.headers)
172
+ delete headers['proxy-connection']
173
+ delete headers['proxy-authorization']
174
+ // the absolute URL wins over a Host header that disagrees
175
+ headers.host = url.host
176
+ const upstream = http.request(
177
+ Object.assign(connectTo(url.hostname, addresses), {
178
+ port: url.port || 80,
179
+ method: req.method,
180
+ path: url.pathname + url.search,
181
+ headers,
182
+ }),
183
+ (response) => {
184
+ res.writeHead(response.statusCode || 502, response.headers)
185
+ response.pipe(res)
186
+ }
187
+ )
188
+ upstream.on('error', () => {
189
+ if (!res.headersSent) {
190
+ res.writeHead(502, { 'content-type': 'text/plain' })
191
+ }
192
+ res.end()
193
+ })
194
+ req.pipe(upstream)
195
+ })
196
+ })
197
+ server.on('connect', (req, socket, head) => {
198
+ const target = parseAuthority(req.url || '')
199
+ socket.on('error', () => socket.destroy())
200
+ const rule = target ? allowedBy(target.host, target.port) : null
201
+ if (!rule) {
202
+ refuse(target ? target.host : String(req.url))
203
+ socket.end('HTTP/1.1 403 Forbidden\\r\\ncontent-type: text/plain\\r\\n\\r\\nhammerkit: network access is not allowed\\n')
204
+ return
205
+ }
206
+ resolve(target.host, rule, (err, addresses) => {
207
+ if (err) {
208
+ socket.end('HTTP/1.1 502 Bad Gateway\\r\\n\\r\\n')
209
+ return
210
+ }
211
+ if (!addresses) {
212
+ refuse(target.host)
213
+ socket.end('HTTP/1.1 403 Forbidden\\r\\ncontent-type: text/plain\\r\\n\\r\\nhammerkit: network access is not allowed\\n')
214
+ return
215
+ }
216
+ let established = false
217
+ const upstream = net.connect(Object.assign(connectTo(target.host, addresses), { port: target.port }), () => {
218
+ established = true
219
+ socket.write('HTTP/1.1 200 Connection Established\\r\\n\\r\\n')
220
+ if (head && head.length > 0) {
221
+ upstream.write(head)
222
+ }
223
+ upstream.pipe(socket)
224
+ socket.pipe(upstream)
225
+ })
226
+ upstream.on('error', () => {
227
+ if (established) {
228
+ socket.destroy()
229
+ } else {
230
+ socket.end('HTTP/1.1 502 Bad Gateway\\r\\n\\r\\n')
231
+ }
232
+ })
233
+ socket.on('close', () => upstream.destroy())
234
+ })
235
+ })
236
+ // A TCP forward: each connection goes to the checked addresses of its host,
237
+ // on the same port.
238
+ function forward(target) {
239
+ return net.createServer({ allowHalfOpen: true }, (client) => {
240
+ client.on('error', () => client.destroy())
241
+ resolve(target.host, allowedBy(target.host, target.port), (err, addresses) => {
242
+ if (err || !addresses) {
243
+ if (!err) {
244
+ refuse(target.host)
245
+ }
246
+ client.destroy()
247
+ return
248
+ }
249
+ const upstream = net.connect(Object.assign(connectTo(target.host, addresses), { port: target.port }))
250
+ upstream.on('error', () => client.destroy())
251
+ client.on('close', () => upstream.destroy())
252
+ client.pipe(upstream)
253
+ upstream.pipe(client)
254
+ })
255
+ })
256
+ }
257
+ function listen(address) {
258
+ const servers = [{ server, port }].concat(forwards.map((f) => ({ server: forward(f), port: f.port })))
259
+ let pending = servers.length
260
+ for (const entry of servers) {
261
+ entry.server.on('error', (err) => {
262
+ process.stderr.write('hammerkit: unable to listen on port ' + entry.port + ': ' + err.message + '\\n')
263
+ process.exit(1)
264
+ })
265
+ entry.server.listen(entry.port, address, () => {
266
+ pending--
267
+ if (pending === 0) {
268
+ emit({ type: 'ready' })
269
+ }
270
+ })
271
+ }
272
+ }
273
+ if (bind) {
274
+ dns.lookup(bind, { family: 4 }, (err, address) => {
275
+ if (err) {
276
+ process.stderr.write('hammerkit: unable to resolve ' + bind + ': ' + err.message + '\\n')
277
+ process.exit(1)
278
+ }
279
+ listen(address)
280
+ })
281
+ } else {
282
+ listen(undefined)
283
+ }
284
+ process.on('SIGTERM', () => process.exit(0))
285
+ `;
286
+ function getEgressProxyCommand() {
287
+ return ['node', '-e', exports.EGRESS_PROXY_SCRIPT];
288
+ }
289
+ exports.getEgressProxyCommand = getEgressProxyCommand;
290
+ // `needs` are the names of the needed services, allowed as exactly those
291
+ // names. `bind` is the name the proxy listens on alone, so that nothing on its
292
+ // other networks reaches it. `publicOnly` are the entries of `hosts` that reach
293
+ // no private address (see WorkNetwork).
294
+ function getEgressProxyEnvironment(hosts, needs, bind, publicOnly = []) {
295
+ return {
296
+ HAMMERKIT_ALLOW: JSON.stringify(hosts),
297
+ ...(publicOnly.length > 0 ? { HAMMERKIT_PUBLIC_ONLY: JSON.stringify(publicOnly) } : {}),
298
+ HAMMERKIT_NEEDS: JSON.stringify(needs),
299
+ HAMMERKIT_PROXY_PORT: `${exports.EGRESS_PROXY_PORT}`,
300
+ ...(bind ? { HAMMERKIT_PROXY_BIND: bind } : {}),
301
+ };
302
+ }
303
+ exports.getEgressProxyEnvironment = getEgressProxyEnvironment;
304
+ // The proxy of a task or service: the hosts of its network in this run (under
305
+ // the run's ceiling), and the names of the services it needs, on every runtime.
306
+ function getItemProxyEnvironment(item, bind) {
307
+ const network = (0, work_network_1.getEffectiveNetwork)(item.data);
308
+ return getEgressProxyEnvironment(network.type === 'allow' ? network.hosts : [], item.needs.map((need) => need.name), bind, network.type === 'allow' ? network.publicOnly ?? [] : []);
309
+ }
310
+ exports.getItemProxyEnvironment = getItemProxyEnvironment;
311
+ // The variables that send the item's HTTP(S) through the proxy, both spellings
312
+ // since tools read either. The services it needs are reached directly.
313
+ // The ports the proxy of a network listens on: its own, and one per TCP forward.
314
+ function getEgressProxyPorts(network) {
315
+ return [exports.EGRESS_PROXY_PORT, ...(0, work_network_1.getTcpForwards)(network).map((forward) => forward.port)];
316
+ }
317
+ exports.getEgressProxyPorts = getEgressProxyPorts;
318
+ function getProxyClientEnvironment(directHosts) {
319
+ const proxy = `http://${exports.EGRESS_PROXY_HOST}:${exports.EGRESS_PROXY_PORT}`;
320
+ const noProxy = ['localhost', '127.0.0.1', ...directHosts].join(',');
321
+ return {
322
+ HTTP_PROXY: proxy,
323
+ HTTPS_PROXY: proxy,
324
+ NO_PROXY: noProxy,
325
+ http_proxy: proxy,
326
+ https_proxy: proxy,
327
+ no_proxy: noProxy,
328
+ };
329
+ }
330
+ exports.getProxyClientEnvironment = getProxyClientEnvironment;
331
+ // The proxy variables emptied: a healthcheck checks the service itself, and
332
+ // tools that ignore NO_PROXY (busybox wget) would ask the proxy for localhost.
333
+ function getProxylessEnvironment(env) {
334
+ const proxyKeys = Object.keys(env).filter((key) => /^(https?|no)_proxy$/i.test(key));
335
+ return proxyKeys.length > 0 ? Object.fromEntries(proxyKeys.map((key) => [key, ''])) : undefined;
336
+ }
337
+ exports.getProxylessEnvironment = getProxylessEnvironment;
338
+ // One line of the proxy's stdout, null for anything else.
339
+ function parseEgressProxyEvent(line) {
340
+ try {
341
+ const value = JSON.parse(line);
342
+ if (value?.type === 'ready') {
343
+ return { type: 'ready' };
344
+ }
345
+ if ((value?.type === 'refused' || value?.type === 'https-url') && typeof value.host === 'string') {
346
+ return { type: value.type, host: value.host };
347
+ }
348
+ }
349
+ catch {
350
+ // not an event
351
+ }
352
+ return null;
353
+ }
354
+ exports.parseEgressProxyEvent = parseEgressProxyEvent;
355
+ function formatRefusedHost(host) {
356
+ return `network: refused ${host}`;
357
+ }
358
+ exports.formatRefusedHost = formatRefusedHost;
359
+ // The line of an event other than `ready` in the item's log.
360
+ function formatEgressProxyEvent(event) {
361
+ if (event.type === 'https-url') {
362
+ return (`network: ${event.host} is allowed, but the client asked the proxy for its https URL instead of ` +
363
+ 'tunneling with CONNECT (BusyBox wget does), use curl or GNU wget');
364
+ }
365
+ return formatRefusedHost(event.host);
366
+ }
367
+ exports.formatEgressProxyEvent = formatEgressProxyEvent;
368
+ //# 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;
@@ -24,8 +24,9 @@ function getWorkTaskCacheDescription(item) {
24
24
  shell: task.shell ?? undefined,
25
25
  platform: task.type === 'container-task' ? task.image : (0, os_1.platform)(),
26
26
  // 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,
27
+ // architecture, so container tasks are keyed by it too; on a cluster it is the
28
+ // one of the nodes the task runs on
29
+ arch: (task.type === 'container-task' && task.arch) || process.arch,
29
30
  generates: task.generates.map((g) => portable(g.path)).sort() ?? undefined,
30
31
  src: task.src.map((s) => s.source).sort() ?? undefined,
31
32
  envs: Object.keys(envs)