hammerkit 1.27.0-next.2 → 1.27.0-next.20

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 (100) hide show
  1. package/build.schema.json +9 -0
  2. package/dist/cache/backends/registry-cache-backend.js +2 -2
  3. package/dist/cache/explain.js +17 -0
  4. package/dist/cli.js +1 -0
  5. package/dist/docker/item-network.js +13 -5
  6. package/dist/executer/deadline.js +17 -3
  7. package/dist/executer/docker-service.js +30 -8
  8. package/dist/executer/event-cache.js +20 -2
  9. package/dist/executer/execute-work-task.js +5 -3
  10. package/dist/executer/execution-steps.js +5 -2
  11. package/dist/executer/scheduler/enqueue-next.js +7 -0
  12. package/dist/executer/scheduler/state-key.js +42 -5
  13. package/dist/harness/connect.js +20 -6
  14. package/dist/harness/mcp-command.js +22 -6
  15. package/dist/harness/mcp.js +63 -114
  16. package/dist/harness/opencode.js +158 -50
  17. package/dist/harness/pi.js +29 -0
  18. package/dist/harness/sandbox-client.js +207 -65
  19. package/dist/harness/service-tools.js +141 -0
  20. package/dist/harness/task-tools.js +221 -0
  21. package/dist/kubernetes/apply.js +27 -16
  22. package/dist/kubernetes/claim-layout.js +71 -0
  23. package/dist/kubernetes/ensure-http-route.js +1 -1
  24. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  25. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  26. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  27. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  28. package/dist/kubernetes/get-service-ip.js +2 -2
  29. package/dist/kubernetes/job-secrets.js +33 -19
  30. package/dist/kubernetes/kept-claims.js +22 -10
  31. package/dist/kubernetes/network-policy.js +85 -27
  32. package/dist/kubernetes/remove-persistent-data.js +3 -1
  33. package/dist/kubernetes/resources.js +25 -3
  34. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  35. package/dist/kubernetes/sandbox-runner.js +626 -0
  36. package/dist/kubernetes/secrets.js +7 -11
  37. package/dist/kubernetes/volumes.js +6 -3
  38. package/dist/network/egress-proxy.js +45 -6
  39. package/dist/optimizer/calculate-checksum.js +3 -2
  40. package/dist/optimizer/check-key.js +94 -0
  41. package/dist/optimizer/check-keys.js +64 -0
  42. package/dist/optimizer/get-work-cache-stats.js +17 -3
  43. package/dist/optimizer/work-task-cache-description.js +51 -1
  44. package/dist/planner/utils/append-work-dependencies.js +10 -5
  45. package/dist/planner/utils/append-work-service.js +1 -0
  46. package/dist/planner/utils/append-work-task.js +15 -2
  47. package/dist/planner/utils/get-work-runtime.js +6 -1
  48. package/dist/planner/utils/normalize-path.js +19 -1
  49. package/dist/planner/utils/parse-work-mount.js +7 -1
  50. package/dist/planner/utils/parse-work-port.js +4 -1
  51. package/dist/planner/utils/task-arguments.js +3 -0
  52. package/dist/planner/validate.js +22 -0
  53. package/dist/planner/work-network.js +3 -1
  54. package/dist/planner/work-runtime-aggregate.js +32 -0
  55. package/dist/planner/work-runtime-docker.js +2 -1
  56. package/dist/planner/work-runtime-kubernetes.js +97 -48
  57. package/dist/planner/work-runtime-local.js +2 -1
  58. package/dist/planner/work-task.js +6 -1
  59. package/dist/program.js +14 -4
  60. package/dist/remote/annotations.js +13 -1
  61. package/dist/remote/device-login.js +7 -2
  62. package/dist/remote/host-config.js +64 -3
  63. package/dist/remote/remote-commands.js +41 -9
  64. package/dist/remote/remote-work-commands.js +246 -1
  65. package/dist/remote/server-client.js +15 -2
  66. package/dist/remote/token-source.js +64 -26
  67. package/dist/run-program.js +9 -0
  68. package/dist/schema/build-file-base-task-schema.js +5 -1
  69. package/dist/schema/build-file-container-service-schema.js +2 -0
  70. package/dist/schema/reference-parser.js +9 -1
  71. package/dist/schema/work-scope-parser.js +6 -0
  72. package/dist/secrets/github-app-token-command.js +3 -1
  73. package/dist/server/accounts-api.js +3 -1
  74. package/dist/server/approvals-api.js +1 -1
  75. package/dist/server/checks-api.js +83 -0
  76. package/dist/server/checks.js +116 -0
  77. package/dist/server/definitions-api.js +4 -2
  78. package/dist/server/endpoints.js +612 -0
  79. package/dist/server/executor.js +56 -5
  80. package/dist/server/kubernetes-backend.js +303 -64
  81. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  82. package/dist/server/oauth.js +12 -1
  83. package/dist/server/plan.js +126 -62
  84. package/dist/server/policy.js +32 -2
  85. package/dist/server/repo-access.js +5 -0
  86. package/dist/server/run-plan.js +50 -19
  87. package/dist/server/run-request.js +64 -3
  88. package/dist/server/run-worker.js +146 -10
  89. package/dist/server/runs.js +347 -27
  90. package/dist/server/sandbox-pod.js +28 -0
  91. package/dist/server/sandbox-storage.js +23 -0
  92. package/dist/server/sandboxes-api.js +10 -4
  93. package/dist/server/sandboxes.js +8 -0
  94. package/dist/server/server-command.js +39 -20
  95. package/dist/server/server-config.js +213 -14
  96. package/dist/server/server.js +268 -15
  97. package/dist/server/service-runs.js +82 -0
  98. package/dist/utils/abort-on-signals.js +46 -6
  99. package/dist/utils/tar.js +36 -0
  100. package/package.json +1 -1
@@ -4,6 +4,7 @@ exports.getSandboxAffinity = exports.SANDBOX_LABEL = exports.getWorkspacePodSpec
4
4
  const path_1 = require("path");
5
5
  const fs_1 = require("fs");
6
6
  const kept_claims_1 = require("./kept-claims");
7
+ const claim_layout_1 = require("./claim-layout");
7
8
  function getVolumeName(service, kubernetes) {
8
9
  return (0, kept_claims_1.getClaimName)(service, kubernetes);
9
10
  }
@@ -50,7 +51,7 @@ async function getKubernetesPersistence(work, kubernetes) {
50
51
  mounts: [],
51
52
  initCommands: [],
52
53
  };
53
- const workspace = kubernetes?.server?.workspace;
54
+ const workspace = kubernetes.server?.workspace;
54
55
  for (const mount of work.data.mounts) {
55
56
  if (workspace &&
56
57
  appendWorkspaceMount(workspace, persistence, mount.containerPath, mount.localPath, mount.isFile, mount.readOnly)) {
@@ -83,6 +84,7 @@ async function getKubernetesPersistence(work, kubernetes) {
83
84
  exports.getKubernetesPersistence = getKubernetesPersistence;
84
85
  function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes) {
85
86
  const volumeName = getVolumeName(sourceWork, kubernetes);
87
+ const subPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath);
86
88
  const existVolume = persistence.volumes.some((v) => v.name === volumeName);
87
89
  if (!existVolume) {
88
90
  persistence.volumes.push({
@@ -92,7 +94,7 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
92
94
  },
93
95
  });
94
96
  persistence.sizes[volumeName] = 'volumeSize' in sourceWork.data ? sourceWork.data.volumeSize : null;
95
- if (kubernetes && (0, kept_claims_1.isKeptClaim)(sourceWork, kubernetes)) {
97
+ if ((0, kept_claims_1.isKeptClaim)(sourceWork, kubernetes)) {
96
98
  persistence.kept.push(volumeName);
97
99
  }
98
100
  }
@@ -101,7 +103,7 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
101
103
  readOnly,
102
104
  mountPath: containerPath,
103
105
  name: volumeName,
104
- subPath: (0, path_1.basename)(containerPath),
106
+ subPath,
105
107
  },
106
108
  uploadPath: `/dev/hammerkit/${sourceWork.id()}`,
107
109
  cwd: isFile ? (0, path_1.dirname)(containerPath) : containerPath,
@@ -111,6 +113,7 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
111
113
  localPath: localPath,
112
114
  matcher: () => true, // TODO
113
115
  containerPath,
116
+ subPath,
114
117
  stateKey: '', // TODO
115
118
  });
116
119
  }
@@ -5,10 +5,12 @@
5
5
  // `CONNECT` tunnels to an allowed host pass, everything else gets a 403 and a
6
6
  // `refused` event on stdout. For each `host:port` entry it also listens on that
7
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
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
9
11
  // needs no image of its own, only a stock Node image.
10
12
  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;
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;
12
14
  const work_network_1 = require("../planner/work-network");
13
15
  const network_schema_1 = require("../schema/network-schema");
14
16
  // Pinned, so the code behind the boundary is the same on every host. To bump
@@ -25,6 +27,7 @@ exports.EGRESS_PROXY_SCRIPT = `
25
27
  const http = require('http')
26
28
  const net = require('net')
27
29
  const dns = require('dns')
30
+ const crypto = require('crypto')
28
31
  const entries = JSON.parse(process.env.HAMMERKIT_ALLOW || '[]').map((h) => h.toLowerCase())
29
32
  // names and patterns for HTTP(S)
30
33
  const allow = entries.filter((e) => !e.includes(':'))
@@ -38,6 +41,9 @@ const publicOnly = JSON.parse(process.env.HAMMERKIT_PUBLIC_ONLY || '[]').map((h)
38
41
  // the names of the needed services, for tools that ignore NO_PROXY: exact
39
42
  // names only, never patterns
40
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 || ''
41
47
  const port = parseInt(process.env.HAMMERKIT_PROXY_PORT || '${exports.EGRESS_PROXY_PORT}', 10)
42
48
  // the name whose address the proxy listens on, every address when unset
43
49
  const bind = process.env.HAMMERKIT_PROXY_BIND
@@ -64,6 +70,31 @@ function allowedBy(host, port) {
64
70
  }
65
71
  return allow.some((rule) => rule.startsWith('*.') && name.endsWith(rule.substring(1))) ? 'wildcard' : null
66
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
+ }
67
98
  function ipv4(address) {
68
99
  const mapped = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/i.exec(address)
69
100
  const value = mapped ? mapped[1] : address
@@ -197,7 +228,7 @@ const server = http.createServer((req, res) => {
197
228
  server.on('connect', (req, socket, head) => {
198
229
  const target = parseAuthority(req.url || '')
199
230
  socket.on('error', () => socket.destroy())
200
- const rule = target ? allowedBy(target.host, target.port) : null
231
+ const rule = target ? allowedBy(target.host, target.port) || allowedForSecrets(req, target.host, target.port) : null
201
232
  if (!rule) {
202
233
  refuse(target ? target.host : String(req.url))
203
234
  socket.end('HTTP/1.1 403 Forbidden\\r\\ncontent-type: text/plain\\r\\n\\r\\nhammerkit: network access is not allowed\\n')
@@ -290,7 +321,8 @@ exports.getEgressProxyCommand = getEgressProxyCommand;
290
321
  // `needs` are the names of the needed services, allowed as exactly those
291
322
  // names. `bind` is the name the proxy listens on alone, so that nothing on its
292
323
  // other networks reaches it. `publicOnly` are the entries of `hosts` that reach
293
- // no private address (see WorkNetwork).
324
+ // no private address (see WorkNetwork). The endpoints of secret providers and
325
+ // the token they need are set by the caller (getSecretProxyEnvironment).
294
326
  function getEgressProxyEnvironment(hosts, needs, bind, publicOnly = []) {
295
327
  return {
296
328
  HAMMERKIT_ALLOW: JSON.stringify(hosts),
@@ -301,6 +333,12 @@ function getEgressProxyEnvironment(hosts, needs, bind, publicOnly = []) {
301
333
  };
302
334
  }
303
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;
304
342
  // The proxy of a task or service: the hosts of its network in this run (under
305
343
  // the run's ceiling), and the names of the services it needs, on every runtime.
306
344
  function getItemProxyEnvironment(item, bind) {
@@ -315,8 +353,9 @@ function getEgressProxyPorts(network) {
315
353
  return [exports.EGRESS_PROXY_PORT, ...(0, work_network_1.getTcpForwards)(network).map((forward) => forward.port)];
316
354
  }
317
355
  exports.getEgressProxyPorts = getEgressProxyPorts;
318
- function getProxyClientEnvironment(directHosts) {
319
- const proxy = `http://${exports.EGRESS_PROXY_HOST}:${exports.EGRESS_PROXY_PORT}`;
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}`;
320
359
  const noProxy = ['localhost', '127.0.0.1', ...directHosts].join(',');
321
360
  return {
322
361
  HTTP_PROXY: proxy,
@@ -4,9 +4,10 @@ exports.calculateChecksum = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  // Hashes the raw bytes: decoding to text first would map every invalid UTF-8
6
6
  // sequence to U+FFFD, so two different binary files could share a checksum.
7
- // For UTF-8 text the result is unchanged.
7
+ // For UTF-8 text the result is unchanged. sha256, which nobody can make two files share:
8
+ // a cache hit is also what a verified check counts (check-key.ts).
8
9
  async function calculateChecksum(environment, path) {
9
- const hash = (0, crypto_1.createHash)('sha1');
10
+ const hash = (0, crypto_1.createHash)('sha256');
10
11
  for await (const chunk of environment.file.readStream(path)) {
11
12
  hash.update(chunk);
12
13
  }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCheckKey = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const state_key_1 = require("../executer/scheduler/state-key");
6
+ const get_work_cache_stats_1 = require("./get-work-cache-stats");
7
+ const work_task_cache_description_1 = require("./work-task-cache-description");
8
+ /**
9
+ * What a passed check is recorded under, and what `remote verify` looks it up by: the
10
+ * inputs of the task's cache key, so a cached result counts for the same key it was
11
+ * stored under. Its definition (the services it needs included), the files of its `src`,
12
+ * the keys of its dependencies, and the sources of the services it needs and of the tasks
13
+ * they depend on.
14
+ *
15
+ * It is computed on a checkout before anything ran in it, on both ends, so it also covers
16
+ * the files a commit holds where a dependency generates (the cache leaves those out, as
17
+ * they change once the dependency ran), and it leaves out `.git`, which is no file of a
18
+ * commit. Two checkouts of the same files give the same key wherever they are.
19
+ * Null for a task that can not be proven up to date (no `src` of its own, or none that
20
+ * matches a file, here or in a dependency): its result says nothing about any files.
21
+ */
22
+ async function getCheckKey(item, environment, memo = new Map(), path = new Set()) {
23
+ return memoized(memo, path, `task\0${item.id()}`, (inner) => computeCheckKey(item, environment, memo, inner));
24
+ }
25
+ exports.getCheckKey = getCheckKey;
26
+ async function computeCheckKey(item, environment, memo, path) {
27
+ const { stats, unmatched } = await (0, get_work_cache_stats_1.getWorkItemCheckoutStats)(item, environment);
28
+ if (!(0, state_key_1.isOwnProvable)(item, unmatched)) {
29
+ return null;
30
+ }
31
+ const inputs = await getInputKeys(item, environment, memo, path);
32
+ if (inputs === null) {
33
+ return null;
34
+ }
35
+ return hash({ task: (0, work_task_cache_description_1.getWorkTaskCacheDescription)(item), files: commitFiles(stats), ...inputs });
36
+ }
37
+ // The sources of a needed service, with the keys of what it depends on and needs; its
38
+ // definition is in the description of the task that needs it.
39
+ function getNeedKey(item, environment, memo, path) {
40
+ return memoized(memo, path, `need\0${item.name}`, async (inner) => {
41
+ const inputs = await getInputKeys(item, environment, memo, inner);
42
+ if (inputs === null) {
43
+ return null;
44
+ }
45
+ return hash({ files: commitFiles(await (0, get_work_cache_stats_1.getWorkCacheStats)(item.data, environment)), ...inputs });
46
+ });
47
+ }
48
+ async function getInputKeys(item, environment, memo, path) {
49
+ const deps = [];
50
+ for (const dep of item.deps) {
51
+ const key = await getCheckKey(dep, environment, memo, path);
52
+ if (key === null) {
53
+ return null;
54
+ }
55
+ deps.push(key);
56
+ }
57
+ const needs = [];
58
+ for (const need of item.needs) {
59
+ const key = await getNeedKey(need.service, environment, memo, path);
60
+ if (key === null) {
61
+ return null;
62
+ }
63
+ needs.push(`${need.name}=${key}`);
64
+ }
65
+ return { deps: deps.sort(), needs: needs.sort() };
66
+ }
67
+ // a commit never holds `.git`: what a checkout keeps there differs between a CI job's clone,
68
+ // the server's fetch and a sandbox's copy, which has none
69
+ function commitFiles(stats) {
70
+ return Object.keys(stats.files)
71
+ .filter((path) => !path.split('/').includes('.git'))
72
+ .sort()
73
+ .map((path) => [path, stats.files[path].checksum]);
74
+ }
75
+ function hash(value) {
76
+ return (0, crypto_1.createHash)('sha256').update(JSON.stringify(value)).digest('hex');
77
+ }
78
+ // `path` holds the keys being computed on the way here: one coming back is a loop (services
79
+ // that need each other), which can not be proven and fails to plan anyway. Waiting on its
80
+ // pending promise would never end. The keys are computed one after another, so every pending
81
+ // promise in the memo is on the path.
82
+ function memoized(memo, path, key, compute) {
83
+ if (path.has(key)) {
84
+ return Promise.resolve(null);
85
+ }
86
+ const known = memo.get(key);
87
+ if (known) {
88
+ return known;
89
+ }
90
+ const computed = compute(new Set([...path, key]));
91
+ memo.set(key, computed);
92
+ return computed;
93
+ }
94
+ //# sourceMappingURL=check-key.js.map
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCheckKeys = void 0;
4
+ const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
5
+ const reference_parser_1 = require("../schema/reference-parser");
6
+ const schema_parser_1 = require("../schema/schema-parser");
7
+ const work_scope_parser_1 = require("../schema/work-scope-parser");
8
+ const log_1 = require("../log");
9
+ const check_key_1 = require("./check-key");
10
+ /**
11
+ * The check keys of the tasks of a scope, as both ends compute them: the process of a run on
12
+ * the server before it executes anything, and `remote verify` in a CI job's checkout. So the
13
+ * build file is read the same way on both, as a server reads it (no `.env`, links are files)
14
+ * but placed nowhere: where a server runs a task (a pool, its tool image, the arch of its
15
+ * nodes) is the server's, and a result counts for the server it passed on. The secrets'
16
+ * providers and accounts are the server's too, and no part of a key, so any is taken here.
17
+ */
18
+ async function getCheckKeys(entry, environment, scope) {
19
+ const keyed = { ...environment, hosted: { catalog: anyCatalog(), localTasksInContainers: true } };
20
+ const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(entry, keyed);
21
+ const workTree = (0, work_scope_parser_1.getWorkContext)(await (0, reference_parser_1.parseReferences)(ctx, parsed, keyed), scope, keyed);
22
+ const memo = new Map();
23
+ const keys = {};
24
+ for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
25
+ try {
26
+ const key = await (0, check_key_1.getCheckKey)(task, keyed, memo);
27
+ keys[task.name] = key
28
+ ? { key }
29
+ : {
30
+ key: null,
31
+ reason: 'it, or a task it or a service it needs depends on, declares no src that matches a file',
32
+ };
33
+ }
34
+ catch (e) {
35
+ // a secret whose value is part of the key (`cache: true`) is not known here
36
+ keys[task.name] = { key: null, reason: (0, log_1.getErrorMessage)(e) };
37
+ }
38
+ }
39
+ return keys;
40
+ }
41
+ exports.getCheckKeys = getCheckKeys;
42
+ // Every provider and account name resolves, to a definition nothing runs.
43
+ function anyCatalog() {
44
+ const provider = (name) => ({
45
+ name,
46
+ type: 'command',
47
+ command: [],
48
+ env: {},
49
+ timeoutMs: 0,
50
+ declaredIn: 'the server',
51
+ });
52
+ const account = (name) => ({
53
+ name,
54
+ default: false,
55
+ providers: new Proxy({}, { get: () => ({ env: {} }) }),
56
+ declaredIn: 'the server',
57
+ });
58
+ return {
59
+ providers: new Proxy({}, { get: (_target, name) => (typeof name === 'string' ? provider(name) : undefined) }),
60
+ accounts: new Proxy({}, { get: (_target, name) => (typeof name === 'string' ? account(name) : undefined) }),
61
+ explicitAccounts: true,
62
+ };
63
+ }
64
+ //# sourceMappingURL=check-keys.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getStateKey = exports.getWorkItemCacheStats = exports.getWorkCacheStats = void 0;
3
+ exports.stateKeyDigest = exports.getStateKey = exports.getWorkItemCheckoutStats = exports.getWorkItemCacheStats = exports.getWorkCacheStats = void 0;
4
4
  const calculate_checksum_1 = require("./calculate-checksum");
5
5
  const path_1 = require("path");
6
6
  const crypto_1 = require("crypto");
@@ -20,7 +20,7 @@ async function addWorkCacheStats(result, cwd, path, matcher, context, root, excl
20
20
  if (!matcher(path)) {
21
21
  return 0;
22
22
  }
23
- const checksum = (0, crypto_1.createHash)('sha1')
23
+ const checksum = (0, crypto_1.createHash)('sha256')
24
24
  .update(`link:${await (0, promises_1.readlink)(path)}`)
25
25
  .digest('hex');
26
26
  result.files[(0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep)] = { lastModified: link.mtimeMs, checksum };
@@ -85,6 +85,13 @@ function getWorkItemCacheStats(item, environment) {
85
85
  return collectSourceStats(item.data, environment, dependencyOutputs(item));
86
86
  }
87
87
  exports.getWorkItemCacheStats = getWorkItemCacheStats;
88
+ // The source stats of a task with what its dependencies generate: on a checkout nothing ran
89
+ // in, those are the files a commit holds there, see check-key.ts.
90
+ async function getWorkItemCheckoutStats(item, environment) {
91
+ const { stats } = await collectSourceStats(item.data, environment, []);
92
+ return { stats, unmatched: (await getWorkItemCacheStats(item, environment)).unmatched };
93
+ }
94
+ exports.getWorkItemCheckoutStats = getWorkItemCheckoutStats;
88
95
  function dependencyOutputs(item) {
89
96
  const outputs = new Set();
90
97
  const visited = new Set();
@@ -113,7 +120,14 @@ function getStateKey(stats, cacheMethod) {
113
120
  contents.push(`${key}:${stats.files[key].lastModified}`);
114
121
  }
115
122
  }
116
- return (0, crypto_1.createHash)('md5').update(contents.join(',')).digest('hex');
123
+ return stateKeyDigest(contents.join(','));
117
124
  }
118
125
  exports.getStateKey = getStateKey;
126
+ // A state key is also the value of the `hammerkit.dev/state` label on Kubernetes, which
127
+ // takes at most 63 characters: the sha256 in base 36 has 50.
128
+ function stateKeyDigest(content) {
129
+ const hex = (0, crypto_1.createHash)('sha256').update(content).digest('hex');
130
+ return BigInt(`0x${hex}`).toString(36).padStart(50, '0');
131
+ }
132
+ exports.stateKeyDigest = stateKeyDigest;
119
133
  //# sourceMappingURL=get-work-cache-stats.js.map
@@ -1,11 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.storedCacheDescription = exports.getWorkTaskCacheDescription = void 0;
3
+ exports.storedCacheDescription = exports.getWorkServiceCacheIdentity = exports.getWorkTaskCacheDescription = void 0;
4
4
  const os_1 = require("os");
5
5
  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");
10
+ const work_service_1 = require("../planner/work-service");
11
+ const work_service_id_1 = require("../planner/work-service-id");
12
+ const work_cache_id_1 = require("../planner/work-cache-id");
9
13
  // Every path is made project-relative (portablePath) so the description, and the
10
14
  // task id hashed from it, is identical wherever the project is checked out.
11
15
  //
@@ -40,9 +44,55 @@ function getWorkTaskCacheDescription(item) {
40
44
  cwd: portable(task.cwd),
41
45
  deps: item.deps.length > 0 ? item.deps.map((dep) => dep.id()).sort() : undefined,
42
46
  secrets: cachedSecrets.length > 0 ? cachedSecrets : undefined,
47
+ claimLayout: (0, claim_layout_1.hasPathsEndingAlike)(item) || (0, claim_layout_1.hasOutputsFlatteningAlike)(item) ? 2 : undefined,
48
+ needs: item.needs.length > 0 ? getNeedsIdentities(item) : undefined,
43
49
  };
44
50
  }
45
51
  exports.getWorkTaskCacheDescription = getWorkTaskCacheDescription;
52
+ // `visiting` holds the services on the way here: services that need each other in a loop
53
+ // fail to plan (checkForLoop), the identity only has to come back to say so
54
+ function getNeedsIdentities(item, visiting = new Set()) {
55
+ return (item.needs
56
+ // an init task needs the service it initializes, whose identity holds the init's id
57
+ .filter((need) => !isInitOf(item, need.service))
58
+ .map((need) => visiting.has(need.service)
59
+ ? `${need.name}=loop`
60
+ : `${need.name}=${getWorkServiceCacheIdentity(need.service, visiting)}`)
61
+ .sort());
62
+ }
63
+ function isInitOf(item, service) {
64
+ return (0, work_service_1.isContainerWorkService)(service.data) && service.data.init?.task.data === item.data;
65
+ }
66
+ // Everything that makes a service what a task needing it talks to: its definition, the ids of
67
+ // the tasks it depends on and of its init task, and the services it needs in turn. The source
68
+ // of those tasks and of the service is in the state key (computeStateKey), like a dependency's.
69
+ // Not the service id, which leaves out what a service runs (getWorkServiceId).
70
+ function getWorkServiceCacheIdentity(item, visiting = new Set()) {
71
+ const service = item.data;
72
+ const definition = (0, work_service_1.isContainerWorkService)(service)
73
+ ? {
74
+ cmd: service.cmd?.cmd ?? null,
75
+ envs: (0, replace_env_variables_1.getEnvironmentVariables)(service.envs),
76
+ healthcheck: service.healthcheck?.cmd.cmd ?? null,
77
+ secrets: service.secrets
78
+ .filter((secret) => secret.cache)
79
+ .map((secret) => `${(0, work_secret_1.getPortableSecretName)(service.projectRoot, secret)}=${secret.digest()}`)
80
+ .sort(),
81
+ // as declared: arguments after -- of a run of the init leave the service as it is
82
+ init: service.init ? (0, work_cache_id_1.getDeclaredWorkTaskId)(service.init.task) : null,
83
+ }
84
+ : { namespace: service.namespace };
85
+ return (0, crypto_1.createHash)('sha256')
86
+ .update(JSON.stringify({
87
+ id: (0, work_service_id_1.getWorkServiceId)(service),
88
+ ...definition,
89
+ ports: service.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`).sort(),
90
+ deps: item.deps.map((dep) => dep.id()).sort(),
91
+ needs: getNeedsIdentities(item, new Set([...visiting, item])),
92
+ }))
93
+ .digest('hex');
94
+ }
95
+ exports.getWorkServiceCacheIdentity = getWorkServiceCacheIdentity;
46
96
  // The form written to disk: description.json in a cache entry (pushed to remote
47
97
  // backends, readable by everyone who can pull) and the local explain record.
48
98
  // Env is where tokens get passed, so each value is stored as a digest; the task
@@ -17,9 +17,14 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
17
17
  }
18
18
  }
19
19
  }
20
- if (((0, work_task_1.isContainerWorkTask)(depNode.data) || (0, work_service_1.isContainerWorkService)(depNode.data)) &&
21
- ((0, work_task_1.isContainerWorkTask)(item.data) || (0, work_service_1.isContainerWorkService)(item.data))) {
20
+ // an aggregate hands on what its dependencies generate, as if what depends on it
21
+ // depended on them
22
+ if (((0, work_task_1.isContainerWorkTask)(depNode.data) || (0, work_service_1.isContainerWorkService)(depNode.data) || (0, work_task_1.isAggregateTask)(depNode.data)) &&
23
+ ((0, work_task_1.isContainerWorkTask)(item.data) || (0, work_service_1.isContainerWorkService)(item.data) || (0, work_task_1.isAggregateTask)(item.data))) {
24
+ // the outputs of the dep's own deps too, which stay those of the task that made them
25
+ // (on Kubernetes, they are in its claim)
22
26
  for (const generate of depNode.data.generates) {
27
+ const inherited = generate.inherited ?? depNode;
23
28
  if (generate.isFile) {
24
29
  // TODO check if thats correct
25
30
  continue;
@@ -31,12 +36,12 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
31
36
  export: false,
32
37
  resetOnChange: false,
33
38
  containerPath: generate.path,
34
- inherited: depNode,
39
+ inherited,
35
40
  readOnly: false,
36
41
  });
37
42
  }
38
43
  }
39
- else {
44
+ else if ((0, work_task_1.isWorkTask)(item.data)) {
40
45
  if (!item.data.generates.some((v) => v.volumeName === generate.volumeName)) {
41
46
  item.data.generates.push({
42
47
  volumeName: generate.volumeName,
@@ -44,7 +49,7 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
44
49
  exportAlways: false,
45
50
  resetOnChange: false,
46
51
  path: generate.path,
47
- inherited: depNode,
52
+ inherited,
48
53
  isFile: false,
49
54
  });
50
55
  }
@@ -120,6 +120,7 @@ function parseService(workTree, service, environment, context) {
120
120
  volumeSize: service.schema.volumeSize ?? null,
121
121
  network: (0, work_network_1.parseWorkNetwork)(service.schema.network),
122
122
  networkCeiling: null,
123
+ args: service.schema.args ?? null,
123
124
  };
124
125
  }
125
126
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.appendWorkTask = void 0;
4
4
  const work_secret_1 = require("../work-secret");
5
+ const work_task_1 = require("../work-task");
5
6
  const parse_work_generate_1 = require("./parse-work-generate");
6
7
  const template_value_1 = require("./template-value");
7
8
  const parse_work_mounts_1 = require("./parse-work-mounts");
@@ -25,7 +26,8 @@ function appendWorkTask(workTree, cwd, referenceTask, environment, context) {
25
26
  const parsed = parseTask(cwd, referenceTask, environment, context);
26
27
  // on a server's cluster a local task runs in a tool image like a container task
27
28
  const server = workTree.environment.type === 'kubernetes' ? workTree.environment.server : undefined;
28
- const task = !server
29
+ // an aggregate runs nothing, so it needs no pod either
30
+ const task = !server || (0, work_task_1.isAggregateTask)(parsed)
29
31
  ? parsed
30
32
  : parsed.type === 'local-task'
31
33
  ? (0, job_placement_1.runInToolImage)(parsed, server, referenceTask.schema.platform)
@@ -83,6 +85,7 @@ function parseTask(cwd, task, environment, context) {
83
85
  resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
84
86
  network: (0, work_network_1.parseWorkNetwork)(task.schema.network),
85
87
  networkCeiling: null,
88
+ args: task.schema.args ?? null,
86
89
  runner: task.schema.runner ?? task.scope.schema.runner ?? null,
87
90
  };
88
91
  if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
@@ -100,13 +103,23 @@ function parseTask(cwd, task, environment, context) {
100
103
  if (fileSecret) {
101
104
  throw new Error(`task ${task.relativeName}: a local task takes secrets as env only (${(0, work_secret_1.getSecretName)(fileSecret)})`);
102
105
  }
103
- if (baseWorkNode.network.type !== 'open') {
106
+ // on a server's cluster it runs in a pod of a pool's tool image (runInToolImage)
107
+ const contained = !!environment.hosted?.kubernetes || !!environment.hosted?.localTasksInContainers;
108
+ if (baseWorkNode.network.type !== 'open' && !contained) {
104
109
  throw new Error(`task ${task.relativeName}: network ${(0, work_network_1.formatNetwork)(baseWorkNode.network)} needs a container, ` +
105
110
  `hammerkit can't limit the network of a local task`);
106
111
  }
112
+ const aggregate = task.deps.length > 0 &&
113
+ task.needs.length === 0 &&
114
+ baseWorkNode.src.length === 0 &&
115
+ !baseWorkNode.continuous &&
116
+ baseWorkNode.cmds.length === 0 &&
117
+ baseWorkNode.generates.length === 0 &&
118
+ baseWorkNode.secrets.length === 0;
107
119
  return {
108
120
  ...baseWorkNode,
109
121
  type: 'local-task',
122
+ ...(aggregate ? { aggregate } : {}),
110
123
  };
111
124
  }
112
125
  }
@@ -4,6 +4,8 @@ exports.getWorkTaskRuntime = exports.getWorkServiceRuntime = void 0;
4
4
  const work_runtime_docker_1 = require("../work-runtime-docker");
5
5
  const work_runtime_kubernetes_1 = require("../work-runtime-kubernetes");
6
6
  const work_runtime_local_1 = require("../work-runtime-local");
7
+ const work_runtime_aggregate_1 = require("../work-runtime-aggregate");
8
+ const work_task_1 = require("../work-task");
7
9
  const work_item_1 = require("../work-item");
8
10
  function getWorkServiceRuntime(workTree, service) {
9
11
  if ((0, work_item_1.isContainerWorkServiceItem)(service)) {
@@ -24,7 +26,10 @@ function getWorkServiceRuntime(workTree, service) {
24
26
  }
25
27
  exports.getWorkServiceRuntime = getWorkServiceRuntime;
26
28
  function getWorkTaskRuntime(workTree, task) {
27
- if ((0, work_item_1.isLocalWorkTaskItem)(task)) {
29
+ if ((0, work_task_1.isAggregateTask)(task.data)) {
30
+ return (0, work_runtime_aggregate_1.aggregateWorkRuntime)();
31
+ }
32
+ else if ((0, work_item_1.isLocalWorkTaskItem)(task)) {
28
33
  return (0, work_runtime_local_1.getLocalWorkRuntime)(task);
29
34
  }
30
35
  else if ((0, work_item_1.isContainerWorkTaskItem)(task)) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizePath = void 0;
3
+ exports.normalizeHostPath = exports.getHomeReference = exports.normalizePath = void 0;
4
4
  const path_1 = require("path");
5
5
  function normalizePath(cwd, pwd, path) {
6
6
  if (path.startsWith('/')) {
@@ -14,4 +14,22 @@ function normalizePath(cwd, pwd, path) {
14
14
  }
15
15
  }
16
16
  exports.normalizePath = normalizePath;
17
+ // What a path of the host starts with to name the home directory: `~` and `$HOME`, and
18
+ // `$PWD`, which in a mount has always meant the home directory, not the project it reads like
19
+ function getHomeReference(path) {
20
+ for (const reference of ['~', '$HOME']) {
21
+ if (path === reference || path.startsWith(`${reference}/`) || path.startsWith(`${reference}\\`)) {
22
+ return reference;
23
+ }
24
+ }
25
+ return path.startsWith('$PWD') ? '$PWD' : null;
26
+ }
27
+ exports.getHomeReference = getHomeReference;
28
+ // A path of the host in a mount: in the home directory when it starts with one of the
29
+ // references above, else in the directory of the build file unless it is absolute
30
+ function normalizeHostPath(cwd, home, path) {
31
+ const reference = getHomeReference(path);
32
+ return reference ? (0, path_1.join)(home, path.substring(reference.length)) : normalizePath(cwd, home, path);
33
+ }
34
+ exports.normalizeHostPath = normalizeHostPath;
17
35
  //# sourceMappingURL=normalize-path.js.map
@@ -7,6 +7,12 @@ const path_1 = require("path");
7
7
  function parseWorkMount(cwd, dir) {
8
8
  const parts = dir.split(':');
9
9
  if (parts.length === 1) {
10
+ const home = (0, normalize_path_1.getHomeReference)(dir);
11
+ if (home === '~' || home === '$HOME') {
12
+ // the container has no such directory: the same path on both sides would be one of the
13
+ // project there (`$PWD` has always been, so it stays)
14
+ throw new Error(`mount ${dir} is in the home directory, name where it goes in the container (${dir}:/path)`);
15
+ }
10
16
  return parseLocalMount(cwd, dir, dir, dir, false);
11
17
  }
12
18
  else if (parts.length === 2) {
@@ -22,7 +28,7 @@ function parseWorkMount(cwd, dir) {
22
28
  exports.parseWorkMount = parseWorkMount;
23
29
  function parseLocalMount(cwd, localPath, containerPath, mount, readOnly) {
24
30
  return {
25
- localPath: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), localPath),
31
+ localPath: (0, normalize_path_1.normalizeHostPath)(cwd, (0, os_1.homedir)(), localPath),
26
32
  containerPath: (0, normalize_path_1.normalizePath)(cwd, cwd, containerPath),
27
33
  isFile: (0, path_1.extname)(localPath).length > 1,
28
34
  mount,
@@ -8,7 +8,10 @@ function parseWorkPort(port) {
8
8
  return { containerPort: port, hostPort: port };
9
9
  }
10
10
  else if (parts.length === 2) {
11
- return { containerPort: parsePort(parts[1], false), hostPort: parsePort(parts[0], true) };
11
+ // `0:80` is not published, as an empty host port: 0 (a free port) is for the endpoints
12
+ // of a server only
13
+ const hostPort = parsePort(parts[0], true);
14
+ return { containerPort: parsePort(parts[1], false), hostPort: hostPort === 0 ? null : hostPort };
12
15
  }
13
16
  else {
14
17
  throw new Error(`invalid port ${port}`);
@@ -101,6 +101,9 @@ function applyArguments(selection, args, keepCache = false) {
101
101
  if (item.type === 'kubernetes-service') {
102
102
  throw new Error(`service ${item.name} is a kubernetes service and has no cmd for the arguments after --`);
103
103
  }
104
+ if (item.args === false) {
105
+ throw new Error(`${item.type === 'container-service' ? 'service' : 'task'} ${item.name} takes no arguments after -- (args: false)`);
106
+ }
104
107
  const target = getArgumentsTarget(item);
105
108
  if (!target) {
106
109
  throw new Error(item.type === 'container-service'