hammerkit 1.26.0 → 1.27.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/docker-service.js +24 -13
  12. package/dist/executer/docker-task.js +18 -29
  13. package/dist/executer/event-cache.js +4 -0
  14. package/dist/executer/execute-command.js +18 -5
  15. package/dist/executer/execute-docker.js +4 -1
  16. package/dist/executer/execution-steps.js +33 -7
  17. package/dist/executer/get-docker-executor.js +14 -7
  18. package/dist/executer/scheduler/enqueue-next.js +17 -3
  19. package/dist/executer/watch-loop.js +2 -2
  20. package/dist/executer/watch-state-key.js +1 -1
  21. package/dist/file/within-root.js +42 -0
  22. package/dist/harness/connect.js +99 -0
  23. package/dist/harness/format.js +73 -0
  24. package/dist/harness/mcp-command.js +59 -0
  25. package/dist/harness/mcp.js +432 -0
  26. package/dist/harness/opencode.js +492 -0
  27. package/dist/harness/pi.js +217 -0
  28. package/dist/harness/sandbox-client.js +373 -0
  29. package/dist/index.js +33 -36
  30. package/dist/kubernetes/apply.js +31 -1
  31. package/dist/kubernetes/await-running-state.js +33 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  35. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  36. package/dist/kubernetes/get-service-ip.js +15 -1
  37. package/dist/kubernetes/job-placement.js +99 -0
  38. package/dist/kubernetes/job-secrets.js +306 -0
  39. package/dist/kubernetes/kept-claims.js +191 -0
  40. package/dist/kubernetes/kubernetes-instance.js +10 -2
  41. package/dist/kubernetes/network-gate.js +71 -0
  42. package/dist/kubernetes/network-policy.js +526 -0
  43. package/dist/kubernetes/resources.js +13 -2
  44. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  45. package/dist/kubernetes/secrets.js +2 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  47. package/dist/kubernetes/volumes.js +100 -11
  48. package/dist/main.js +41 -0
  49. package/dist/network/egress-proxy.js +368 -0
  50. package/dist/network/port-relay.js +35 -0
  51. package/dist/optimizer/get-work-cache-stats.js +16 -0
  52. package/dist/optimizer/work-task-cache-description.js +3 -2
  53. package/dist/planner/service-definition.js +5 -0
  54. package/dist/planner/utils/append-work-environment.js +2 -0
  55. package/dist/planner/utils/append-work-service.js +4 -0
  56. package/dist/planner/utils/append-work-task.js +17 -1
  57. package/dist/planner/utils/assert-item-paths.js +50 -0
  58. package/dist/planner/utils/hosted-volumes.js +35 -0
  59. package/dist/planner/work-network.js +193 -0
  60. package/dist/planner/work-runtime-docker.js +41 -0
  61. package/dist/planner/work-runtime-kubernetes.js +335 -102
  62. package/dist/program.js +21 -0
  63. package/dist/remote/annotations.js +103 -0
  64. package/dist/remote/bundle-text.js +24 -0
  65. package/dist/remote/bundle.js +9 -1
  66. package/dist/remote/remote-commands.js +4 -1
  67. package/dist/remote/remote-work-commands.js +182 -69
  68. package/dist/remote/server-client.js +18 -5
  69. package/dist/remote/source-manifest.js +15 -2
  70. package/dist/remote/token-source.js +67 -0
  71. package/dist/schema/build-file-base-task-schema.js +3 -0
  72. package/dist/schema/build-file-container-service-schema.js +6 -0
  73. package/dist/schema/build-file-container-task-schema.js +3 -0
  74. package/dist/schema/build-file-environment-schema.js +9 -0
  75. package/dist/schema/network-schema.js +78 -0
  76. package/dist/schema/reference-parser.js +1 -0
  77. package/dist/schema/schema-parser.js +16 -3
  78. package/dist/schema/work-scope-parser.js +5 -0
  79. package/dist/secrets/command-secret-provider.js +73 -10
  80. package/dist/secrets/github-app-token-command.js +23 -0
  81. package/dist/secrets/github-app-token.js +191 -0
  82. package/dist/server/accounts-api.js +9 -2
  83. package/dist/server/definitions-api.js +69 -47
  84. package/dist/server/definitions.js +76 -51
  85. package/dist/server/executor.js +156 -41
  86. package/dist/server/git-checkout.js +72 -8
  87. package/dist/server/kubernetes-backend.js +554 -0
  88. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  89. package/dist/server/oauth.js +1 -0
  90. package/dist/server/plan.js +51 -1
  91. package/dist/server/policy.js +104 -15
  92. package/dist/server/proposals-api.js +24 -40
  93. package/dist/server/proposals.js +12 -25
  94. package/dist/server/repo-access.js +37 -6
  95. package/dist/server/run-logs.js +217 -0
  96. package/dist/server/run-plan.js +51 -2
  97. package/dist/server/run-request.js +23 -10
  98. package/dist/server/run-store.js +5 -0
  99. package/dist/server/run-worker-command.js +13 -1
  100. package/dist/server/run-worker.js +121 -24
  101. package/dist/server/runs.js +184 -10
  102. package/dist/server/sandbox-docker.js +62 -0
  103. package/dist/server/sandbox-files-api.js +269 -0
  104. package/dist/server/sandbox-files.js +1054 -0
  105. package/dist/server/sandbox-patch.js +481 -0
  106. package/dist/server/sandbox-pod-command.js +15 -0
  107. package/dist/server/sandbox-pod.js +146 -0
  108. package/dist/server/sandbox-storage.js +462 -0
  109. package/dist/server/sandboxes-api.js +210 -0
  110. package/dist/server/sandboxes.js +519 -0
  111. package/dist/server/server-config.js +412 -22
  112. package/dist/server/server.js +204 -39
  113. package/dist/utils/run-id.js +12 -0
  114. package/harness/opencode.ts +11 -0
  115. package/harness/pi.ts +7 -0
  116. package/package.json +8 -2
  117. package/dist/remote/text-diff.js +0 -89
@@ -3,18 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.kubernetesForwardRuntime = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = void 0;
6
+ exports.kubernetesForwardRuntime = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = exports.describeFailedJob = void 0;
7
7
  const abort_1 = require("../executer/abort");
8
- const get_service_ip_1 = require("../kubernetes/get-service-ip");
9
- const work_item_1 = require("./work-item");
10
8
  const kubernetes_service_1 = require("../executer/kubernetes-service");
11
9
  const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
12
10
  const replace_env_variables_1 = require("../environment/replace-env-variables");
13
11
  const apply_1 = require("../kubernetes/apply");
14
12
  const ensure_kubernetes_service_exists_1 = require("../kubernetes/ensure-kubernetes-service-exists");
15
13
  const ensure_kubernetes_deployment_exists_1 = require("../kubernetes/ensure-kubernetes-deployment-exists");
14
+ const service_definition_1 = require("./service-definition");
16
15
  const ensure_namespace_1 = require("../kubernetes/ensure-namespace");
17
16
  const ensure_persistent_data_1 = require("../kubernetes/ensure-persistent-data");
17
+ const network_gate_1 = require("../kubernetes/network-gate");
18
18
  const await_running_state_1 = require("../kubernetes/await-running-state");
19
19
  const volumes_1 = require("../kubernetes/volumes");
20
20
  const resources_1 = require("../kubernetes/resources");
@@ -26,8 +26,15 @@ const task_arguments_1 = require("./utils/task-arguments");
26
26
  const restore_kubernetes_data_1 = require("../kubernetes/restore-kubernetes-data");
27
27
  const store_kubernetes_data_1 = require("../kubernetes/store-kubernetes-data");
28
28
  const remove_persistent_data_1 = require("../kubernetes/remove-persistent-data");
29
+ const kept_claims_1 = require("../kubernetes/kept-claims");
29
30
  const container_resources_1 = require("../kubernetes/container-resources");
30
31
  const secrets_1 = require("../kubernetes/secrets");
32
+ const job_secrets_1 = require("../kubernetes/job-secrets");
33
+ const job_placement_1 = require("../kubernetes/job-placement");
34
+ const network_policy_1 = require("../kubernetes/network-policy");
35
+ const run_id_1 = require("../utils/run-id");
36
+ const work_network_1 = require("./work-network");
37
+ const work_secret_1 = require("./work-secret");
31
38
  async function listJobNames(instance, kubernetes, task) {
32
39
  const jobs = await instance.batchApi.listNamespacedJob({
33
40
  namespace: kubernetes.namespace,
@@ -35,138 +42,334 @@ async function listJobNames(instance, kubernetes, task) {
35
42
  });
36
43
  return jobs.items.flatMap((job) => (job.metadata?.name ? [job.metadata.name] : []));
37
44
  }
45
+ // The Secret of the values the server hands over and the one of the credentials the
46
+ // pod reads its own secrets with
47
+ async function removeTaskSecrets(instance, kubernetes, task) {
48
+ await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, task);
49
+ await (0, job_secrets_1.removeJobCredentials)(instance, kubernetes, task);
50
+ }
51
+ // The newest pod of a Job
52
+ async function getJobPod(instance, kubernetes, job) {
53
+ const pods = await instance.coreApi.listNamespacedPod({
54
+ namespace: kubernetes.namespace,
55
+ labelSelector: `job-name=${job}`,
56
+ });
57
+ return pods.items
58
+ .slice()
59
+ .sort((a, b) => (a.metadata?.creationTimestamp?.toISOString() ?? '').localeCompare(b.metadata?.creationTimestamp?.toISOString() ?? ''))
60
+ .pop();
61
+ }
62
+ // a task's log is kept to this, the run's own cap is smaller
63
+ const MAX_JOB_LOG_BYTES = 4 * 1024 * 1024;
64
+ // What the command printed, as the task's output: read from the cluster once the Job
65
+ // has ended. Without a pod (it never started) there is nothing to say.
66
+ async function writeJobLogs(instance, kubernetes, task, job, container) {
67
+ try {
68
+ const pod = await getJobPod(instance, kubernetes, job);
69
+ if (!pod?.metadata?.name) {
70
+ return;
71
+ }
72
+ const log = await instance.coreApi.readNamespacedPodLog({
73
+ name: pod.metadata.name,
74
+ namespace: kubernetes.namespace,
75
+ container,
76
+ limitBytes: MAX_JOB_LOG_BYTES,
77
+ });
78
+ for (const line of String(log ?? '').split(/\r?\n/)) {
79
+ if (line.length > 0) {
80
+ task.status.console('stdout', line);
81
+ }
82
+ }
83
+ }
84
+ catch (e) {
85
+ task.status.write('debug', `the log of ${job} could not be read: ${(0, log_1.getErrorMessage)(e)}`);
86
+ }
87
+ }
88
+ // the command of a Job exited with this code, as a crash on docker or a host
89
+ class JobCrash extends Error {
90
+ constructor(exitCode) {
91
+ super(`exit code ${exitCode}`);
92
+ this.exitCode = exitCode;
93
+ }
94
+ }
95
+ // Why a Job failed: the network gate, an init container that could not read a secret, which
96
+ // names the secret, never its output (which is the provider's and can hold the value), or the
97
+ // exit code of the command.
98
+ async function describeFailedJob(instance, kubernetes, task, job, container, secrets) {
99
+ const pod = await getJobPod(instance, kubernetes, job).catch(() => undefined);
100
+ for (const status of pod?.status?.initContainerStatuses ?? []) {
101
+ const terminated = status.state?.terminated ?? status.lastState?.terminated;
102
+ if (!terminated || terminated.exitCode === 0) {
103
+ continue;
104
+ }
105
+ if (status.name === network_gate_1.NETWORK_GATE_CONTAINER) {
106
+ // the gate says why in its termination message
107
+ return {
108
+ type: 'error',
109
+ message: `${task}: ${terminated.message?.trim() || 'the network policy did not take hold'}`,
110
+ };
111
+ }
112
+ const secret = secrets.find((s) => `secret-${s.index}` === status.name);
113
+ if (secret) {
114
+ return {
115
+ type: 'error',
116
+ message: `secret ${(0, work_secret_1.getSecretName)(secret.secret)} (${(0, work_secret_1.describeSecretSource)(secret.secret)}) could not be read: ${terminated.reason ?? 'exit code'} ${terminated.exitCode}`,
117
+ };
118
+ }
119
+ }
120
+ const command = pod?.status?.containerStatuses?.find((status) => status.name === container);
121
+ const terminated = command?.state?.terminated ?? command?.lastState?.terminated;
122
+ if (terminated && terminated.exitCode !== 0) {
123
+ return { type: 'crash', exitCode: terminated.exitCode };
124
+ }
125
+ return undefined;
126
+ }
127
+ exports.describeFailedJob = describeFailedJob;
38
128
  function kubernetesTaskRuntime(task, kubernetes) {
39
129
  const instance = (0, kubernetes_instance_1.createKubernetesInstances)(kubernetes);
130
+ // on a server that keeps them, the task's claim stays after the run with the state of its
131
+ // outputs, and the next run of the same source mounts them as they are (see kept-claims)
132
+ const kept = (0, kept_claims_1.isKeptClaim)(task, kubernetes);
133
+ const claim = (0, volumes_1.getVolumeName)(task, kubernetes);
40
134
  return {
41
135
  initialize() {
42
136
  return Promise.resolve();
43
137
  },
44
- async restore(environment, path) {
45
- await (0, restore_kubernetes_data_1.restoreKubernetesData)(task, kubernetes, instance, environment, path);
138
+ async restore(environment, path, stateKey) {
139
+ if (!kept) {
140
+ await (0, restore_kubernetes_data_1.restoreKubernetesData)(task, kubernetes, instance, environment, path);
141
+ return;
142
+ }
143
+ const reset = await (0, kept_claims_1.forgetClaimState)(instance, kubernetes, claim);
144
+ await (0, restore_kubernetes_data_1.restoreKubernetesData)(task, kubernetes, instance, environment, path, { reset });
145
+ if (stateKey) {
146
+ await (0, kept_claims_1.recordClaimState)(instance, kubernetes, claim, stateKey);
147
+ }
46
148
  },
47
149
  async archive(environment, path) {
48
150
  await (0, store_kubernetes_data_1.storeKubernetesData)(task, kubernetes, instance, environment, path);
49
151
  },
50
152
  async execute(environment, options) {
51
153
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(task.data.envs);
52
- const persistence = await (0, volumes_1.getKubernetesPersistence)(task);
154
+ const persistence = await (0, volumes_1.getKubernetesPersistence)(task, kubernetes);
155
+ const workspace = (0, volumes_1.getWorkspacePodSpec)(kubernetes, persistence, { name: task.name, pool: task.data.pool });
53
156
  await (0, ensure_namespace_1.ensureNamespace)(instance, kubernetes.namespace);
54
- await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, task, persistence);
55
- const secrets = (0, secrets_1.getKubernetesSecretRefs)(task, task.data.secrets);
56
157
  try {
57
- await (0, secrets_1.ensureKubernetesSecret)(instance, kubernetes, task, task.data.secrets, environment);
158
+ // the task writes to it now: what it holds stops being the outputs of a state, and
159
+ // goes, as a new claim would hold nothing
160
+ const reset = kept && (await (0, kept_claims_1.forgetClaimState)(instance, kubernetes, claim));
161
+ await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, task, persistence, { reset });
162
+ }
163
+ catch (e) {
164
+ options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
165
+ return;
166
+ }
167
+ const server = kubernetes.server;
168
+ const { delivered, inJob } = (0, job_secrets_1.splitJobSecrets)(task.data.secrets, server);
169
+ const secrets = (0, secrets_1.getKubernetesSecretRefs)(task, delivered);
170
+ const admitted = environment.hosted?.accounts;
171
+ try {
172
+ (0, job_secrets_1.assertSecretsReachable)(task, inJob);
173
+ if (server) {
174
+ (0, job_secrets_1.assertJobAccounts)(inJob, server, admitted);
175
+ }
176
+ await (0, secrets_1.ensureKubernetesSecret)(instance, kubernetes, task, delivered, environment);
58
177
  }
59
178
  catch (e) {
179
+ await removeTaskSecrets(instance, kubernetes, task).catch(() => undefined);
60
180
  options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
61
181
  return;
62
182
  }
183
+ // the run its pods and limits belong to: on a server, the server's run; on a
184
+ // sandbox, its proxy stays with the sandbox for the next runs
185
+ const scope = {
186
+ run: server ? (0, job_placement_1.getRunLabelValue)(server.run) : (0, run_id_1.getRunId)(),
187
+ ...(server?.workspace
188
+ ? {
189
+ sandbox: {
190
+ label: server.workspace.label,
191
+ ...(server.workspace.pod ? { pod: server.workspace.pod } : {}),
192
+ },
193
+ }
194
+ : {}),
195
+ };
196
+ const labels = {
197
+ 'hammerkit.dev/id': task.id(),
198
+ 'hammerkit.dev/state': options.stateKey,
199
+ ...workspace.labels,
200
+ [network_policy_1.RUN_LABEL]: scope.run,
201
+ ...(0, network_policy_1.getNetworkLabels)(kubernetes, task),
202
+ };
203
+ const placement = server ? (0, job_placement_1.getPodPlacement)(server, task.data.pool) : null;
63
204
  // The last job of a run stays: its state label is what currentStateKey
64
205
  // reads. Jobs from an earlier run of this task go before this one starts.
65
206
  for (const name of await listJobNames(instance, kubernetes, task)) {
66
207
  await (0, await_running_state_1.deleteJobAndWait)(instance, kubernetes, name, options.abort);
67
208
  }
68
- // the services the task needs, by the names it lists them under
69
- const hostAliases = [];
70
- for (const need of task.needs) {
71
- if (!(0, work_item_1.isContainerWorkServiceItem)(need.service)) {
72
- continue;
73
- }
74
- const ip = await (0, get_service_ip_1.getServiceIp)(instance, kubernetes, need.service);
75
- if (!ip) {
76
- throw new Error(`unable to get service ip for ${need.name}`);
77
- }
78
- hostAliases.push({ ip, hostnames: [need.name] });
209
+ // the services the task needs by the names it lists them under, and the
210
+ // NetworkPolicy and proxy of a limited network, both of this run
211
+ let network;
212
+ try {
213
+ network = await (0, network_policy_1.ensureKubernetesItemNetwork)(instance, kubernetes, task, { followLog: true, ...scope });
79
214
  }
80
- const podName = `${task.name}-${options.stateKey}`;
81
- let i = 0;
82
- for (const cmd of task.data.cmds) {
83
- const spec = {
84
- kind: 'Job',
85
- apiVersion: 'batch/v1',
86
- metadata: {
87
- namespace: kubernetes.namespace,
88
- name: podName,
89
- annotations: {
90
- 'hammerkit.dev/version': (0, version_1.getVersion)(),
91
- },
92
- labels: {
93
- 'hammerkit.dev/id': task.id(),
94
- 'hammerkit.dev/state': options.stateKey,
215
+ catch (e) {
216
+ await removeTaskSecrets(instance, kubernetes, task).catch(() => undefined);
217
+ options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
218
+ return;
219
+ }
220
+ try {
221
+ const podName = (0, resources_1.getResourceName)(task, '-job');
222
+ let i = 0;
223
+ for (const cmd of task.data.cmds) {
224
+ const container = `cmd-${++i}`;
225
+ let secretSpec;
226
+ try {
227
+ // made for each Job right before it, so a later command's token is as fresh
228
+ const credentials = await (0, job_secrets_1.ensureJobCredentials)(instance, kubernetes, task, inJob, admitted);
229
+ secretSpec = (0, job_secrets_1.getJobSecretSpec)(task, inJob, server, credentials, environment);
230
+ }
231
+ catch (e) {
232
+ await removeTaskSecrets(instance, kubernetes, task).catch(() => undefined);
233
+ options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
234
+ return;
235
+ }
236
+ // the arguments after `--` keep their `$` as text, Kubernetes expands `$(NAME)`
237
+ const command = secretSpec.command(task.data.shell, (0, task_arguments_1.getKubernetesCommandText)(cmd));
238
+ const spec = {
239
+ kind: 'Job',
240
+ apiVersion: 'batch/v1',
241
+ metadata: {
242
+ namespace: kubernetes.namespace,
243
+ name: podName,
244
+ annotations: {
245
+ 'hammerkit.dev/version': (0, version_1.getVersion)(),
246
+ },
247
+ labels,
95
248
  },
96
- },
97
- spec: {
98
- template: {
99
- metadata: {
100
- annotations: {
101
- 'hammerkit.dev/version': (0, version_1.getVersion)(),
249
+ spec: {
250
+ template: {
251
+ metadata: {
252
+ annotations: {
253
+ 'hammerkit.dev/version': (0, version_1.getVersion)(),
254
+ },
255
+ labels,
102
256
  },
103
- labels: {
104
- 'hammerkit.dev/id': task.id(),
105
- 'hammerkit.dev/state': options.stateKey,
257
+ spec: {
258
+ // the network gate first: nothing runs before the item's policy holds
259
+ ...(network.initContainers.length + secretSpec.initContainers.length > 0
260
+ ? { initContainers: [...network.initContainers, ...secretSpec.initContainers] }
261
+ : {}),
262
+ containers: [
263
+ {
264
+ image: task.data.image,
265
+ // through the task's shell, as on Docker
266
+ command: command.command,
267
+ args: command.args,
268
+ workingDir: cmd.cwd,
269
+ env: [
270
+ ...Object.entries(envs).map(([key, value]) => ({ name: key, value })),
271
+ ...secrets.env,
272
+ ...network.env,
273
+ ...command.env,
274
+ ],
275
+ name: container,
276
+ volumeMounts: [
277
+ ...persistence.mounts.map((m) => m.mount),
278
+ ...secrets.mounts,
279
+ ...secretSpec.mounts,
280
+ ],
281
+ ...(0, container_resources_1.getKubernetesResources)(task.data.resources),
282
+ ...(0, job_placement_1.getContainerSecurity)(kubernetes),
283
+ },
284
+ ],
285
+ volumes: [
286
+ ...persistence.volumes,
287
+ ...persistence.workspace,
288
+ ...secrets.volumes,
289
+ ...secretSpec.volumes,
290
+ ],
291
+ ...(workspace.affinity ? { affinity: workspace.affinity } : {}),
292
+ hostAliases: network.hostAliases,
293
+ restartPolicy: 'Never',
294
+ // on a server the pod is the build's: no token of the namespace, and the
295
+ // nodes of its pool
296
+ ...(placement
297
+ ? {
298
+ automountServiceAccountToken: false,
299
+ nodeSelector: placement.nodeSelector,
300
+ tolerations: placement.tolerations,
301
+ }
302
+ : {}),
106
303
  },
107
304
  },
108
- spec: {
109
- containers: [
110
- {
111
- image: task.data.image,
112
- // through the task's shell, as on Docker
113
- command: [task.data.shell, '-c'],
114
- args: [(0, task_arguments_1.getKubernetesCommandText)(cmd)],
115
- workingDir: cmd.cwd,
116
- env: [
117
- ...Object.entries(envs).map(([key, value]) => ({ name: key, value })),
118
- ...secrets.env,
119
- ],
120
- name: `cmd-${++i}`,
121
- volumeMounts: [...persistence.mounts.map((m) => m.mount), ...secrets.mounts],
122
- ...(0, container_resources_1.getKubernetesResources)(task.data.resources),
123
- },
124
- ],
125
- volumes: [...persistence.volumes, ...secrets.volumes],
126
- hostAliases,
127
- restartPolicy: 'Never',
128
- },
305
+ backoffLimit: server ? 0 : 1,
306
+ parallelism: 1,
129
307
  },
130
- backoffLimit: 1,
131
- parallelism: 1,
132
- },
133
- };
134
- try {
135
- const pod = await (0, apply_1.apply)(instance, spec);
136
- if (!pod.status?.succeeded) {
137
- await (0, await_running_state_1.awaitJobCompletion)(instance, kubernetes, spec.metadata.name, options.abort);
308
+ };
309
+ try {
310
+ const pod = await (0, apply_1.apply)(instance, spec);
311
+ if (!pod.status?.succeeded) {
312
+ try {
313
+ await (0, await_running_state_1.awaitJobCompletion)(instance, kubernetes, spec.metadata.name, options.abort);
314
+ }
315
+ catch (e) {
316
+ if (!(e instanceof abort_1.AbortError)) {
317
+ await writeJobLogs(instance, kubernetes, task, podName, container);
318
+ const failure = await describeFailedJob(instance, kubernetes, task.name, podName, container, inJob);
319
+ if (failure?.type === 'crash') {
320
+ throw new JobCrash(failure.exitCode);
321
+ }
322
+ throw new Error(failure?.message ?? (0, log_1.getErrorMessage)(e));
323
+ }
324
+ throw e;
325
+ }
326
+ }
327
+ await writeJobLogs(instance, kubernetes, task, podName, container);
328
+ task.status.write('debug', 'pod completed');
329
+ // its init containers are done with the credentials
330
+ await (0, job_secrets_1.removeJobCredentials)(instance, kubernetes, task);
331
+ if (i < task.data.cmds.length) {
332
+ await (0, await_running_state_1.deleteJobAndWait)(instance, kubernetes, podName, options.abort);
333
+ }
138
334
  }
139
- task.status.write('debug', 'pod completed');
140
- if (i < task.data.cmds.length) {
141
- await (0, await_running_state_1.deleteJobAndWait)(instance, kubernetes, podName, options.abort);
142
- }
143
- }
144
- catch (e) {
145
- if (e instanceof abort_1.AbortError) {
146
- options.state.set({ stateKey: options.stateKey, type: 'canceled' });
335
+ catch (e) {
336
+ if (e instanceof abort_1.AbortError) {
337
+ // at once, so its policy can go (in finally) once the pod ended
338
+ await (0, await_running_state_1.stopJob)(instance, kubernetes, podName).catch((e) => task.status.write('error', `the job could not be stopped: ${(0, log_1.getErrorMessage)(e)}`));
339
+ await removeTaskSecrets(instance, kubernetes, task).catch(() => undefined);
340
+ options.state.set({ stateKey: options.stateKey, type: 'canceled' });
341
+ return;
342
+ }
343
+ await (0, await_running_state_1.deleteJob)(instance, kubernetes, podName).catch(() => undefined);
344
+ options.state.set(e instanceof JobCrash
345
+ ? { stateKey: options.stateKey, type: 'crash', exitCode: e.exitCode }
346
+ : { stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
347
+ await removeTaskSecrets(instance, kubernetes, task).catch(() => undefined);
147
348
  return;
148
349
  }
149
- await (0, await_running_state_1.deleteJob)(instance, kubernetes, podName).catch(() => undefined);
150
- options.state.set({
151
- stateKey: options.stateKey,
152
- type: 'error',
153
- errorMessage: (0, log_1.getErrorMessage)(e),
154
- });
155
- await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, task).catch(() => undefined);
156
- return;
350
+ }
351
+ // the finished job stays for its state, the values it read need not
352
+ await removeTaskSecrets(instance, kubernetes, task);
353
+ if (kept) {
354
+ await (0, kept_claims_1.recordClaimState)(instance, kubernetes, claim, options.stateKey);
355
+ }
356
+ }
357
+ finally {
358
+ network.close();
359
+ // once the run's pods ended: a cancelled job's pod is still terminating
360
+ if ((0, work_network_1.isLimitedNetwork)((0, work_network_1.getEffectiveNetwork)(task.data))) {
361
+ await (0, network_policy_1.releaseKubernetesItemNetwork)(instance, kubernetes, task, scope).catch((e) => task.status.write('error', `remove of network policy failed ${(0, log_1.getErrorMessage)(e)}`));
157
362
  }
158
363
  }
159
- // the finished job stays for its state, the values it read need not
160
- await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, task);
161
364
  },
162
365
  async stop() {
163
366
  const jobs = await instance.batchApi.listNamespacedJob({
164
367
  namespace: kubernetes.namespace,
165
368
  labelSelector: `hammerkit.dev/id=${task.id()}`,
166
369
  });
167
- for (const pod of jobs.items) {
168
- if (pod.metadata?.name) {
169
- await instance.batchApi.deleteNamespacedJob({ name: pod.metadata.name, namespace: kubernetes.namespace });
370
+ for (const job of jobs.items) {
371
+ if (job.metadata?.name) {
372
+ await (0, await_running_state_1.stopJob)(instance, kubernetes, job.metadata.name);
170
373
  }
171
374
  }
172
375
  const deployments = await instance.appsApi.listNamespacedDeployment({
@@ -190,13 +393,19 @@ function kubernetesTaskRuntime(task, kubernetes) {
190
393
  await instance.coreApi.deleteNamespacedPod({ name: pod.metadata.name, namespace: kubernetes.namespace });
191
394
  }
192
395
  }
396
+ // the limits go last, once no pod of the task runs
397
+ await (0, network_policy_1.removeAllKubernetesItemNetworks)(instance, kubernetes, task);
193
398
  },
194
399
  async remove() {
195
400
  await this.stop();
196
401
  await (0, remove_persistent_data_1.removePersistentData)(instance, kubernetes, task);
197
- await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, task);
402
+ await removeTaskSecrets(instance, kubernetes, task);
198
403
  },
199
404
  async currentStateKey() {
405
+ if (kept) {
406
+ // the server removes the Jobs of a run at its end, the claim says
407
+ return (0, kept_claims_1.useClaimState)(instance, kubernetes, claim);
408
+ }
200
409
  const jobs = await instance.batchApi.listNamespacedJob({
201
410
  namespace: kubernetes.namespace,
202
411
  labelSelector: `hammerkit.dev/id=${task.id()}`,
@@ -211,11 +420,11 @@ function kubernetesTaskRuntime(task, kubernetes) {
211
420
  // outputs live in the task's claim; without it the finished job left
212
421
  // nothing to reuse
213
422
  try {
214
- const claim = await instance.coreApi.readNamespacedPersistentVolumeClaim({
215
- name: (0, volumes_1.getVolumeName)(task),
423
+ const found = await instance.coreApi.readNamespacedPersistentVolumeClaim({
424
+ name: claim,
216
425
  namespace: kubernetes.namespace,
217
426
  });
218
- return claim.metadata?.deletionTimestamp ? null : stateKey;
427
+ return found.metadata?.deletionTimestamp ? null : stateKey;
219
428
  }
220
429
  catch (e) {
221
430
  if ((0, apply_1.statusCodeOf)(e) === 404) {
@@ -230,8 +439,29 @@ exports.kubernetesTaskRuntime = kubernetesTaskRuntime;
230
439
  function kubernetesServiceRuntime(service, kubernetes) {
231
440
  const instance = (0, kubernetes_instance_1.createKubernetesInstances)(kubernetes);
232
441
  return {
233
- initialize() {
234
- return Promise.resolve();
442
+ // A Deployment left by an earlier `up` is the running service, so `down`
443
+ // stops it and a run reuses it, as with a container on Docker. One whose
444
+ // definition changed goes, with its limits, and is created anew.
445
+ async initialize(state) {
446
+ const deployments = await instance.appsApi.listNamespacedDeployment({
447
+ namespace: kubernetes.namespace,
448
+ labelSelector: `hammerkit.dev/id=${service.id()}`,
449
+ });
450
+ const deployment = deployments.items[0];
451
+ if (!deployment?.metadata?.name) {
452
+ return;
453
+ }
454
+ if (deployment.metadata.labels?.[ensure_kubernetes_deployment_exists_1.DEFINITION_LABEL] !== (0, service_definition_1.getServiceDefinitionHash)(service)) {
455
+ service.status.write('info', `${service.name} changed since it was started, recreating it`);
456
+ await this.stop();
457
+ return;
458
+ }
459
+ state.set({
460
+ type: 'running',
461
+ dns: { containerId: deployment.metadata.name },
462
+ stateKey: deployment.metadata.labels?.['hammerkit.dev/state'] ?? '',
463
+ remote: null,
464
+ });
235
465
  },
236
466
  async restore(environment, path) {
237
467
  await (0, restore_kubernetes_data_1.restoreKubernetesData)(service, kubernetes, instance, environment, path);
@@ -253,14 +483,17 @@ function kubernetesServiceRuntime(service, kubernetes) {
253
483
  }
254
484
  }
255
485
  await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, service);
486
+ // the limits go last, once the service's pods ended
487
+ await (0, network_policy_1.removeAllKubernetesItemNetworks)(instance, kubernetes, service);
256
488
  },
257
489
  async execute(environment, options) {
258
- const persistence = await (0, volumes_1.getKubernetesPersistence)(service);
490
+ const persistence = await (0, volumes_1.getKubernetesPersistence)(service, kubernetes);
259
491
  await (0, ensure_namespace_1.ensureNamespace)(instance, kubernetes.namespace);
260
492
  await (0, ensure_kubernetes_service_exists_1.ensureKubernetesServiceExists)(instance, kubernetes, service);
261
493
  await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, service, persistence);
262
494
  await (0, secrets_1.ensureKubernetesSecret)(instance, kubernetes, service, service.data.secrets, environment);
263
- await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, service.data.secrets), options.stateKey);
495
+ const network = await (0, network_policy_1.ensureKubernetesItemNetwork)(instance, kubernetes, service, { followLog: false, run: null });
496
+ await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, service.data.secrets), options.stateKey, network);
264
497
  const name = (0, resources_1.getResourceName)(service);
265
498
  options.state.set({
266
499
  type: 'running',
package/dist/program.js CHANGED
@@ -28,6 +28,8 @@ const cache_inventory_1 = require("./cache/cache-inventory");
28
28
  const remote_commands_1 = require("./remote/remote-commands");
29
29
  const server_command_1 = require("./server/server-command");
30
30
  const run_worker_command_1 = require("./server/run-worker-command");
31
+ const github_app_token_command_1 = require("./secrets/github-app-token-command");
32
+ const work_network_1 = require("./planner/work-network");
31
33
  const task_arguments_1 = require("./planner/utils/task-arguments");
32
34
  async function createCli(fileName, environment, workScope) {
33
35
  const { ctx, scope } = await (0, schema_parser_1.createParseContext)(fileName, environment);
@@ -138,6 +140,8 @@ async function getProgram(environment, argv) {
138
140
  const program = new commander_1.Command();
139
141
  (0, server_command_1.registerServerCommand)(program, environment);
140
142
  (0, run_worker_command_1.registerRunWorkerCommand)(program, environment);
143
+ (0, run_worker_command_1.registerSandboxPodCommand)(program, environment);
144
+ (0, github_app_token_command_1.registerGithubAppTokenCommand)(program, environment);
141
145
  // What follows `--` belongs to the one task or service of a run or up, not to
142
146
  // hammerkit: a `--set` or `--file` there is an argument like any other.
143
147
  const separator = argv.indexOf('--');
@@ -145,6 +149,11 @@ async function getProgram(environment, argv) {
145
149
  const parsed = (0, parse_set_arguments_1.parseSetArguments)(separator >= 0 ? argv.slice(0, separator) : argv);
146
150
  const args = parsed.args;
147
151
  environment.processEnvs = { ...environment.processEnvs, ...parsed.values };
152
+ const remote = args.indexOf('remote');
153
+ if (remote >= 0 && args[remote + 1] === 'mcp') {
154
+ // `remote mcp` speaks its protocol on stdout: a warning there would break it
155
+ environment.console = (0, log_1.consoleContext)(environment.stderr);
156
+ }
148
157
  const fileIndex = args.indexOf('--file');
149
158
  const fileName = fileIndex >= 0 ? (0, path_1.join)(environment.cwd, args[fileIndex + 1]) : await (0, default_build_file_1.getBuildFilename)(environment.cwd, environment);
150
159
  if (fileIndex >= 0) {
@@ -593,6 +602,7 @@ async function getProgram(environment, argv) {
593
602
  .choices(['checksum', 'modify-date', 'none']))
594
603
  .addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
595
604
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
605
+ .addOption(new commander_1.Option('--network <level>', 'the most any task or service reaches besides its needs: none, open or allow:<host>,<host>').argParser(parseNetworkArgument))
596
606
  .action(async (options, command) => {
597
607
  try {
598
608
  const scope = {
@@ -610,6 +620,7 @@ async function getProgram(environment, argv) {
610
620
  wait: options.wait,
611
621
  cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
612
622
  timeout: options.timeout ?? null,
623
+ network: options.network ?? null,
613
624
  });
614
625
  if (!result.success) {
615
626
  program.error('Up was not successful', { exitCode: 1 });
@@ -671,6 +682,7 @@ async function getProgram(environment, argv) {
671
682
  .addOption(new commander_1.Option('--dry-run', 'print the execution plan with predicted cache hits/misses without running').default(false))
672
683
  .addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
673
684
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
685
+ .addOption(new commander_1.Option('--network <level>', 'the most any task or service reaches besides its needs: none, open or allow:<host>,<host>').argParser(parseNetworkArgument))
674
686
  .addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when nothing needing them has to run'))
675
687
  .action(async (task, options, command) => {
676
688
  try {
@@ -704,6 +716,7 @@ async function getProgram(environment, argv) {
704
716
  explain: options.explain,
705
717
  cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
706
718
  timeout: options.timeout ?? null,
719
+ network: options.network ?? null,
707
720
  skipDeps: options.skipDeps !== false,
708
721
  });
709
722
  // Reporting only: the summary never changes the exit code or behavior.
@@ -775,4 +788,12 @@ tasks:
775
788
  return { program, args };
776
789
  }
777
790
  exports.getProgram = getProgram;
791
+ function parseNetworkArgument(value) {
792
+ try {
793
+ return (0, work_network_1.parseNetworkOption)(value);
794
+ }
795
+ catch (e) {
796
+ throw new commander_1.InvalidArgumentError((0, log_1.getErrorMessage)(e));
797
+ }
798
+ }
778
799
  //# sourceMappingURL=program.js.map