hammerkit 1.27.0-next.11 → 1.27.0-next.13

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.
package/dist/cli.js CHANGED
@@ -79,6 +79,7 @@ class Cli {
79
79
  explain: options?.explain ?? false,
80
80
  cacheReadOnly: options?.cacheReadOnly ?? false,
81
81
  timeout: options?.timeout ?? null,
82
+ maxTimeout: options?.maxTimeout ?? null,
82
83
  skipDeps: options?.skipDeps ?? true,
83
84
  wait: options?.wait ?? 'ready',
84
85
  resourceCheck: options?.resourceCheck ?? true,
@@ -1,14 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.withDeadline = void 0;
3
+ exports.withDeadline = exports.getTaskTimeout = void 0;
4
4
  const abort_event_1 = require("../utils/abort-event");
5
+ // How long a task may run: its own `timeout`, else the run's default (`--timeout`), and
6
+ // never longer than the run's cap (`--max-timeout`), which also covers tasks with a
7
+ // longer timeout of their own. A caller can shorten a task this way, never extend it.
8
+ function getTaskTimeout(own, run) {
9
+ const timeout = own ?? run.timeout;
10
+ if (run.maxTimeout === null) {
11
+ return timeout;
12
+ }
13
+ return timeout === null ? run.maxTimeout : Math.min(timeout, run.maxTimeout);
14
+ }
15
+ exports.getTaskTimeout = getTaskTimeout;
16
+ // the longest delay of setTimeout: Node fires a longer one after 1 ms
17
+ const MAX_TIMER_MS = 2 ** 31 - 1;
5
18
  // An abort signal that follows `parent` and additionally fires after `timeout`
6
- // ms, so a task deadline reuses the runtimes' existing abort/cleanup path.
19
+ // ms, so a task deadline reuses the runtimes' existing abort/cleanup path. A
20
+ // timeout beyond what a timer can wait (about 24.8 days) sets no deadline.
7
21
  function withDeadline(parent, timeout) {
8
22
  const controller = new AbortController();
9
23
  let expired = false;
10
24
  const parentListener = (0, abort_event_1.listenOnAbort)(parent, () => controller.abort());
11
- const timer = timeout === null
25
+ const timer = timeout === null || timeout > MAX_TIMER_MS
12
26
  ? null
13
27
  : setTimeout(() => {
14
28
  expired = true;
@@ -94,7 +94,7 @@ dependents = null) {
94
94
  stateKey: cacheState.stateKey,
95
95
  started,
96
96
  });
97
- const timeout = work.data.timeout ?? options.timeout;
97
+ const timeout = (0, deadline_1.getTaskTimeout)(work.data.timeout, options);
98
98
  const deadline = (0, deadline_1.withDeadline)(abort, timeout);
99
99
  try {
100
100
  await work.runtime.execute(environment, {
@@ -115,7 +115,8 @@ class SandboxClient {
115
115
  runOptions.args = options.args;
116
116
  }
117
117
  if (options.timeoutSeconds) {
118
- runOptions.timeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
118
+ // a cap: it also shortens a shell task that sets a longer timeout of its own
119
+ runOptions.maxTimeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
119
120
  }
120
121
  const submitted = await submitRun(this.connection, {
121
122
  definition: this.build.hash,
@@ -129,7 +129,8 @@ function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item,
129
129
  namespace: env.namespace,
130
130
  name: getNetworkPolicyName(item, scope),
131
131
  annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
132
- labels: { [NETWORK_OF_LABEL]: item.id(), ...(0, job_placement_1.getRunLabels)(env) },
132
+ labels: { [NETWORK_OF_LABEL]: item.id(), ...(scope.owners ? {} : (0, job_placement_1.getRunLabels)(env)) },
133
+ ...(scope.owners ? { ownerReferences: scope.owners } : {}),
133
134
  },
134
135
  spec: {
135
136
  podSelector: { matchLabels: getItemPodLabels(item, scope) },
@@ -0,0 +1,626 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.removeSandboxRunners = exports.executeInRunner = exports.getExecExitCode = exports.buildRunnerPod = exports.getRunnerName = exports.getRunnerKey = exports.canUseRunner = exports.getWrapper = exports.RUNNER_CHANGED_EXIT = exports.getCleanUp = exports.getScratchDirectories = exports.getRunnerMounts = exports.RUNNER_SANDBOX_PATH = exports.IDLE_COMMAND = exports.RUNNER_TOOLS_IMAGE = exports.RUNNER_LABEL = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const path_1 = require("path");
6
+ const string_decoder_1 = require("string_decoder");
7
+ const stream_1 = require("stream");
8
+ const promises_1 = require("stream/promises");
9
+ const work_network_1 = require("../planner/work-network");
10
+ const replace_env_variables_1 = require("../environment/replace-env-variables");
11
+ const log_1 = require("../log");
12
+ const sleep_1 = require("../utils/sleep");
13
+ const version_1 = require("../version");
14
+ const apply_1 = require("./apply");
15
+ const container_resources_1 = require("./container-resources");
16
+ const job_placement_1 = require("./job-placement");
17
+ const network_policy_1 = require("./network-policy");
18
+ const resources_1 = require("./resources");
19
+ const volumes_1 = require("./volumes");
20
+ /**
21
+ * The runner of a sandbox: one pod that the commands of a run on the sandbox are exec'd
22
+ * into, one after the other, in place of a Job each. A Job's pod takes seconds to be
23
+ * scheduled and to start; an exec into a running pod a fraction of one.
24
+ *
25
+ * - Only a task shaped like an agent's shell gets it: a container task that mounts the
26
+ * whole sandbox (`mounts: [.]`) and nothing else (no claims of its own or of its
27
+ * dependencies, so no `generates`), with no `needs` and no secrets. Any other task runs
28
+ * as a Job.
29
+ * - The pod is the task's container as its Job would have it: its image, variables,
30
+ * resources, the sandbox's claim, the security context, the nodes of the pool and the
31
+ * node of the pod of the sandbox's files. It is named after all of that (getRunnerKey),
32
+ * so a change starts a new one and the one before goes. Its first process only waits.
33
+ * - Every command runs in a wrapper that first and last kills every other process of the
34
+ * container and empties /tmp and /dev/shm (unless a mount of the task is in one): the
35
+ * container is reused, what a command started is not. A command cancelled, out of time or whose connection broke is killed
36
+ * the same way, or the pod is deleted. The first process and the wrapper run a static
37
+ * busybox from a read-only mount, so a command can not replace what cleans up after it.
38
+ * - Under a limited network it has a NetworkPolicy of its own and, under `allow`, the
39
+ * sandbox's egress proxy, both keyed like the pod. They carry no run, and go with the
40
+ * pod of the sandbox's files, which owns them and the runner: the runner stays while
41
+ * the sandbox has that pod, `backend.sandboxes.idle` after its last run or request.
42
+ */
43
+ exports.RUNNER_LABEL = 'hammerkit.dev/runner';
44
+ const RUNNER_CONTAINER = 'runner';
45
+ const TMP_VOLUME = 'hammerkit-runner-tmp';
46
+ const TOOLS_VOLUME = 'hammerkit-runner-tools';
47
+ const TOOLS_PATH = '/.hammerkit';
48
+ const BUSYBOX = `${TOOLS_PATH}/busybox`;
49
+ // Pinned, static (it runs in any image), and copied into the runner by an init container.
50
+ // To bump it, take the index digest of the tag (`docker buildx imagetools inspect
51
+ // busybox:<version>-musl`).
52
+ exports.RUNNER_TOOLS_IMAGE = 'busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092';
53
+ const RUNNER_START_MS = 5 * 60 * 1000;
54
+ const RUNNER_POLL_MS = 250;
55
+ const PULL_FAILURES = ['ErrImagePull', 'ImagePullBackOff', 'InvalidImageName'];
56
+ const KILL_TIMEOUT_MS = 10000;
57
+ // a command's output is kept to this, as the log of a Job
58
+ const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
59
+ // Waits without a process of its own to signal: a command's `kill -KILL -1` ends the
60
+ // sleep, not the loop (the first process of the container is spared), which reaps what
61
+ // the commands left
62
+ exports.IDLE_COMMAND = [BUSYBOX, 'sh', '-c', `trap "exit 0" TERM; while :; do ${BUSYBOX} sleep 3600 & wait; done`];
63
+ const SCRATCH_DIRECTORIES = ['/tmp', '/dev/shm'];
64
+ // Where the runner mounts the sandbox's files. A run has them in a directory of its own (the
65
+ // server's directory of the run), which every command links here first: the pod and its key
66
+ // stay the same from one run to the next.
67
+ exports.RUNNER_SANDBOX_PATH = '/.hammerkit-sandbox';
68
+ // The mounts of the task, from the directory of the run to the runner's
69
+ function getRunnerMounts(workspace, persistence) {
70
+ return persistence.mounts.map(({ mount }) => ({ ...mount, mountPath: toRunnerPath(workspace, mount.mountPath) }));
71
+ }
72
+ exports.getRunnerMounts = getRunnerMounts;
73
+ function toRunnerPath(workspace, path) {
74
+ const rel = (0, path_1.relative)(workspace.root, path);
75
+ return rel === '' ? exports.RUNNER_SANDBOX_PATH : path_1.posix.join(exports.RUNNER_SANDBOX_PATH, ...rel.split(path_1.sep));
76
+ }
77
+ // The scratch directories the runner empties: not one that holds a mount of the task, or is
78
+ // in one, such as the sandbox's files when the server keeps its runs below /tmp
79
+ function getScratchDirectories(mounts) {
80
+ const within = (path, directory) => path === directory || path.startsWith(`${directory}/`);
81
+ return SCRATCH_DIRECTORIES.filter((directory) => mounts.every((mount) => !within(mount.mountPath, directory) && !within(directory, mount.mountPath)));
82
+ }
83
+ exports.getScratchDirectories = getScratchDirectories;
84
+ // Kills every process of the container but the first and the shell itself (`kill` is the
85
+ // shell's own), again while one forked meanwhile, then empties the scratch directories
86
+ function getCleanUp(directories) {
87
+ const patterns = directories.flatMap((directory) => [`${directory}/*`, `${directory}/.[!.]*`, `${directory}/..?*`]);
88
+ return [
89
+ 'i=0',
90
+ 'while kill -KILL -1 2>/dev/null && [ $i -lt 10 ]; do i=$((i+1)); done',
91
+ ...(patterns.length > 0 ? [`${BUSYBOX} rm -rf ${patterns.join(' ')} 2>/dev/null`] : []),
92
+ ].join('\n');
93
+ }
94
+ exports.getCleanUp = getCleanUp;
95
+ // What the wrapper says on stderr, with RUNNER_CHANGED_EXIT, when a command before changed
96
+ // the runner: the command did not run, the runner goes and the task fails
97
+ exports.RUNNER_CHANGED_EXIT = 125;
98
+ const RUNNER_CHANGED_MARK = `hammerkit-runner-changed-${(0, crypto_1.randomBytes)(8).toString('hex')}`;
99
+ // Runs `"$@"` in the directory `$3`, alone in the container: what an earlier command left
100
+ // is cleaned up before, what this one left after. The directory of the run `$2` is a link
101
+ // to the sandbox's files first. Before the command runs, each of `mountPaths` must still be
102
+ // the mount it was made as (the kernel's mountinfo, which no command writes): a command
103
+ // before could have moved a folder a mount is in and put files of its own in its place.
104
+ // When not, it says `$1` and exits RUNNER_CHANGED_EXIT. Else its exit code is the command's.
105
+ function getWrapper(cleanUp, mountPaths) {
106
+ const sandbox = shellQuote(exports.RUNNER_SANDBOX_PATH);
107
+ return [
108
+ cleanUp,
109
+ `changed() { echo "$1" >&2; exit ${exports.RUNNER_CHANGED_EXIT}; }`,
110
+ `${BUSYBOX} mkdir -p "$(${BUSYBOX} dirname "$2")" && ${BUSYBOX} ln -sfn ${sandbox} "$2" || changed "$1"`,
111
+ ...mountPaths.map((path) => {
112
+ const quoted = shellQuote(path);
113
+ const field = shellQuote(`^[^ ]* [^ ]* [^ ]* [^ ]* ${toMountInfoPattern(path)} `);
114
+ return `[ "$(${BUSYBOX} realpath ${quoted})" = ${quoted} ] && ${BUSYBOX} grep -q ${field} /proc/self/mountinfo || changed "$1"`;
115
+ }),
116
+ 'cd "$3" || exit 126',
117
+ `case "$(pwd -P)" in ${sandbox}|${sandbox}/*) ;; *) changed "$1" ;; esac`,
118
+ 'shift 3',
119
+ '"$@"',
120
+ 'code=$?',
121
+ cleanUp,
122
+ 'exit $code',
123
+ ].join('\n');
124
+ }
125
+ exports.getWrapper = getWrapper;
126
+ function shellQuote(value) {
127
+ return `'${value.replace(/'/g, `'\\''`)}'`;
128
+ }
129
+ // A mount point as /proc/self/mountinfo writes it (space, tab, newline and backslash as
130
+ // octal escapes), as a basic regular expression
131
+ function toMountInfoPattern(path) {
132
+ const escaped = path.replace(/[\\ \t\n]/g, (c) => `\\${c.charCodeAt(0).toString(8).padStart(3, '0')}`);
133
+ return escaped.replace(/[.[\]*^$\\]/g, (c) => `\\${c}`);
134
+ }
135
+ // the sandboxes whose runner a task of this process uses: one command at a time each, the
136
+ // other tasks of the run run as Jobs meanwhile
137
+ const inUse = new Set();
138
+ // per sandbox, the runners whose container ended as it started (an image without `sh`, or
139
+ // one that can not be pulled): their tasks run as Jobs while that pod of the files stays
140
+ const failed = new Map();
141
+ // Whether the task's commands may run in the sandbox's runner
142
+ function canUseRunner(task, kubernetes, persistence) {
143
+ const workspace = kubernetes.server?.workspace;
144
+ return (!!workspace?.runner &&
145
+ !!workspace.pod &&
146
+ persistence.workspace.length > 0 &&
147
+ persistence.volumes.length === 0 &&
148
+ persistence.mounts.every((mount) => mount.workspace) &&
149
+ // the whole of the files at the root, so the runner has no writable directory of its own
150
+ // around the mounts that a command could leave files in for the next runs
151
+ persistence.mounts.some((mount) => (0, path_1.relative)(workspace.root, mount.mount.mountPath) === '') &&
152
+ task.needs.length === 0 &&
153
+ task.data.secrets.length === 0 &&
154
+ task.data.generates.length === 0);
155
+ }
156
+ exports.canUseRunner = canUseRunner;
157
+ // What the runner is made of: the same key for the same pod
158
+ function getRunnerKey(task, kubernetes, persistence) {
159
+ const server = kubernetes.server;
160
+ const workspace = server.workspace;
161
+ return (0, crypto_1.createHash)('sha256')
162
+ .update(JSON.stringify({
163
+ sandbox: workspace.label,
164
+ pod: workspace.pod?.uid ?? null,
165
+ image: task.data.image,
166
+ envs: (0, replace_env_variables_1.getEnvironmentVariables)(task.data.envs),
167
+ resources: (0, container_resources_1.getKubernetesResources)(task.data.resources),
168
+ network: (0, work_network_1.getEffectiveNetwork)(task.data),
169
+ mounts: getRunnerMounts(workspace, persistence),
170
+ placement: (0, job_placement_1.getPodPlacement)(server, task.data.pool),
171
+ security: (0, job_placement_1.getContainerSecurity)(kubernetes),
172
+ version: (0, version_1.getVersion)(),
173
+ }))
174
+ .digest('hex')
175
+ .slice(0, 40);
176
+ }
177
+ exports.getRunnerKey = getRunnerKey;
178
+ function getRunnerName(key) {
179
+ return `hammerkit-runner-${key.slice(0, 20)}`;
180
+ }
181
+ exports.getRunnerName = getRunnerName;
182
+ function buildRunnerPod(options) {
183
+ const { task, kubernetes, persistence, key, network, owner } = options;
184
+ const server = kubernetes.server;
185
+ const workspace = server.workspace;
186
+ const placement = (0, job_placement_1.getPodPlacement)(server, task.data.pool);
187
+ const envs = (0, replace_env_variables_1.getEnvironmentVariables)(task.data.envs);
188
+ return {
189
+ apiVersion: 'v1',
190
+ kind: 'Pod',
191
+ metadata: {
192
+ namespace: kubernetes.namespace,
193
+ name: getRunnerName(key),
194
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
195
+ labels: {
196
+ // what its policy and proxy select it by
197
+ [resources_1.ID_LABEL]: key,
198
+ [exports.RUNNER_LABEL]: key.slice(0, 20),
199
+ [volumes_1.SANDBOX_LABEL]: workspace.label,
200
+ ...(0, network_policy_1.getNetworkLabels)(kubernetes, task),
201
+ },
202
+ ownerReferences: [owner],
203
+ },
204
+ spec: {
205
+ automountServiceAccountToken: false,
206
+ enableServiceLinks: false,
207
+ restartPolicy: 'Never',
208
+ terminationGracePeriodSeconds: 0,
209
+ nodeSelector: placement.nodeSelector,
210
+ ...(placement.tolerations.length > 0 ? { tolerations: placement.tolerations } : {}),
211
+ affinity: (0, volumes_1.getSandboxAffinity)(workspace.label),
212
+ hostAliases: network.hostAliases,
213
+ initContainers: [
214
+ ...network.initContainers,
215
+ {
216
+ name: 'hammerkit-runner-tools',
217
+ image: exports.RUNNER_TOOLS_IMAGE,
218
+ command: ['/bin/busybox', 'cp', '/bin/busybox', BUSYBOX],
219
+ volumeMounts: [{ name: TOOLS_VOLUME, mountPath: TOOLS_PATH }],
220
+ securityContext: {
221
+ runAsNonRoot: true,
222
+ runAsUser: 65534,
223
+ allowPrivilegeEscalation: false,
224
+ readOnlyRootFilesystem: true,
225
+ capabilities: { drop: ['ALL'] },
226
+ },
227
+ resources: { limits: { memory: '16Mi' } },
228
+ },
229
+ ],
230
+ containers: [
231
+ {
232
+ name: RUNNER_CONTAINER,
233
+ image: task.data.image,
234
+ command: exports.IDLE_COMMAND,
235
+ workingDir: exports.RUNNER_SANDBOX_PATH,
236
+ env: [...Object.entries(envs).map(([name, value]) => ({ name, value })), ...network.env],
237
+ volumeMounts: [
238
+ ...getRunnerMounts(workspace, persistence),
239
+ { name: TMP_VOLUME, mountPath: '/tmp' },
240
+ // not even root in the container writes it: it has no capability to remount
241
+ { name: TOOLS_VOLUME, mountPath: TOOLS_PATH, readOnly: true },
242
+ ],
243
+ ...(0, container_resources_1.getKubernetesResources)(task.data.resources),
244
+ ...(0, job_placement_1.getContainerSecurity)(kubernetes),
245
+ },
246
+ ],
247
+ volumes: [...persistence.workspace, { name: TMP_VOLUME, emptyDir: {} }, { name: TOOLS_VOLUME, emptyDir: {} }],
248
+ },
249
+ };
250
+ }
251
+ exports.buildRunnerPod = buildRunnerPod;
252
+ // The exit code of an exec, from the status the API server ends it with
253
+ function getExecExitCode(status) {
254
+ if (status?.status === 'Success') {
255
+ return 0;
256
+ }
257
+ const code = status?.details?.causes?.find((cause) => cause.reason === 'ExitCode')?.message;
258
+ return code !== undefined && /^\d+$/.test(code) ? Number(code) : undefined;
259
+ }
260
+ exports.getExecExitCode = getExecExitCode;
261
+ /**
262
+ * Runs the task's commands in the sandbox's runner, started when there is none (or one of
263
+ * something else). `unavailable` when the task can not use it now (the runner is in use by
264
+ * another task of the run, the pod of the sandbox's files went, or the runner did not
265
+ * start): the task then runs as Jobs.
266
+ */
267
+ async function executeInRunner(instance, task, kubernetes, persistence, abort) {
268
+ const workspace = kubernetes.server.workspace;
269
+ if (inUse.has(workspace.label)) {
270
+ return { type: 'unavailable' };
271
+ }
272
+ inUse.add(workspace.label);
273
+ try {
274
+ const filesPod = await getFilesPod(instance, kubernetes.namespace, workspace);
275
+ if (!filesPod) {
276
+ return { type: 'unavailable' };
277
+ }
278
+ const key = getRunnerKey(task, kubernetes, persistence);
279
+ const name = getRunnerName(key);
280
+ const failedOf = failed.get(workspace.label);
281
+ if (failedOf?.pod === filesPod.uid && failedOf.keys.has(key)) {
282
+ return { type: 'unavailable' };
283
+ }
284
+ // its policy and proxy select it by the key, and stay for the next runs
285
+ const item = { ...task, id: () => key };
286
+ const found = await getRunner(instance, kubernetes.namespace, name, filesPod.uid);
287
+ let runner = found?.ready ? found.pod : undefined;
288
+ const sandbox = { label: workspace.label, pod: workspace.pod };
289
+ const owners = (pod) => [filesPod, ...(pod ? [ownerOf(pod)] : [])];
290
+ const network = await (0, network_policy_1.ensureKubernetesItemNetwork)(instance, kubernetes, item, {
291
+ followLog: true,
292
+ run: null,
293
+ sandbox,
294
+ owners: owners(runner),
295
+ });
296
+ try {
297
+ if (!runner) {
298
+ let started;
299
+ if (found) {
300
+ // one a task before started, which stopped while it did: it goes on starting
301
+ started = await waitForRunner(instance, kubernetes.namespace, found.pod, abort);
302
+ }
303
+ else {
304
+ // one of something else goes, with its limits
305
+ await removeSandboxRunners(instance, kubernetes.namespace, workspace.label, (pod) => pod.metadata?.name === name);
306
+ started = await startRunner(instance, buildRunnerPod({ task, kubernetes, persistence, key, network, owner: filesPod }), abort);
307
+ }
308
+ if (!('pod' in started)) {
309
+ if (started.reason === 'ended') {
310
+ if (failedOf?.pod !== filesPod.uid) {
311
+ failed.set(workspace.label, { pod: filesPod.uid, keys: new Set() });
312
+ }
313
+ failed.get(workspace.label).keys.add(key);
314
+ }
315
+ task.status.write('debug', `the runner of the sandbox did not start (${started.message}), the task runs as a Job`);
316
+ return { type: 'unavailable' };
317
+ }
318
+ runner = started.pod;
319
+ if (Object.keys((0, network_policy_1.getNetworkLabels)(kubernetes, task)).length > 0) {
320
+ // the policy stays until the runner ended too
321
+ await setPolicyOwners(instance, kubernetes.namespace, (0, network_policy_1.getNetworkPolicyName)(item, { run: null }), owners(runner));
322
+ }
323
+ }
324
+ else {
325
+ task.status.write('debug', `runs in the runner ${name} of the sandbox`);
326
+ }
327
+ const mounts = getRunnerMounts(workspace, persistence);
328
+ const cleanUp = getCleanUp(getScratchDirectories(mounts));
329
+ const wrapper = getWrapper(cleanUp, mounts.map((mount) => mount.mountPath));
330
+ for (const cmd of task.data.cmds) {
331
+ task.status.write('debug', `execute ${cmd.cmd} in the runner of the sandbox`);
332
+ const exitCode = await execInRunner(instance, kubernetes.namespace, name, workspace.root, cmd.cwd, [task.data.shell, '-c', cmd.cmd], { wrapper, cleanUp }, task, abort);
333
+ if (abort.aborted) {
334
+ return { type: 'canceled' };
335
+ }
336
+ if (exitCode === 'changed') {
337
+ // a fresh pod for the next task; this one fails rather than running again as a Job,
338
+ // since a command can say so itself after it ran
339
+ await removeSandboxRunners(instance, kubernetes.namespace, workspace.label, (pod) => pod.metadata?.name !== name);
340
+ throw new Error('a command changed the runner of the sandbox, it was removed: run the task again');
341
+ }
342
+ if (exitCode !== 0) {
343
+ return { type: 'crash', exitCode };
344
+ }
345
+ }
346
+ return { type: 'done' };
347
+ }
348
+ finally {
349
+ network.close();
350
+ }
351
+ }
352
+ finally {
353
+ inUse.delete(workspace.label);
354
+ }
355
+ }
356
+ exports.executeInRunner = executeInRunner;
357
+ // The pod of the sandbox's files the run holds, as long as it is still that pod
358
+ async function getFilesPod(instance, namespace, workspace) {
359
+ if (!workspace.pod) {
360
+ return undefined;
361
+ }
362
+ const pod = await instance.coreApi
363
+ .readNamespacedPod({ name: workspace.pod.name, namespace })
364
+ .catch((e) => ((0, apply_1.statusCodeOf)(e) === 404 ? undefined : Promise.reject(e)));
365
+ if (!pod || pod.metadata?.uid !== workspace.pod.uid || pod.metadata?.deletionTimestamp) {
366
+ return undefined;
367
+ }
368
+ return ownerOf(pod);
369
+ }
370
+ function ownerOf(pod) {
371
+ return { apiVersion: 'v1', kind: 'Pod', name: pod.metadata.name, uid: pod.metadata.uid };
372
+ }
373
+ // The runner of that name, when it belongs to the pod of the files and runs, or still
374
+ // starts (`ready` false); one that ended or is going is deleted
375
+ async function getRunner(instance, namespace, name, owner) {
376
+ const pod = await instance.coreApi
377
+ .readNamespacedPod({ name, namespace })
378
+ .catch((e) => ((0, apply_1.statusCodeOf)(e) === 404 ? undefined : Promise.reject(e)));
379
+ if (!pod) {
380
+ return undefined;
381
+ }
382
+ const ours = !pod.metadata?.deletionTimestamp &&
383
+ (pod.metadata?.ownerReferences ?? []).some((reference) => reference.uid === owner);
384
+ if (ours && isRunning(pod)) {
385
+ return { pod, ready: true };
386
+ }
387
+ if (ours && pod.status?.phase === 'Pending' && !getStartFailure(pod)) {
388
+ return { pod, ready: false };
389
+ }
390
+ await deletePod(instance, namespace, name);
391
+ return undefined;
392
+ }
393
+ function isRunning(pod) {
394
+ const status = pod.status?.containerStatuses?.find((s) => s.name === RUNNER_CONTAINER);
395
+ return pod.status?.phase === 'Running' && !!status?.state?.running;
396
+ }
397
+ // Why the runner will not run: its container ended, or an image can not be pulled, which
398
+ // waits forever and is as good as ended
399
+ function getStartFailure(pod) {
400
+ const status = pod.status?.containerStatuses?.find((s) => s.name === RUNNER_CONTAINER);
401
+ const unpulled = [...(pod.status?.initContainerStatuses ?? []), ...(pod.status?.containerStatuses ?? [])].find((s) => PULL_FAILURES.includes(s.state?.waiting?.reason ?? ''));
402
+ if (unpulled) {
403
+ return `${unpulled.state?.waiting?.reason} of ${unpulled.image}`;
404
+ }
405
+ if (pod.status?.phase === 'Failed' || pod.status?.phase === 'Succeeded' || status?.state?.terminated) {
406
+ return `its container ended with ${status?.state?.terminated?.exitCode ?? 'no code'}`;
407
+ }
408
+ return undefined;
409
+ }
410
+ // Waits until no pod of that name is left: a new one of the same name is refused before
411
+ async function deletePod(instance, namespace, name) {
412
+ try {
413
+ await instance.coreApi.deleteNamespacedPod({ name, namespace, gracePeriodSeconds: 0 });
414
+ }
415
+ catch (e) {
416
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
417
+ return;
418
+ }
419
+ throw e;
420
+ }
421
+ const deadline = Date.now() + RUNNER_START_MS;
422
+ while (Date.now() < deadline) {
423
+ const left = await instance.coreApi
424
+ .readNamespacedPod({ name, namespace })
425
+ .catch((e) => ((0, apply_1.statusCodeOf)(e) === 404 ? undefined : Promise.reject(e)));
426
+ if (!left) {
427
+ return;
428
+ }
429
+ await (0, sleep_1.sleep)(RUNNER_POLL_MS);
430
+ }
431
+ throw new Error(`the runner ${name} is still there`);
432
+ }
433
+ // Starts the runner and answers it once it runs, or why it does not: the cluster refused
434
+ // it (a full quota, say, which a Job waits for), its container ended, or it took too long
435
+ async function startRunner(instance, spec, abort) {
436
+ try {
437
+ await instance.objectApi.create(spec);
438
+ }
439
+ catch (e) {
440
+ return { reason: 'refused', message: (0, log_1.getErrorMessage)(e) };
441
+ }
442
+ return await waitForRunner(instance, spec.metadata.namespace, spec, abort);
443
+ }
444
+ // Waits until the runner runs. A task stopped meanwhile (cancelled, out of time) leaves it
445
+ // starting, for the next command, which would time out the same way if each started anew;
446
+ // one that ends, or does not run within RUNNER_START_MS of being made, goes.
447
+ async function waitForRunner(instance, namespace, runner, abort) {
448
+ const name = runner.metadata.name;
449
+ const created = runner.metadata?.creationTimestamp ? new Date(runner.metadata.creationTimestamp).getTime() : NaN;
450
+ const deadline = (Number.isNaN(created) ? Date.now() : Math.min(created, Date.now())) + RUNNER_START_MS;
451
+ for (;;) {
452
+ const pod = await instance.coreApi.readNamespacedPod({ name, namespace });
453
+ if (isRunning(pod)) {
454
+ return { pod };
455
+ }
456
+ const failure = getStartFailure(pod);
457
+ if (failure || Date.now() > deadline) {
458
+ await deletePod(instance, namespace, name).catch(() => undefined);
459
+ return failure ? { reason: 'ended', message: failure } : { reason: 'stopped', message: 'it took too long' };
460
+ }
461
+ if (abort.aborted) {
462
+ return { reason: 'stopped', message: 'the task was stopped' };
463
+ }
464
+ await (0, sleep_1.sleep)(RUNNER_POLL_MS);
465
+ }
466
+ }
467
+ // Removes the runners of a sandbox but those `keep` takes, and once each is gone its policy
468
+ // and proxy, which the pod of the sandbox's files would keep while it runs
469
+ async function removeSandboxRunners(instance, namespace, sandbox, keep) {
470
+ if (!keep) {
471
+ // all of the sandbox's runners go: so does what was remembered of them
472
+ failed.delete(sandbox);
473
+ }
474
+ const pods = await instance.coreApi.listNamespacedPod({
475
+ namespace,
476
+ labelSelector: `${volumes_1.SANDBOX_LABEL}=${sandbox},${exports.RUNNER_LABEL}`,
477
+ });
478
+ for (const pod of pods.items) {
479
+ const name = pod.metadata?.name;
480
+ const key = pod.metadata?.labels?.[resources_1.ID_LABEL];
481
+ if (!name || keep?.(pod)) {
482
+ continue;
483
+ }
484
+ await deletePod(instance, namespace, name);
485
+ if (key) {
486
+ const item = { id: () => key };
487
+ await instance.objectApi
488
+ .delete({
489
+ apiVersion: 'networking.k8s.io/v1',
490
+ kind: 'NetworkPolicy',
491
+ metadata: { name: (0, network_policy_1.getNetworkPolicyName)(item, { run: null }), namespace },
492
+ })
493
+ .catch((e) => ((0, apply_1.statusCodeOf)(e) === 404 ? undefined : Promise.reject(e)));
494
+ await (0, network_policy_1.removeSandboxProxies)(instance, namespace, { sandbox, item });
495
+ }
496
+ }
497
+ }
498
+ exports.removeSandboxRunners = removeSandboxRunners;
499
+ async function setPolicyOwners(instance, namespace, name, owners) {
500
+ await instance.objectApi.patch({
501
+ apiVersion: 'networking.k8s.io/v1',
502
+ kind: 'NetworkPolicy',
503
+ metadata: { name, namespace, ownerReferences: owners },
504
+ }, undefined, undefined, undefined, undefined, 'application/merge-patch+json');
505
+ }
506
+ // Runs a command in the runner and answers its exit code. Its output goes to the task's,
507
+ // line by line as it comes. On abort (cancel, timeout) every process of the command is
508
+ // killed, or the runner goes when that fails.
509
+ async function execInRunner(instance, namespace, pod,
510
+ // the directory of the run's sandbox, and the command's in it
511
+ root, cwd, command, scripts, task, abort) {
512
+ const { cleanUp } = scripts;
513
+ const output = { bytes: 0, cut: false };
514
+ let changed = false;
515
+ const lines = (stream) => {
516
+ let rest = '';
517
+ // a character split between two chunks stays whole
518
+ const decoder = new string_decoder_1.StringDecoder('utf8');
519
+ const writable = new stream_1.Writable({
520
+ write(chunk, _encoding, callback) {
521
+ if (output.bytes < MAX_OUTPUT_BYTES) {
522
+ output.bytes += chunk.length;
523
+ const parts = (rest + decoder.write(chunk)).split(/\r?\n/);
524
+ rest = parts.pop() ?? '';
525
+ for (const line of parts) {
526
+ if (stream === 'stderr' && line === RUNNER_CHANGED_MARK) {
527
+ changed = true;
528
+ continue;
529
+ }
530
+ task.status.console(stream, line);
531
+ }
532
+ }
533
+ else if (!output.cut) {
534
+ output.cut = true;
535
+ task.status.write('warn', `the output of ${task.name} was cut at ${MAX_OUTPUT_BYTES} bytes`);
536
+ }
537
+ callback();
538
+ },
539
+ final(callback) {
540
+ rest += decoder.end();
541
+ if (rest.length > 0) {
542
+ task.status.console(stream, rest);
543
+ }
544
+ callback();
545
+ },
546
+ });
547
+ return writable;
548
+ };
549
+ const stdout = lines('stdout');
550
+ const stderr = lines('stderr');
551
+ let status;
552
+ const socket = await instance.exec.exec(namespace, pod, RUNNER_CONTAINER, [BUSYBOX, 'sh', '-c', scripts.wrapper, 'hammerkit', RUNNER_CHANGED_MARK, root, cwd, ...command], stdout, stderr, null, false, (received) => {
553
+ status = received;
554
+ });
555
+ const closed = new Promise((resolve, reject) => {
556
+ socket.on('close', () => resolve());
557
+ socket.on('error', (e) => reject(e));
558
+ });
559
+ const kill = async () => {
560
+ try {
561
+ await killInRunner(instance, namespace, pod, cleanUp);
562
+ }
563
+ catch (e) {
564
+ task.status.write('debug', `the command could not be stopped in the runner, it goes: ${(0, log_1.getErrorMessage)(e)}`);
565
+ await deletePod(instance, namespace, pod).catch(() => undefined);
566
+ }
567
+ // what the command printed after it was killed is not waited for long
568
+ await Promise.race([closed.catch(() => undefined), (0, sleep_1.sleep)(KILL_TIMEOUT_MS)]);
569
+ socket.close();
570
+ };
571
+ const aborted = () => {
572
+ void kill();
573
+ };
574
+ if (abort.aborted) {
575
+ aborted();
576
+ }
577
+ else {
578
+ abort.addEventListener('abort', aborted, { once: true });
579
+ }
580
+ let broken;
581
+ try {
582
+ await closed.catch((e) => {
583
+ broken = e;
584
+ });
585
+ if (!abort.aborted && getExecExitCode(status) === undefined) {
586
+ // the connection broke while the command may still run: it ends here as on a cancel,
587
+ // before the run lets go of the sandbox
588
+ await kill();
589
+ }
590
+ }
591
+ finally {
592
+ abort.removeEventListener('abort', aborted);
593
+ for (const stream of [stdout, stderr]) {
594
+ if (!stream.writableEnded) {
595
+ stream.end();
596
+ }
597
+ }
598
+ await Promise.all([(0, promises_1.finished)(stdout), (0, promises_1.finished)(stderr)]).catch(() => undefined);
599
+ }
600
+ if (abort.aborted) {
601
+ return 137;
602
+ }
603
+ const exitCode = getExecExitCode(status);
604
+ if (exitCode === undefined) {
605
+ throw new Error(`the command in the runner of the sandbox ended without an exit code: ${status?.message ?? (broken ? (0, log_1.getErrorMessage)(broken) : 'the connection closed')}`);
606
+ }
607
+ return exitCode === exports.RUNNER_CHANGED_EXIT && changed ? 'changed' : exitCode;
608
+ }
609
+ async function killInRunner(instance, namespace, pod, cleanUp) {
610
+ let status;
611
+ const socket = await instance.exec.exec(namespace, pod, RUNNER_CONTAINER, [BUSYBOX, 'sh', '-c', cleanUp], null, null, null, false, (received) => {
612
+ status = received;
613
+ });
614
+ await Promise.race([
615
+ new Promise((resolve, reject) => {
616
+ socket.on('close', () => resolve());
617
+ socket.on('error', reject);
618
+ }),
619
+ (0, sleep_1.sleep)(KILL_TIMEOUT_MS).then(() => Promise.reject(new Error('the kill did not answer'))),
620
+ ]);
621
+ // what it answers does not matter, that it answered does
622
+ if (getExecExitCode(status) === undefined) {
623
+ throw new Error(status?.message ?? 'the kill did not end');
624
+ }
625
+ }
626
+ //# sourceMappingURL=sandbox-runner.js.map
@@ -34,6 +34,7 @@ const job_placement_1 = require("../kubernetes/job-placement");
34
34
  const network_policy_1 = require("../kubernetes/network-policy");
35
35
  const run_id_1 = require("../utils/run-id");
36
36
  const work_network_1 = require("./work-network");
37
+ const sandbox_runner_1 = require("../kubernetes/sandbox-runner");
37
38
  const work_secret_1 = require("./work-secret");
38
39
  async function listJobNames(instance, kubernetes, task) {
39
40
  const jobs = await instance.batchApi.listNamespacedJob({
@@ -188,6 +189,28 @@ function kubernetesTaskRuntime(task, kubernetes) {
188
189
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(task.data.envs);
189
190
  const persistence = await (0, volumes_1.getKubernetesPersistence)(task, kubernetes);
190
191
  const workspace = (0, volumes_1.getWorkspacePodSpec)(kubernetes, persistence, { name: task.name, pool: task.data.pool });
192
+ // a shell of a sandbox: its commands go into the sandbox's runner, when it has one now
193
+ if ((0, sandbox_runner_1.canUseRunner)(task, kubernetes, persistence)) {
194
+ try {
195
+ const result = await (0, sandbox_runner_1.executeInRunner)(instance, task, kubernetes, persistence, options.abort);
196
+ if (result.type === 'crash') {
197
+ options.state.set({ stateKey: options.stateKey, type: 'crash', exitCode: result.exitCode });
198
+ return;
199
+ }
200
+ // stopped while the runner started: no Job for it either
201
+ if (result.type === 'canceled' || options.abort.aborted) {
202
+ options.state.set({ stateKey: options.stateKey, type: 'canceled' });
203
+ return;
204
+ }
205
+ if (result.type === 'done') {
206
+ return;
207
+ }
208
+ }
209
+ catch (e) {
210
+ options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
211
+ return;
212
+ }
213
+ }
191
214
  await (0, ensure_namespace_1.ensureNamespace)(instance, kubernetes.namespace);
192
215
  try {
193
216
  // the task writes to it now: what it holds stops being the outputs of a state, and
package/dist/program.js CHANGED
@@ -608,6 +608,7 @@ async function getProgram(environment, argv) {
608
608
  .choices(['checksum', 'modify-date', 'none']))
609
609
  .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))
610
610
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
611
+ .addOption(new commander_1.Option('--max-timeout <duration>', 'fail any task after this long, also one with a longer timeout of its own (e.g. 2m)').argParser(units_1.parseDuration))
611
612
  .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))
612
613
  .action(async (options, command) => {
613
614
  try {
@@ -626,6 +627,7 @@ async function getProgram(environment, argv) {
626
627
  wait: options.wait,
627
628
  cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
628
629
  timeout: options.timeout ?? null,
630
+ maxTimeout: options.maxTimeout ?? null,
629
631
  network: options.network ?? null,
630
632
  });
631
633
  if (!result.success) {
@@ -688,6 +690,7 @@ async function getProgram(environment, argv) {
688
690
  .addOption(new commander_1.Option('--dry-run', 'print the execution plan with predicted cache hits/misses without running').default(false))
689
691
  .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))
690
692
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
693
+ .addOption(new commander_1.Option('--max-timeout <duration>', 'fail any task after this long, also one with a longer timeout of its own (e.g. 2m)').argParser(units_1.parseDuration))
691
694
  .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))
692
695
  .addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when nothing needing them has to run'))
693
696
  .action(async (task, options, command) => {
@@ -722,6 +725,7 @@ async function getProgram(environment, argv) {
722
725
  explain: options.explain,
723
726
  cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
724
727
  timeout: options.timeout ?? null,
728
+ maxTimeout: options.maxTimeout ?? null,
725
729
  network: options.network ?? null,
726
730
  skipDeps: options.skipDeps !== false,
727
731
  });
@@ -139,7 +139,8 @@ function registerWorkCommands(remote, context) {
139
139
  .addOption(new commander_1.Option('--commit <sha>', 'the commit the ref has to be at, else the run is refused; the commit checked out here when --ref is left out'))
140
140
  .addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
141
141
  .addOption(new commander_1.Option('--cache <method>', 'as --cache of run').choices(['checksum', 'modify-date', 'none']))
142
- .addOption(new commander_1.Option('--timeout <duration>', 'stop the run after this long, such as 30m'))
142
+ .addOption(new commander_1.Option('--timeout <duration>', 'as --timeout of run: for tasks without their own, such as 30m'))
143
+ .addOption(new commander_1.Option('--max-timeout <duration>', 'as --max-timeout of run: for every task, also one with its own timeout'))
143
144
  .action(guarded('Remote run was not successful', async (tasks, options) => {
144
145
  const named = options.build ?? environment.processEnvs.HAMMERKIT_BUILD;
145
146
  const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
@@ -160,7 +161,7 @@ function registerWorkCommands(remote, context) {
160
161
  const { name: serverName, server } = await getServer(options.server);
161
162
  const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, serverName, server) };
162
163
  const runOptions = {};
163
- for (const key of ['env', 'cache', 'timeout']) {
164
+ for (const key of ['env', 'cache', 'timeout', 'maxTimeout']) {
164
165
  if (options[key] !== undefined) {
165
166
  runOptions[key] = options[key];
166
167
  }
@@ -22,6 +22,8 @@ const POLICY_NAME = 'hammerkit';
22
22
  const MANAGED_LABEL = 'hammerkit.dev/managed';
23
23
  // how long the end of a run waits for its pods and claims to be gone
24
24
  const RELEASE_TIMEOUT = 120000;
25
+ // how long the setup of a caller's namespace is taken as it is, before it is applied again
26
+ const NAMESPACE_SETUP_REUSE_MS = 5 * 60000;
25
27
  // where a pod of the cluster finds the token and certificate of its ServiceAccount
26
28
  const IN_CLUSTER_NAMESPACE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace';
27
29
  /**
@@ -186,6 +188,9 @@ class KubernetesExecutor {
186
188
  this.sweeps = new Map();
187
189
  // whether the kept claims a server left when it stopped were looked for
188
190
  this.swept = false;
191
+ // the namespaces where no run of this process left anything: what a run makes goes at its
192
+ // end, unless its release failed
193
+ this.tidy = new Set();
189
194
  this.access = getClusterAccess(config.backend, processEnvs);
190
195
  this.instance = (0, kubernetes_instance_1.createKubernetesInstances)({
191
196
  type: 'kubernetes',
@@ -268,11 +273,14 @@ class KubernetesExecutor {
268
273
  throw new Error('the run was cancelled');
269
274
  }
270
275
  await this.namespaces.prepare(namespace);
271
- // what an earlier run left when the server stopped before its end: the objects of
272
- // a run that does not execute. One that starts after this waits for it here
273
- // before it makes any.
274
- const running = [...new Set([label, ...(this.active.get(namespace)?.keys() ?? [])])];
275
- await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL} notin (${running.join(',')})`, RELEASE_TIMEOUT);
276
+ // what an earlier run left when the server stopped before its end, or when its
277
+ // release failed: the objects of a run that does not execute. One that starts
278
+ // after this waits for it here before it makes any.
279
+ if (!this.tidy.has(namespace)) {
280
+ const running = [...new Set([label, ...(this.active.get(namespace)?.keys() ?? [])])];
281
+ await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL} notin (${running.join(',')})`, RELEASE_TIMEOUT);
282
+ this.tidy.add(namespace);
283
+ }
276
284
  // the claims earlier runs kept that expired; the others this run may mount
277
285
  const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
278
286
  now: new Date(),
@@ -302,7 +310,9 @@ class KubernetesExecutor {
302
310
  pools: backend.pools,
303
311
  providers: backend.providers,
304
312
  accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
305
- ...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace) } : {}),
313
+ ...(work?.claim
314
+ ? { workspace: getWorkspace(work.root, work.claim, namespace, backend.sandboxes.runner) }
315
+ : {}),
306
316
  ...(this.keep > 0 ? { volumeScope: getVolumeScope(run) } : {}),
307
317
  },
308
318
  };
@@ -315,6 +325,8 @@ class KubernetesExecutor {
315
325
  await removeRunObjects(this.instance, namespace, selector, RELEASE_TIMEOUT);
316
326
  }
317
327
  catch (e) {
328
+ // the next run removes what is left
329
+ this.tidy.delete(namespace);
318
330
  context.emit({
319
331
  type: 'log',
320
332
  task: 'hammerkit',
@@ -411,6 +423,8 @@ class CallerNamespaces {
411
423
  constructor(instance, backend) {
412
424
  this.instance = instance;
413
425
  this.backend = backend;
426
+ // the namespaces this process set up, by their uid, and when
427
+ this.applied = new Map();
414
428
  }
415
429
  name(owner) {
416
430
  return getCallerNamespace(this.backend.namespacePrefix, owner);
@@ -418,8 +432,10 @@ class CallerNamespaces {
418
432
  // The caller's namespace: made by the server and kept apart from the others. The
419
433
  // server binds its rights there, isolates the network of the pods, applies the
420
434
  // operator's quota and gives containers without resources default requests, so they
421
- // are admitted under a quota on compute. All of it is replaced on every run, so the
422
- // namespace follows the config.
435
+ // are admitted under a quota on compute. All of it is replaced when the server sets up
436
+ // a namespace it did not set up in the last `NAMESPACE_SETUP_REUSE_MS` (it reads the
437
+ // config once, at its start), so the namespace follows the config; a namespace made anew
438
+ // since (deleted and made again) is set up again at once.
423
439
  async prepare(namespace) {
424
440
  const backend = this.backend;
425
441
  let existing;
@@ -434,9 +450,19 @@ class CallerNamespaces {
434
450
  if (existing && existing.metadata?.labels?.[MANAGED_LABEL] !== 'true') {
435
451
  throw new Error(`the namespace ${namespace} was not made by hammerkit, the server does not run tasks in it`);
436
452
  }
453
+ const started = Date.now();
454
+ const applied = this.applied.get(namespace);
455
+ if (existing &&
456
+ !existing.metadata?.deletionTimestamp &&
457
+ applied &&
458
+ applied.uid === existing.metadata?.uid &&
459
+ started - applied.at < NAMESPACE_SETUP_REUSE_MS) {
460
+ return;
461
+ }
462
+ this.applied.delete(namespace);
437
463
  if (!existing) {
438
464
  try {
439
- await this.instance.coreApi.createNamespace({
465
+ existing = await this.instance.coreApi.createNamespace({
440
466
  body: {
441
467
  metadata: {
442
468
  name: namespace,
@@ -506,6 +532,10 @@ class CallerNamespaces {
506
532
  });
507
533
  }
508
534
  }
535
+ const uid = existing?.metadata?.uid;
536
+ if (uid) {
537
+ this.applied.set(namespace, { uid, at: started });
538
+ }
509
539
  }
510
540
  whoAmI() {
511
541
  if (!this.subject) {
@@ -528,7 +558,7 @@ class CallerNamespaces {
528
558
  exports.CallerNamespaces = CallerNamespaces;
529
559
  // The claim of a sandbox as the Jobs of its run mount it: the run's copy of the files is
530
560
  // `root`, a source below it is the same path below `subPath` in the claim.
531
- function getWorkspace(root, claim, namespace) {
561
+ function getWorkspace(root, claim, namespace, runner) {
532
562
  if (claim.namespace !== namespace) {
533
563
  throw new Error(`the sandbox is in ${claim.namespace}, the run in ${namespace}`);
534
564
  }
@@ -538,6 +568,7 @@ function getWorkspace(root, claim, namespace) {
538
568
  subPath: claim.subPath,
539
569
  label: claim.label,
540
570
  ...(claim.pod ? { pod: claim.pod } : {}),
571
+ ...(runner ? { runner: true } : {}),
541
572
  };
542
573
  }
543
574
  // Everything a run made in the caller's namespace, by the label selector of the run:
@@ -11,6 +11,7 @@ const tar_2 = require("../utils/tar");
11
11
  const apply_1 = require("../kubernetes/apply");
12
12
  const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
13
13
  const network_policy_1 = require("../kubernetes/network-policy");
14
+ const sandbox_runner_1 = require("../kubernetes/sandbox-runner");
14
15
  const volumes_1 = require("../kubernetes/volumes");
15
16
  const log_1 = require("../log");
16
17
  const sleep_1 = require("../utils/sleep");
@@ -284,6 +285,8 @@ class KubernetesSandboxStorage {
284
285
  // runs on a sandbox go one at a time: a proxy the pod of the files does not own was
285
286
  // left by a run before (the server stopped, or its release could not remove it)
286
287
  await (0, network_policy_1.removeSandboxProxies)(this.instance, namespace, { sandbox: names.label }, (deployment) => isOwnedBy(deployment, pod.uid)).catch(() => undefined);
288
+ // and a runner, which the pod did not own when it made way for a run
289
+ await (0, sandbox_runner_1.removeSandboxRunners)(this.instance, namespace, names.label, (runner) => isOwnedBy(runner, pod.uid)).catch(() => undefined);
287
290
  await (0, promises_1.mkdir)(into, { recursive: true });
288
291
  await this.copyOut(key, ['tar', 'cf', '-', '-C', `${CLAIM_PATH}/${key.id}/files`, '.'], into,
289
292
  // what the run plans with: files, directories and links, nothing outside of it
@@ -388,7 +391,8 @@ class KubernetesSandboxStorage {
388
391
  async removeIn(namespace, id) {
389
392
  const names = getSandboxNames(id);
390
393
  await this.stopPod({ id, owner: { iss: '', sub: '' } }, namespace);
391
- // the cluster removes the proxies the pod owned; those it did not own go here
394
+ // the cluster removes the proxies and the runner the pod owned; those it did not own go here
395
+ await (0, sandbox_runner_1.removeSandboxRunners)(this.instance, namespace, names.label);
392
396
  await (0, network_policy_1.removeSandboxProxies)(this.instance, namespace, { sandbox: names.label });
393
397
  for (const name of [names.claim, names.git]) {
394
398
  try {
@@ -38,6 +38,8 @@ exports.runOptionsSchema = zod_1.z
38
38
  cacheReadOnly: zod_1.z.boolean().optional(),
39
39
  // as --timeout
40
40
  timeout: duration.optional(),
41
+ // as --max-timeout: caps every task, also one with a longer timeout of its own
42
+ maxTimeout: duration.optional(),
41
43
  // as --no-skip-deps when false
42
44
  skipDeps: zod_1.z.boolean().optional(),
43
45
  // as the text after `--`: appended to the last command of the one task the run
@@ -120,6 +120,7 @@ cancelled, emit) {
120
120
  cacheDefault: options.cache ?? 'checksum',
121
121
  cacheReadOnly: options.cacheReadOnly ?? false,
122
122
  timeout: options.timeout ? (0, units_1.parseDuration)(options.timeout) : null,
123
+ maxTimeout: options.maxTimeout ? (0, units_1.parseDuration)(options.maxTimeout) : null,
123
124
  skipDeps: options.skipDeps !== false,
124
125
  network: getRunNetwork(input),
125
126
  // why a task was not taken from the cache goes into its result
@@ -188,8 +188,12 @@ const kubernetesSandboxesSchema = zod_1.z
188
188
  // when left out. With a class of their own, a ResourceQuota on that class in each
189
189
  // caller's namespace holds `sandboxes.limits`.
190
190
  storageClass: zod_1.z.string().min(1).optional(),
191
- // how long the pod of a sandbox's files stays after the last request
191
+ // how long the pod of a sandbox's files stays after the last request; the runner of the
192
+ // sandbox goes with it
192
193
  idle: duration.default('1m'),
194
+ // exec the commands of a task shaped like a shell (it mounts the sandbox only, has no
195
+ // needs, outputs or secrets) into one pod per sandbox, in place of a Job each
196
+ runner: zod_1.z.boolean().default(true),
193
197
  // of the pod of a sandbox's files, which runs git and ripgrep over all of them
194
198
  resources: zod_1.z
195
199
  .object({ requests: quantitiesSchema.optional(), limits: quantitiesSchema.optional() })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.27.0-next.11",
4
+ "version": "1.27.0-next.13",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",