hammerkit 1.27.0-next.8 → 2.0.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 (129) hide show
  1. package/build.schema.json +176 -6
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/cache-sync.js +1 -1
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +6 -3
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +76 -17
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-http-route.js +1 -1
  36. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +138 -18
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  38. package/dist/kubernetes/ensure-persistent-data.js +212 -10
  39. package/dist/kubernetes/follow-pod-logs.js +182 -0
  40. package/dist/kubernetes/get-service-ip.js +2 -2
  41. package/dist/kubernetes/job-placement.js +36 -12
  42. package/dist/kubernetes/job-secrets.js +9 -6
  43. package/dist/kubernetes/kept-claims.js +22 -10
  44. package/dist/kubernetes/kubernetes-instance.js +1 -0
  45. package/dist/kubernetes/network-policy.js +16 -8
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/remove-stale-items.js +46 -0
  48. package/dist/kubernetes/resources.js +25 -3
  49. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  50. package/dist/kubernetes/sandbox-runner.js +628 -0
  51. package/dist/kubernetes/secrets.js +7 -11
  52. package/dist/kubernetes/service-rollouts.js +74 -0
  53. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  54. package/dist/kubernetes/volumes.js +96 -11
  55. package/dist/main.js +2 -0
  56. package/dist/optimizer/calculate-checksum.js +3 -2
  57. package/dist/optimizer/check-key.js +94 -0
  58. package/dist/optimizer/check-keys.js +64 -0
  59. package/dist/optimizer/get-work-cache-stats.js +17 -3
  60. package/dist/optimizer/work-task-cache-description.js +49 -1
  61. package/dist/planner/service-definition.js +9 -4
  62. package/dist/planner/utils/append-work-dependencies.js +5 -3
  63. package/dist/planner/utils/append-work-service.js +18 -1
  64. package/dist/planner/utils/append-work-task.js +13 -3
  65. package/dist/planner/utils/get-work-runtime.js +6 -1
  66. package/dist/planner/utils/normalize-path.js +19 -1
  67. package/dist/planner/utils/parse-work-mount.js +7 -1
  68. package/dist/planner/utils/parse-work-port.js +4 -1
  69. package/dist/planner/validate.js +22 -0
  70. package/dist/planner/work-network.js +3 -1
  71. package/dist/planner/work-runtime-aggregate.js +32 -0
  72. package/dist/planner/work-runtime-kubernetes.js +153 -26
  73. package/dist/planner/work-task.js +6 -1
  74. package/dist/program.js +27 -9
  75. package/dist/remote/device-login.js +7 -2
  76. package/dist/remote/host-config.js +69 -3
  77. package/dist/remote/remote-commands.js +33 -7
  78. package/dist/remote/remote-status.js +112 -0
  79. package/dist/remote/remote-work-commands.js +562 -45
  80. package/dist/remote/server-client.js +15 -2
  81. package/dist/remote/token-source.js +64 -26
  82. package/dist/run-program.js +9 -0
  83. package/dist/schema/build-file-base-task-schema.js +3 -3
  84. package/dist/schema/build-file-container-service-schema.js +10 -0
  85. package/dist/schema/build-file-container-task-schema.js +16 -1
  86. package/dist/schema/build-file-include-schema.js +18 -4
  87. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  88. package/dist/schema/build-file-task-schema.js +5 -1
  89. package/dist/schema/capabilities-schema.js +56 -0
  90. package/dist/schema/reference-parser.js +119 -11
  91. package/dist/schema/removed-properties.js +20 -0
  92. package/dist/schema/schema-parser.js +94 -41
  93. package/dist/schema/work-scope-parser.js +33 -10
  94. package/dist/secrets/command-secret-provider.js +3 -1
  95. package/dist/secrets/github-app-token-command.js +3 -1
  96. package/dist/secrets/secret-catalog.js +43 -19
  97. package/dist/server/accounts-api.js +4 -1
  98. package/dist/server/approvals-api.js +1 -1
  99. package/dist/server/capacity-format.js +36 -0
  100. package/dist/server/capacity.js +300 -0
  101. package/dist/server/checks-api.js +83 -0
  102. package/dist/server/checks.js +116 -0
  103. package/dist/server/dashboard.js +196 -0
  104. package/dist/server/definitions-api.js +66 -3
  105. package/dist/server/endpoints.js +794 -0
  106. package/dist/server/executor.js +164 -9
  107. package/dist/server/kubernetes-backend.js +478 -70
  108. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  109. package/dist/server/overview.js +271 -0
  110. package/dist/server/plan.js +170 -121
  111. package/dist/server/policy.js +34 -2
  112. package/dist/server/proposals-api.js +17 -8
  113. package/dist/server/repo-access.js +5 -0
  114. package/dist/server/run-logs.js +4 -0
  115. package/dist/server/run-plan.js +38 -4
  116. package/dist/server/run-request.js +58 -3
  117. package/dist/server/run-worker.js +262 -13
  118. package/dist/server/runs.js +969 -36
  119. package/dist/server/sandbox-pod.js +28 -0
  120. package/dist/server/sandbox-storage.js +23 -0
  121. package/dist/server/sandboxes.js +8 -0
  122. package/dist/server/server-config.js +246 -10
  123. package/dist/server/server.js +429 -32
  124. package/dist/server/service-runs.js +82 -0
  125. package/dist/server/timings.js +132 -0
  126. package/dist/server/wait-reason.js +21 -0
  127. package/dist/utils/tar.js +28 -1
  128. package/dist/utils/terminal-prompt.js +53 -0
  129. package/package.json +8 -2
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serviceRollouts = exports.ServiceRollouts = void 0;
4
+ const apply_1 = require("./apply");
5
+ const log_1 = require("../log");
6
+ /**
7
+ * The Deployments an execution of a service run replaced or made (rollOut). A move whose
8
+ * services do not all get up leaves the run where it was: the execution puts every one of
9
+ * them back as it found it and removes the ones it made, so no service serves the commit the
10
+ * run did not get to, and nothing of it takes the quota of the namespace.
11
+ */
12
+ class ServiceRollouts {
13
+ constructor() {
14
+ this.rolled = new Map();
15
+ }
16
+ // before the Deployment is replaced or made; the first time counts
17
+ add(namespace, name, previous, restore) {
18
+ const key = `${namespace}/${name}`;
19
+ if (!this.rolled.has(key)) {
20
+ this.rolled.set(key, {
21
+ namespace,
22
+ name,
23
+ ...(previous?.spec ? { previous } : {}),
24
+ ...(restore ? { restore } : {}),
25
+ });
26
+ }
27
+ }
28
+ get size() {
29
+ return this.rolled.size;
30
+ }
31
+ async undo(instance, say) {
32
+ const rolled = [...this.rolled.values()];
33
+ this.rolled.clear();
34
+ for (const { namespace, name, previous, restore } of rolled) {
35
+ try {
36
+ if (previous?.spec && restore) {
37
+ say(`putting the files of ${name} back as they were`);
38
+ await restore();
39
+ }
40
+ if (previous?.spec) {
41
+ say(`putting ${name} back as it was`);
42
+ await (0, apply_1.replaceDeployment)(instance, {
43
+ kind: 'Deployment',
44
+ apiVersion: 'apps/v1',
45
+ metadata: {
46
+ namespace,
47
+ name,
48
+ labels: previous.metadata?.labels,
49
+ annotations: previous.metadata?.annotations,
50
+ },
51
+ spec: previous.spec,
52
+ });
53
+ }
54
+ else {
55
+ say(`removing ${name}, which it made`);
56
+ await instance.appsApi
57
+ .deleteNamespacedDeployment({ name, namespace, propagationPolicy: 'Background' })
58
+ .catch((e) => {
59
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
60
+ throw e;
61
+ }
62
+ });
63
+ }
64
+ }
65
+ catch (e) {
66
+ say(`${name} could not be put back: ${(0, log_1.getErrorMessage)(e)}`);
67
+ }
68
+ }
69
+ }
70
+ }
71
+ exports.ServiceRollouts = ServiceRollouts;
72
+ // the rollouts of this process: a worker executes one run
73
+ exports.serviceRollouts = new ServiceRollouts();
74
+ //# sourceMappingURL=service-rollouts.js.map
@@ -5,8 +5,11 @@ const log_1 = require("../log");
5
5
  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
- async function storeKubernetesData(work, kubernetes, instance, environment, path) {
9
- const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes);
8
+ async function storeKubernetesData(work, kubernetes, instance, environment, path,
9
+ // the state the outputs are of: on a service run, they are in its folder (see
10
+ // getStateFilesDir)
11
+ stateKey) {
12
+ const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes, stateKey);
10
13
  await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'read', async (name) => {
11
14
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(work.data, path)) {
12
15
  const writerStream = environment.file.createWriteStream(generatedArchive.filename);
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getSandboxAffinity = exports.SANDBOX_LABEL = exports.getWorkspacePodSpec = exports.appendVolume = exports.getKubernetesPersistence = exports.getVolumeName = void 0;
3
+ exports.getItemFilesDir = exports.isLiveService = exports.LIVE_FILES_DIR = exports.getStateFilesDir = exports.STATE_FILES_PREFIX = exports.getSandboxAffinity = exports.SANDBOX_LABEL = exports.getClaimMountLabel = exports.getClaimPodSpec = exports.getWorkspacePodSpec = exports.appendVolume = exports.getKubernetesPersistence = exports.getVolumeName = void 0;
4
4
  const path_1 = require("path");
5
5
  const fs_1 = require("fs");
6
+ const crypto_1 = require("crypto");
6
7
  const kept_claims_1 = require("./kept-claims");
7
8
  const claim_layout_1 = require("./claim-layout");
8
9
  function getVolumeName(service, kubernetes) {
@@ -41,7 +42,15 @@ function appendWorkspaceMount(workspace, persistence, containerPath, localPath,
41
42
  });
42
43
  return true;
43
44
  }
44
- async function getKubernetesPersistence(work, kubernetes) {
45
+ async function getKubernetesPersistence(work, kubernetes,
46
+ // the state the work runs at, on a service run (see getStateFilesDir); what it inherits is
47
+ // at the state its own work item completed at
48
+ stateKey) {
49
+ const dirOf = (item) => kubernetes.server?.serviceRun ? getItemFilesDir(item, item === work ? stateKey : undefined) : undefined;
50
+ const own = dirOf(work);
51
+ // a service that watches its files mounts them from the folder they are synced into, and
52
+ // takes them into the folder of their state (see syncLiveFiles)
53
+ const mounted = own && isLiveService(work, kubernetes) ? exports.LIVE_FILES_DIR : own;
45
54
  const persistence = {
46
55
  volumes: [],
47
56
  sizes: {},
@@ -51,13 +60,13 @@ async function getKubernetesPersistence(work, kubernetes) {
51
60
  mounts: [],
52
61
  initCommands: [],
53
62
  };
54
- const workspace = kubernetes?.server?.workspace;
63
+ const workspace = kubernetes.server?.workspace;
55
64
  for (const mount of work.data.mounts) {
56
65
  if (workspace &&
57
66
  appendWorkspaceMount(workspace, persistence, mount.containerPath, mount.localPath, mount.isFile, mount.readOnly)) {
58
67
  continue;
59
68
  }
60
- appendVolume(work, persistence, mount.containerPath, mount.localPath, mount.isFile, true, kubernetes);
69
+ appendVolume(work, persistence, mount.containerPath, mount.localPath, mount.isFile, true, kubernetes, mounted, own);
61
70
  }
62
71
  for (const src of work.data.src) {
63
72
  if (src.inherited) {
@@ -67,24 +76,32 @@ async function getKubernetesPersistence(work, kubernetes) {
67
76
  appendWorkspaceMount(workspace, persistence, src.absolutePath, src.absolutePath, src.isFile, false)) {
68
77
  continue;
69
78
  }
70
- appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes); // TODO matcher
79
+ appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes, mounted, own); // TODO matcher
71
80
  }
72
81
  if (work.data.type === 'container-task') {
73
82
  for (const generate of work.data.generates) {
74
- appendVolume(generate.inherited ?? work, persistence, generate.path, null, false, !!generate.inherited, kubernetes);
83
+ appendVolume(generate.inherited ?? work, persistence, generate.path, null, false, !!generate.inherited, kubernetes, dirOf(generate.inherited ?? work));
75
84
  }
76
85
  }
77
86
  else {
78
87
  for (const volume of work.data.volumes) {
79
- appendVolume(volume.inherited ?? work, persistence, volume.containerPath, null, false, !!volume.inherited || volume.readOnly, kubernetes);
88
+ appendVolume(volume.inherited ?? work, persistence, volume.containerPath, null, false, !!volume.inherited || volume.readOnly, kubernetes,
89
+ // the data of a service stays where it is, the outputs of its deps are by their state
90
+ volume.inherited ? dirOf(volume.inherited) : undefined);
80
91
  }
81
92
  }
82
93
  return persistence;
83
94
  }
84
95
  exports.getKubernetesPersistence = getKubernetesPersistence;
85
- function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes) {
96
+ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes,
97
+ // the folder of the state of `sourceWork` the path is in, see getStateFilesDir
98
+ stateDir,
99
+ // the folder its upload goes to, when it is not the one the path is mounted from (a
100
+ // service that watches its files, see syncLiveFiles)
101
+ uploadDir = stateDir) {
86
102
  const volumeName = getVolumeName(sourceWork, kubernetes);
87
- const subPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath);
103
+ const claimPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath);
104
+ const subPath = (stateDir ? `${stateDir}/` : '') + claimPath;
88
105
  const existVolume = persistence.volumes.some((v) => v.name === volumeName);
89
106
  if (!existVolume) {
90
107
  persistence.volumes.push({
@@ -94,7 +111,7 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
94
111
  },
95
112
  });
96
113
  persistence.sizes[volumeName] = 'volumeSize' in sourceWork.data ? sourceWork.data.volumeSize : null;
97
- if (kubernetes && (0, kept_claims_1.isKeptClaim)(sourceWork, kubernetes)) {
114
+ if ((0, kept_claims_1.isKeptClaim)(sourceWork, kubernetes)) {
98
115
  persistence.kept.push(volumeName);
99
116
  }
100
117
  }
@@ -113,7 +130,7 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
113
130
  localPath: localPath,
114
131
  matcher: () => true, // TODO
115
132
  containerPath,
116
- subPath,
133
+ subPath: (uploadDir ? `${uploadDir}/` : '') + claimPath,
117
134
  stateKey: '', // TODO
118
135
  });
119
136
  }
@@ -128,6 +145,9 @@ exports.appendVolume = appendVolume;
128
145
  */
129
146
  function getWorkspacePodSpec(kubernetes, persistence, task) {
130
147
  const workspace = kubernetes.server?.workspace;
148
+ if (kubernetes.server?.serviceRun && !workspace) {
149
+ return getClaimPodSpec(persistence.volumes ?? []);
150
+ }
131
151
  if (!workspace || persistence.workspace.length === 0) {
132
152
  return { labels: {} };
133
153
  }
@@ -141,6 +161,41 @@ function getWorkspacePodSpec(kubernetes, persistence, task) {
141
161
  };
142
162
  }
143
163
  exports.getWorkspacePodSpec = getWorkspacePodSpec;
164
+ /**
165
+ * A pod of a service run that mounts claims: labelled by each of them, and made to go where a
166
+ * pod runs that mounts all of them. A run that moves to another commit starts a new pod of a
167
+ * service, a Job of a changed task or an upload while the pods of the commit before still
168
+ * mount the same claims, which a cloud disk attaches to one node at a time: a pod on another
169
+ * node would wait for the disk until its deadline. Required, as one term: while no pod mounts
170
+ * all of them (the first one, or after the run slept), the scheduler lets the pod go anywhere,
171
+ * because it matches the term itself, so a Deployment that keeps the term is never held back
172
+ * by it. Mounting all of them is what holds the disks on one node.
173
+ */
174
+ function getClaimPodSpec(volumes) {
175
+ const keys = [
176
+ ...new Set(volumes.flatMap((volume) => volume.persistentVolumeClaim ? [getClaimMountLabel(volume.persistentVolumeClaim.claimName)] : [])),
177
+ ].sort();
178
+ if (keys.length === 0) {
179
+ return { labels: {} };
180
+ }
181
+ const labels = Object.fromEntries(keys.map((key) => [key, 'mounted']));
182
+ return {
183
+ labels,
184
+ affinity: {
185
+ podAffinity: {
186
+ requiredDuringSchedulingIgnoredDuringExecution: [
187
+ { labelSelector: { matchLabels: labels }, topologyKey: 'kubernetes.io/hostname' },
188
+ ],
189
+ },
190
+ },
191
+ };
192
+ }
193
+ exports.getClaimPodSpec = getClaimPodSpec;
194
+ // a claim name is longer than a label name may be: its hash
195
+ function getClaimMountLabel(claimName) {
196
+ return `claim.hammerkit.dev/${(0, crypto_1.createHash)('sha256').update(claimName).digest('hex').slice(0, 16)}`;
197
+ }
198
+ exports.getClaimMountLabel = getClaimMountLabel;
144
199
  // the label of the pods that mount the claim of a sandbox
145
200
  exports.SANDBOX_LABEL = 'hammerkit.dev/sandbox';
146
201
  function getSandboxAffinity(label) {
@@ -153,4 +208,34 @@ function getSandboxAffinity(label) {
153
208
  };
154
209
  }
155
210
  exports.getSandboxAffinity = getSandboxAffinity;
211
+ // The folder of a service run's claim that holds the files of one state of a work item: the
212
+ // src and mounts it uploads, and the outputs of a task. A run that moves to another commit
213
+ // uploads and builds the files of the new state into a folder of their own, so the pods of
214
+ // the commit before keep the files and outputs they started with until the new ones are up,
215
+ // and a Deployment that is put back finds them again. The volumes of a service hold its data,
216
+ // which stays where it is.
217
+ exports.STATE_FILES_PREFIX = '.hammerkit-files-';
218
+ function getStateFilesDir(stateKey) {
219
+ return `${exports.STATE_FILES_PREFIX}${(0, crypto_1.createHash)('sha256').update(stateKey).digest('hex').slice(0, 16)}`;
220
+ }
221
+ exports.getStateFilesDir = getStateFilesDir;
222
+ // The folder of the claim of a service of a service run that watches its own files
223
+ // (`continuous`), which its pod mounts its `src` and `mounts` from. The files of each state
224
+ // are uploaded into the folder of that state, and synced into this one from there: the pod
225
+ // stays, and the service sees the files of the new commit (syncLiveFiles).
226
+ exports.LIVE_FILES_DIR = `${exports.STATE_FILES_PREFIX}live`;
227
+ function isLiveService(work, kubernetes) {
228
+ return (!!kubernetes.server?.serviceRun &&
229
+ !kubernetes.server.workspace &&
230
+ work.data.type === 'container-service' &&
231
+ work.data.continuous === true);
232
+ }
233
+ exports.isLiveService = isLiveService;
234
+ // The folder of the state of `item`: `stateKey`, or the one it completed at in this run
235
+ function getItemFilesDir(item, stateKey) {
236
+ const key = stateKey ??
237
+ item.state?.current.stateKey;
238
+ return key ? getStateFilesDir(key) : undefined;
239
+ }
240
+ exports.getItemFilesDir = getItemFilesDir;
156
241
  //# sourceMappingURL=volumes.js.map
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ const redact_1 = require("./utils/redact");
8
8
  const empty_writable_1 = require("./utils/empty-writable");
9
9
  const run_program_1 = require("./run-program");
10
10
  const abort_on_signals_1 = require("./utils/abort-on-signals");
11
+ const terminal_prompt_1 = require("./utils/terminal-prompt");
11
12
  const abortCtrl = new AbortController();
12
13
  (0, abort_on_signals_1.abortOnSignals)(process, abortCtrl);
13
14
  let settled = false;
@@ -32,6 +33,7 @@ const secrets = (0, redact_1.createSecretRegistry)();
32
33
  stdout: process.stdout,
33
34
  stderr: process.stderr,
34
35
  stdoutColumns: process.stdout.columns,
36
+ prompt: (0, terminal_prompt_1.getTerminalPrompt)(process.env, abortCtrl.signal),
35
37
  }, process.argv, false).then(() => {
36
38
  settled = true;
37
39
  }, () => {
@@ -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,12 +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
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");
10
13
  // Every path is made project-relative (portablePath) so the description, and the
11
14
  // task id hashed from it, is identical wherever the project is checked out.
12
15
  //
@@ -42,9 +45,54 @@ function getWorkTaskCacheDescription(item) {
42
45
  deps: item.deps.length > 0 ? item.deps.map((dep) => dep.id()).sort() : undefined,
43
46
  secrets: cachedSecrets.length > 0 ? cachedSecrets : undefined,
44
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,
45
49
  };
46
50
  }
47
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;
48
96
  // The form written to disk: description.json in a cache entry (pushed to remote
49
97
  // backends, readable by everyone who can pull) and the local explain record.
50
98
  // Env is where tokens get passed, so each value is stored as a digest; the task
@@ -6,6 +6,7 @@ const work_secret_1 = require("./work-secret");
6
6
  const work_service_1 = require("./work-service");
7
7
  const work_network_1 = require("./work-network");
8
8
  const work_cache_id_1 = require("./work-cache-id");
9
+ const portable_path_1 = require("./utils/portable-path");
9
10
  // Everything a running container of the service was created from, plus the
10
11
  // definitions of the services it needs: a dependent links to the container it
11
12
  // started with, so a recreated need means a recreated dependent too. Stored as
@@ -14,19 +15,23 @@ const work_cache_id_1 = require("./work-cache-id");
14
15
  function getServiceDefinitionHash(item) {
15
16
  const service = item.data;
16
17
  const network = (0, work_network_1.getEffectiveNetwork)(service);
18
+ const portable = (path) => (0, portable_path_1.portablePath)(service.projectRoot, path);
17
19
  const definition = {
18
20
  image: service.image,
19
21
  cmd: service.cmd?.cmd ?? null,
20
22
  shell: service.shell,
21
- workdir: service.workdir,
23
+ // where the checkout is does not count: a service run that moves to another commit runs
24
+ // from another directory beside the first, and keeps what did not change
25
+ workdir: service.workdir ? portable(service.workdir) : null,
22
26
  envs: {
23
27
  variables: service.envs.variables,
24
28
  replacements: service.envs.replacements.map((r) => [r.key, r.name, r.value]),
25
29
  },
26
30
  ports: service.ports.map((p) => `${p.hostPort ?? ''}:${p.containerPort}`),
27
- mounts: service.mounts.map((m) => `${m.localPath}:${m.containerPath}:${m.readOnly ? 'ro' : 'rw'}`),
28
- volumes: service.volumes.map((v) => `${v.name}:${v.containerPath}:${v.readOnly ? 'ro' : 'rw'}`),
29
- src: service.src.map((s) => s.absolutePath),
31
+ mounts: service.mounts.map((m) => `${portable(m.localPath)}:${m.containerPath}:${m.readOnly ? 'ro' : 'rw'}`),
32
+ // the volume of a dep's output is named by where it is, which its path says already
33
+ volumes: service.volumes.map((v) => `${v.inherited ? '' : v.name}:${portable(v.containerPath)}:${v.readOnly ? 'ro' : 'rw'}`),
34
+ src: service.src.map((s) => portable(s.absolutePath)),
30
35
  healthcheck: service.healthcheck?.cmd.cmd ?? null,
31
36
  // where secrets go, never their values: a rotated value needs a `down`
32
37
  secrets: service.secrets.map((s) => s.source.type === 'provider'
@@ -17,8 +17,10 @@ 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))) {
22
24
  // the outputs of the dep's own deps too, which stay those of the task that made them
23
25
  // (on Kubernetes, they are in its claim)
24
26
  for (const generate of depNode.data.generates) {
@@ -39,7 +41,7 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
39
41
  });
40
42
  }
41
43
  }
42
- else {
44
+ else if ((0, work_task_1.isWorkTask)(item.data)) {
43
45
  if (!item.data.generates.some((v) => v.volumeName === generate.volumeName)) {
44
46
  item.data.generates.push({
45
47
  volumeName: generate.volumeName,