hammerkit 1.26.0 → 1.27.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/build.schema.json +62 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/backends/registry-cache-backend.js +2 -2
  4. package/dist/cache/explain.js +13 -11
  5. package/dist/cli.js +3 -0
  6. package/dist/docker/item-network.js +471 -0
  7. package/dist/docker/pull.js +4 -2
  8. package/dist/docker/remove-orphaned-containers.js +13 -6
  9. package/dist/environment/replace-env-variables.js +8 -6
  10. package/dist/executer/await-completed-dependencies.js +2 -1
  11. package/dist/executer/check-readiness.js +2 -2
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +24 -2
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +411 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +42 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/claim-layout.js +71 -0
  34. package/dist/kubernetes/ensure-http-route.js +1 -1
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +46 -28
  36. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +132 -5
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +27 -11
  38. package/dist/kubernetes/ensure-persistent-data.js +75 -24
  39. package/dist/kubernetes/get-service-ip.js +16 -2
  40. package/dist/kubernetes/job-placement.js +99 -0
  41. package/dist/kubernetes/job-secrets.js +320 -0
  42. package/dist/kubernetes/kept-claims.js +203 -0
  43. package/dist/kubernetes/kubernetes-instance.js +10 -2
  44. package/dist/kubernetes/network-gate.js +71 -0
  45. package/dist/kubernetes/network-policy.js +583 -0
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/resources.js +37 -4
  48. package/dist/kubernetes/restore-kubernetes-data.js +28 -7
  49. package/dist/kubernetes/secrets.js +7 -9
  50. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  51. package/dist/kubernetes/volumes.js +104 -12
  52. package/dist/log.js +1 -1
  53. package/dist/main.js +41 -0
  54. package/dist/network/egress-proxy.js +407 -0
  55. package/dist/network/port-relay.js +35 -0
  56. package/dist/optimizer/get-work-cache-stats.js +16 -0
  57. package/dist/optimizer/work-task-cache-description.js +5 -2
  58. package/dist/planner/service-definition.js +5 -0
  59. package/dist/planner/utils/append-work-dependencies.js +5 -2
  60. package/dist/planner/utils/append-work-environment.js +2 -0
  61. package/dist/planner/utils/append-work-service.js +5 -0
  62. package/dist/planner/utils/append-work-task.js +20 -1
  63. package/dist/planner/utils/assert-item-paths.js +50 -0
  64. package/dist/planner/utils/hosted-volumes.js +35 -0
  65. package/dist/planner/utils/task-arguments.js +3 -0
  66. package/dist/planner/work-network.js +193 -0
  67. package/dist/planner/work-runtime-docker.js +43 -1
  68. package/dist/planner/work-runtime-kubernetes.js +370 -111
  69. package/dist/planner/work-runtime-local.js +2 -1
  70. package/dist/program.js +33 -5
  71. package/dist/remote/annotations.js +115 -0
  72. package/dist/remote/bundle-text.js +24 -0
  73. package/dist/remote/bundle.js +9 -1
  74. package/dist/remote/remote-commands.js +15 -3
  75. package/dist/remote/remote-work-commands.js +272 -71
  76. package/dist/remote/server-client.js +18 -5
  77. package/dist/remote/source-manifest.js +15 -2
  78. package/dist/remote/token-source.js +67 -0
  79. package/dist/schema/build-file-base-task-schema.js +7 -0
  80. package/dist/schema/build-file-container-service-schema.js +8 -0
  81. package/dist/schema/build-file-container-task-schema.js +3 -0
  82. package/dist/schema/build-file-environment-schema.js +9 -0
  83. package/dist/schema/network-schema.js +78 -0
  84. package/dist/schema/reference-parser.js +2 -0
  85. package/dist/schema/schema-parser.js +16 -3
  86. package/dist/schema/work-scope-parser.js +5 -0
  87. package/dist/secrets/command-secret-provider.js +73 -10
  88. package/dist/secrets/github-app-token-command.js +26 -0
  89. package/dist/secrets/github-app-token.js +191 -0
  90. package/dist/server/accounts-api.js +9 -2
  91. package/dist/server/definitions-api.js +69 -47
  92. package/dist/server/definitions.js +76 -51
  93. package/dist/server/executor.js +156 -41
  94. package/dist/server/git-checkout.js +72 -8
  95. package/dist/server/kubernetes-backend.js +603 -0
  96. package/dist/server/kubernetes-sandbox-storage.js +997 -0
  97. package/dist/server/oauth.js +12 -0
  98. package/dist/server/plan.js +88 -2
  99. package/dist/server/policy.js +104 -15
  100. package/dist/server/proposals-api.js +24 -40
  101. package/dist/server/proposals.js +12 -25
  102. package/dist/server/repo-access.js +37 -6
  103. package/dist/server/run-logs.js +217 -0
  104. package/dist/server/run-plan.js +48 -2
  105. package/dist/server/run-request.js +29 -10
  106. package/dist/server/run-store.js +5 -0
  107. package/dist/server/run-worker-command.js +13 -1
  108. package/dist/server/run-worker.js +121 -24
  109. package/dist/server/runs.js +191 -10
  110. package/dist/server/sandbox-docker.js +62 -0
  111. package/dist/server/sandbox-files-api.js +269 -0
  112. package/dist/server/sandbox-files.js +1054 -0
  113. package/dist/server/sandbox-patch.js +481 -0
  114. package/dist/server/sandbox-pod-command.js +15 -0
  115. package/dist/server/sandbox-pod.js +146 -0
  116. package/dist/server/sandbox-storage.js +462 -0
  117. package/dist/server/sandboxes-api.js +216 -0
  118. package/dist/server/sandboxes.js +519 -0
  119. package/dist/server/server-command.js +39 -20
  120. package/dist/server/server-config.js +443 -22
  121. package/dist/server/server.js +219 -40
  122. package/dist/utils/abort-on-signals.js +46 -6
  123. package/dist/utils/run-id.js +12 -0
  124. package/dist/utils/tar.js +25 -0
  125. package/harness/opencode.ts +11 -0
  126. package/harness/pi.ts +7 -0
  127. package/package.json +8 -2
  128. package/dist/remote/text-diff.js +0 -89
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.evictKeptClaims = exports.sweepKeptClaims = exports.expireKeptClaims = exports.recordClaimState = exports.forgetClaimState = exports.useClaimState = exports.isKeptClaim = exports.getClaimName = exports.EXPIRES_ANNOTATION = exports.STATE_ANNOTATION = exports.KEPT_BY_LABEL = exports.VOLUME_SCOPE_LABEL = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const apply_1 = require("./apply");
6
+ const sleep_1 = require("../utils/sleep");
7
+ const job_placement_1 = require("./job-placement");
8
+ const resources_1 = require("./resources");
9
+ // On a server with `backend.keepVolumes`, the claim of a task stays after the run, so the
10
+ // next run of the same source finds the outputs in it and mounts them without restoring
11
+ // them from the cache. A kept claim has no run label (the end of the run removes what has
12
+ // one), but these:
13
+ // the trust of the source of the runs that may mount it (see getVolumeScope)
14
+ exports.VOLUME_SCOPE_LABEL = 'hammerkit.dev/volume-scope';
15
+ // the run label of the run that mounted it last; while it runs, the claim does not expire
16
+ exports.KEPT_BY_LABEL = 'hammerkit.dev/kept-by';
17
+ // the state key of the outputs in it, set once the task completed or they were restored
18
+ exports.STATE_ANNOTATION = 'hammerkit.dev/state';
19
+ // when it goes, set by the end of the run that mounted it last
20
+ exports.EXPIRES_ANNOTATION = 'hammerkit.dev/expires';
21
+ // The claim of a work item: a task's on a server that keeps them carries the scope, so a
22
+ // run of another source never mounts it, and the runs of one scope go one at a time
23
+ // (KubernetesExecutor.exclusive). Any other claim on a server is the run's own, as the
24
+ // runs of a caller share their namespace. 63 characters at most, a pod's volume name.
25
+ function getClaimName(work, kubernetes) {
26
+ const scope = kubernetes.server?.volumeScope;
27
+ if (scope && isTask(work)) {
28
+ return `hammerkit-${work.id()}-${(0, crypto_1.createHash)('sha256').update(scope).digest('hex').slice(0, 12)}`;
29
+ }
30
+ return 'hammerkit-' + (0, resources_1.getItemKey)(work, kubernetes);
31
+ }
32
+ exports.getClaimName = getClaimName;
33
+ // whether the claim of the work item stays after the run
34
+ function isKeptClaim(work, kubernetes) {
35
+ return !!kubernetes.server?.volumeScope && isTask(work);
36
+ }
37
+ exports.isKeptClaim = isKeptClaim;
38
+ function isTask(work) {
39
+ return work.data.type === 'container-task';
40
+ }
41
+ async function readClaim(instance, namespace, name) {
42
+ try {
43
+ return await instance.coreApi.readNamespacedPersistentVolumeClaim({ name, namespace });
44
+ }
45
+ catch (e) {
46
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
47
+ return null;
48
+ }
49
+ throw e;
50
+ }
51
+ }
52
+ // The state key of the outputs a kept claim holds, null when it holds none that are whole.
53
+ // The claim is the run's from now: it does not expire or make way for another claim
54
+ // before the run ended, so outputs the run found in it are still there when it mounts them.
55
+ async function useClaimState(instance, kubernetes, name) {
56
+ let claim;
57
+ try {
58
+ claim = await instance.objectApi.patch({
59
+ apiVersion: 'v1',
60
+ kind: 'PersistentVolumeClaim',
61
+ metadata: {
62
+ name,
63
+ namespace: kubernetes.namespace,
64
+ labels: { [exports.KEPT_BY_LABEL]: (0, job_placement_1.getRunLabelValue)(kubernetes.server?.run ?? '') },
65
+ // null removes it
66
+ annotations: { [exports.EXPIRES_ANNOTATION]: null },
67
+ },
68
+ });
69
+ }
70
+ catch (e) {
71
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
72
+ return null;
73
+ }
74
+ throw e;
75
+ }
76
+ // one being removed holds nothing to mount, nor one of another source (ensured anyway
77
+ // by the hash of the source in its name)
78
+ if (claim.metadata?.deletionTimestamp ||
79
+ claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] !== kubernetes.server?.volumeScope) {
80
+ return null;
81
+ }
82
+ return claim.metadata?.annotations?.[exports.STATE_ANNOTATION] ?? null;
83
+ }
84
+ exports.useClaimState = useClaimState;
85
+ async function annotate(instance, namespace, name, annotations) {
86
+ try {
87
+ await instance.objectApi.patch({
88
+ apiVersion: 'v1',
89
+ kind: 'PersistentVolumeClaim',
90
+ metadata: { name, namespace, annotations: annotations },
91
+ });
92
+ return true;
93
+ }
94
+ catch (e) {
95
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
96
+ return false;
97
+ }
98
+ throw e;
99
+ }
100
+ }
101
+ // Before a task writes to its kept claim: the outputs in it stop being those of a state.
102
+ // Whether the claim was there, with what an earlier run left in it.
103
+ function forgetClaimState(instance, kubernetes, name) {
104
+ return annotate(instance, kubernetes.namespace, name, { [exports.STATE_ANNOTATION]: null });
105
+ }
106
+ exports.forgetClaimState = forgetClaimState;
107
+ // Once the outputs of `stateKey` are whole in the kept claim
108
+ async function recordClaimState(instance, kubernetes, name, stateKey) {
109
+ await annotate(instance, kubernetes.namespace, name, { [exports.STATE_ANNOTATION]: stateKey });
110
+ }
111
+ exports.recordClaimState = recordClaimState;
112
+ // The kept claims in a namespace
113
+ async function listKeptClaims(instance, namespace, labelSelector = exports.VOLUME_SCOPE_LABEL) {
114
+ const claims = await instance.coreApi.listNamespacedPersistentVolumeClaim({ namespace, labelSelector });
115
+ return claims.items.filter((claim) => !claim.metadata?.deletionTimestamp);
116
+ }
117
+ async function removeClaims(instance, namespace, claims, timeout) {
118
+ const names = [];
119
+ for (const claim of claims) {
120
+ const name = claim.metadata?.name;
121
+ if (!name) {
122
+ continue;
123
+ }
124
+ try {
125
+ // only as it was listed: one a run took for itself since (useClaimState) stays
126
+ await instance.coreApi.deleteNamespacedPersistentVolumeClaim({
127
+ name,
128
+ namespace,
129
+ body: { preconditions: { resourceVersion: claim.metadata?.resourceVersion } },
130
+ });
131
+ names.push(name);
132
+ }
133
+ catch (e) {
134
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
135
+ names.push(name);
136
+ }
137
+ else if ((0, apply_1.statusCodeOf)(e) !== 409) {
138
+ throw e;
139
+ }
140
+ }
141
+ }
142
+ // a run mounts a claim of the same name again
143
+ const deadline = Date.now() + timeout;
144
+ for (const name of names) {
145
+ while (await readClaim(instance, namespace, name)) {
146
+ if (Date.now() > deadline) {
147
+ throw new Error(`the claim ${name} is still there after ${Math.round(timeout / 1000)}s`);
148
+ }
149
+ await (0, sleep_1.sleep)(1000);
150
+ }
151
+ }
152
+ return names;
153
+ }
154
+ // At the end of a run: the claims it kept go `keep` milliseconds from now, unless a run
155
+ // mounts them before
156
+ async function expireKeptClaims(instance, namespace, runLabel, expires) {
157
+ for (const claim of await listKeptClaims(instance, namespace, `${exports.KEPT_BY_LABEL}=${runLabel}`)) {
158
+ if (claim.metadata?.name) {
159
+ await annotate(instance, namespace, claim.metadata.name, { [exports.EXPIRES_ANNOTATION]: expires.toISOString() });
160
+ }
161
+ }
162
+ }
163
+ exports.expireKeptClaims = expireKeptClaims;
164
+ // The kept claims that expired, and those a run left without an expiry because the server
165
+ // stopped before its end (all of them when the server keeps none any more), but those of
166
+ // the scopes `inUse`: a run of the scope executes and mounts them, or may in a moment.
167
+ // The names of those removed, and when the next of the others expires.
168
+ async function sweepKeptClaims(instance, namespace, options) {
169
+ const expired = [];
170
+ let next = null;
171
+ for (const claim of await listKeptClaims(instance, namespace)) {
172
+ if (options.inUse?.has(claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] ?? '')) {
173
+ continue;
174
+ }
175
+ const expires = Date.parse(claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION] ?? '');
176
+ if (!options.keep || !(expires > options.now.getTime())) {
177
+ expired.push(claim);
178
+ }
179
+ else if (next === null || expires < next) {
180
+ next = expires;
181
+ }
182
+ }
183
+ const removed = await removeClaims(instance, namespace, expired, options.timeout);
184
+ return { removed, next: next === null ? null : new Date(next) };
185
+ }
186
+ exports.sweepKeptClaims = sweepKeptClaims;
187
+ // When a run needs room under the quota of its namespace: the kept claims it does not use
188
+ // go, those of the other sources and those of its own a task of this run did not take (one
189
+ // renamed or changed since). Of another scope, only one its last run released, which has an
190
+ // expiry: one without is mounted by a run of the caller that executes now (or was left by
191
+ // a server that stopped, which the next sweep removes). The names of those removed.
192
+ async function evictKeptClaims(instance, kubernetes, timeout) {
193
+ const server = kubernetes.server;
194
+ if (!server?.volumeScope) {
195
+ return [];
196
+ }
197
+ const unused = await listKeptClaims(instance, kubernetes.namespace, `${exports.VOLUME_SCOPE_LABEL},${exports.KEPT_BY_LABEL}!=${(0, job_placement_1.getRunLabelValue)(server.run)}`);
198
+ const released = unused.filter((claim) => claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] === server.volumeScope ||
199
+ !!claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION]);
200
+ return removeClaims(instance, kubernetes.namespace, released, timeout);
201
+ }
202
+ exports.evictKeptClaims = evictKeptClaims;
203
+ //# sourceMappingURL=kept-claims.js.map
@@ -12,23 +12,31 @@ function createKubernetesInstances(kubernetes) {
12
12
  else {
13
13
  kc.loadFromDefault();
14
14
  }
15
- kc.setCurrentContext(kubernetes.context);
15
+ if (kubernetes.context) {
16
+ kc.setCurrentContext(kubernetes.context);
17
+ }
16
18
  const coreApi = kc.makeApiClient(client_node_1.CoreV1Api);
17
19
  const batchApi = kc.makeApiClient(client_node_1.BatchV1Api);
18
20
  const appsApi = kc.makeApiClient(client_node_1.AppsV1Api);
19
21
  const objectApi = kc.makeApiClient(client_node_1.KubernetesObjectApi);
22
+ const authApi = kc.makeApiClient(client_node_1.AuthenticationV1Api);
20
23
  const exec = new client_node_1.Exec(kc);
21
24
  return {
22
25
  objectApi,
26
+ log: new client_node_1.Log(kc),
23
27
  exec,
24
28
  appsApi,
25
29
  coreApi,
26
30
  batchApi,
31
+ authApi,
27
32
  };
28
33
  }
29
34
  catch (e) {
30
35
  if ((0, log_1.getErrorMessage)(e) === 'No active cluster!') {
31
- if (kubernetes.kubeConfig) {
36
+ if (!kubernetes.context) {
37
+ throw new Error('No cluster found: no kubeconfig names a current context, and this is not a pod of a cluster');
38
+ }
39
+ else if (kubernetes.kubeConfig) {
32
40
  throw new Error(`No cluster found for context ${kubernetes.context} in ${kubernetes.kubeConfig}`);
33
41
  }
34
42
  else {
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getNetworkGateContainer = exports.HELPER_SECURITY_CONTEXT = exports.NETWORK_GATE_SCRIPT = exports.NETWORK_GATE_CONTAINER = exports.NETWORK_GATE_TIMEOUT_MS = void 0;
4
+ const egress_proxy_1 = require("../network/egress-proxy");
5
+ // Some network plugins (kind's kindnet among them) enforce a NetworkPolicy a
6
+ // moment after a pod started, so a limited pod's first connections could
7
+ // still get out. The gate is the pod's first init container: it waits until
8
+ // the pod no longer reaches the cluster DNS, which the policy of every limited
9
+ // item denies, so the item's own containers start only once the policy holds.
10
+ // A policy that never takes hold fails the pod instead of running it open.
11
+ // A script for `node -e` in the proxy's image, like the proxy.
12
+ exports.NETWORK_GATE_TIMEOUT_MS = 60000;
13
+ // the name of the gate's init container
14
+ exports.NETWORK_GATE_CONTAINER = 'hammerkit-network';
15
+ // Kept free of anything but Node's own modules.
16
+ exports.NETWORK_GATE_SCRIPT = `
17
+ const fs = require('fs')
18
+ const net = require('net')
19
+ const resolvConf = process.env.HAMMERKIT_GATE_RESOLV_CONF || '/etc/resolv.conf'
20
+ const port = parseInt(process.env.HAMMERKIT_GATE_PORT || '53', 10)
21
+ const deadline = Date.now() + parseInt(process.env.HAMMERKIT_GATE_TIMEOUT_MS || '${exports.NETWORK_GATE_TIMEOUT_MS}', 10)
22
+ function fail(message) {
23
+ process.stderr.write('hammerkit: ' + message + '\\n')
24
+ try {
25
+ fs.writeFileSync('/dev/termination-log', 'hammerkit: ' + message)
26
+ } catch (e) {}
27
+ process.exit(1)
28
+ }
29
+ let conf = ''
30
+ try {
31
+ conf = fs.readFileSync(resolvConf, 'utf8')
32
+ } catch (e) {}
33
+ const match = /^nameserver\\s+(\\S+)/m.exec(conf)
34
+ if (!match) {
35
+ fail('the pod has no cluster DNS, so its network policy can not be checked')
36
+ }
37
+ const nameserver = match[1]
38
+ function attempt() {
39
+ const socket = net.connect({ host: nameserver, port, timeout: 2000 })
40
+ socket.once('connect', () => {
41
+ socket.destroy()
42
+ if (Date.now() > deadline) {
43
+ fail('the network policy of this pod is not enforced, it still reaches ' + nameserver)
44
+ }
45
+ setTimeout(attempt, 500)
46
+ })
47
+ // dropped or refused: the policy holds
48
+ socket.once('timeout', () => process.exit(0))
49
+ socket.once('error', () => process.exit(0))
50
+ }
51
+ attempt()
52
+ `;
53
+ exports.HELPER_SECURITY_CONTEXT = {
54
+ runAsNonRoot: true,
55
+ // the image's `node` user
56
+ runAsUser: 1000,
57
+ allowPrivilegeEscalation: false,
58
+ readOnlyRootFilesystem: true,
59
+ capabilities: { drop: ['ALL'] },
60
+ };
61
+ function getNetworkGateContainer() {
62
+ return {
63
+ name: exports.NETWORK_GATE_CONTAINER,
64
+ image: egress_proxy_1.EGRESS_PROXY_IMAGE,
65
+ command: ['node', '-e', exports.NETWORK_GATE_SCRIPT],
66
+ securityContext: exports.HELPER_SECURITY_CONTEXT,
67
+ resources: { limits: { memory: '64Mi' } },
68
+ };
69
+ }
70
+ exports.getNetworkGateContainer = getNetworkGateContainer;
71
+ //# sourceMappingURL=network-gate.js.map