hammerkit 1.7.0 → 1.9.0

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 (53) hide show
  1. package/README.md +3 -3
  2. package/build.schema.json +19 -13
  3. package/dist/docker/package.js +13 -3
  4. package/dist/docker/remove-orphaned-containers.js +53 -0
  5. package/dist/docker/run-labels.js +14 -0
  6. package/dist/docker/using-container.js +25 -26
  7. package/dist/executer/check-readiness.js +4 -4
  8. package/dist/executer/container-task-state.js +34 -0
  9. package/dist/executer/docker-service.js +23 -6
  10. package/dist/executer/docker-task.js +26 -4
  11. package/dist/executer/event-cache.js +8 -0
  12. package/dist/executer/execute-command.js +36 -6
  13. package/dist/executer/execute-docker.js +18 -2
  14. package/dist/executer/execute-work-service.js +4 -0
  15. package/dist/executer/execute-work-task.js +17 -2
  16. package/dist/executer/execution-steps.js +3 -8
  17. package/dist/executer/get-container-binds.js +13 -1
  18. package/dist/executer/main-process-user.js +52 -0
  19. package/dist/index.js +15 -3
  20. package/dist/kubernetes/apply.js +6 -7
  21. package/dist/kubernetes/await-running-state.js +48 -44
  22. package/dist/kubernetes/ensure-http-route.js +7 -10
  23. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +27 -6
  24. package/dist/kubernetes/ensure-namespace.js +9 -10
  25. package/dist/kubernetes/ensure-persistent-data.js +6 -5
  26. package/dist/kubernetes/get-service-ip.js +2 -2
  27. package/dist/kubernetes/kubernetes-instance.js +0 -4
  28. package/dist/kubernetes/remove-persistent-data.js +16 -6
  29. package/dist/kubernetes/resolve-pod-name.js +7 -7
  30. package/dist/planner/unreferenced-build-file-envs.js +35 -0
  31. package/dist/planner/utils/append-work-environment.js +1 -1
  32. package/dist/planner/utils/append-work-service.js +26 -2
  33. package/dist/planner/utils/get-outputs-to-reset.js +20 -0
  34. package/dist/planner/utils/parse-work-generate.js +2 -2
  35. package/dist/planner/validate.js +10 -0
  36. package/dist/planner/work-healthcheck.js +8 -0
  37. package/dist/planner/work-instance-id.js +3 -3
  38. package/dist/planner/work-runtime-docker.js +19 -19
  39. package/dist/planner/work-runtime-kubernetes.js +66 -48
  40. package/dist/planner/work-runtime-local.js +22 -5
  41. package/dist/planner/work-service-id.js +3 -0
  42. package/dist/planner/work-service.js +27 -1
  43. package/dist/program.js +6 -6
  44. package/dist/schema/build-file-container-service-schema.js +8 -0
  45. package/dist/schema/build-file-environment-schema-http-route.js +15 -0
  46. package/dist/schema/build-file-environment-schema.js +8 -2
  47. package/dist/schema/build-file-schema.js +1 -1
  48. package/dist/schema/build-file-service-container-healthcheck.js +4 -0
  49. package/dist/schema/envs-schema.js +1 -1
  50. package/dist/utils/abort-on-signals.js +17 -0
  51. package/package.json +9 -9
  52. package/dist/kubernetes/ensure-ingress.js +0 -54
  53. package/dist/schema/build-file-environment-schema-ingress.js +0 -14
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # About
2
2
 
3
- <figure><img src="docs/hammerkit-small.png" width="144" alt=""><figcaption></figcaption></figure>
3
+ <img src="website/public/logo.png" width="144" alt="hammerkit">
4
4
 
5
5
  ## What is hammerkit?
6
6
 
@@ -9,7 +9,7 @@ Hammerkit is a build tool intended to build software projects in **containers**
9
9
  * the build tools come from the container image, so they are the same across all machines.
10
10
  * the build process is isolated since every file in the container has to be declared as a source or an output. Therefore, side effects can be reduced.
11
11
 
12
- For a fuller pitch — including how it compares to Make, npm scripts, Taskfile and Earthly — see [Why hammerkit](docs/why-hammerkit.md).
12
+ For a fuller pitch — including how it compares to Make, npm scripts, Taskfile and Earthly — see [Why hammerkit](https://hammerkit.dev/docs/why-hammerkit).
13
13
 
14
14
  There are a lot of containerized CI systems that allow writing containerized builds today, but they have shortcomings that hammerkit tries to address:
15
15
 
@@ -23,4 +23,4 @@ Hammerkit, on the other hand, knows the source and output files of each build st
23
23
 
24
24
  ## Where to go next?
25
25
 
26
- Check out the [docs](https://no0dles.gitbook.io/hammerkit/) for more info about how hammerkit can be used.
26
+ Check out the [docs on hammerkit.dev](https://hammerkit.dev) for more info about how hammerkit can be used.
package/build.schema.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "number"
13
13
  ]
14
14
  },
15
- "description": "Environment values for the current build task\nhttps://no0dles.gitbook.io/hammerkit/build-file/environment-variables"
15
+ "description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
16
16
  },
17
17
  "tasks": {
18
18
  "type": "object",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "envs": {
63
63
  "$ref": "#/definitions/hammerkit/properties/envs",
64
- "description": "Environment values for the current build task\nhttps://no0dles.gitbook.io/hammerkit/build-file/environment-variables"
64
+ "description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
65
65
  },
66
66
  "labels": {
67
67
  "type": "object",
@@ -280,7 +280,7 @@
280
280
  },
281
281
  "envs": {
282
282
  "$ref": "#/definitions/hammerkit/properties/envs",
283
- "description": "Environment values for the current build task\nhttps://no0dles.gitbook.io/hammerkit/build-file/environment-variables"
283
+ "description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
284
284
  },
285
285
  "mounts": {
286
286
  "type": "array",
@@ -309,6 +309,12 @@
309
309
  "cmd": {
310
310
  "type": "string"
311
311
  },
312
+ "workdir": {
313
+ "type": "string"
314
+ },
315
+ "shell": {
316
+ "type": "string"
317
+ },
312
318
  "volumes": {
313
319
  "type": "array",
314
320
  "items": {
@@ -326,6 +332,9 @@
326
332
  "properties": {
327
333
  "cmd": {
328
334
  "type": "string"
335
+ },
336
+ "timeout": {
337
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/timeout"
329
338
  }
330
339
  },
331
340
  "required": [
@@ -431,18 +440,11 @@
431
440
  "kubeconfig": {
432
441
  "type": "string"
433
442
  },
434
- "ingresses": {
443
+ "httpRoutes": {
435
444
  "type": "array",
436
445
  "items": {
437
446
  "type": "object",
438
447
  "properties": {
439
- "kind": {
440
- "type": "string",
441
- "enum": [
442
- "ingress",
443
- "httproute"
444
- ]
445
- },
446
448
  "host": {
447
449
  "type": "string"
448
450
  },
@@ -464,10 +466,14 @@
464
466
  },
465
467
  "required": [
466
468
  "host",
467
- "service"
469
+ "service",
470
+ "gateway"
468
471
  ],
469
472
  "additionalProperties": false
470
473
  }
474
+ },
475
+ "ingresses": {
476
+ "not": {}
471
477
  }
472
478
  },
473
479
  "required": [
@@ -608,7 +614,7 @@
608
614
  }
609
615
  },
610
616
  "additionalProperties": false,
611
- "description": "Build file with support for containerization\nhttps://no0dles.gitbook.io/hammerkit/build-file"
617
+ "description": "Build file with support for containerization\nhttps://hammerkit.dev/docs/build-file"
612
618
  }
613
619
  },
614
620
  "$schema": "http://json-schema.org/draft-07/schema#"
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.longestCommonPrefix = exports.packageWorkTree = exports.getDefaultPlatform = void 0;
3
+ exports.getServiceInstructions = exports.longestCommonPrefix = exports.packageWorkTree = exports.getDefaultPlatform = void 0;
4
4
  const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
5
5
  const work_item_1 = require("../planner/work-item");
6
+ const work_service_1 = require("../planner/work-service");
6
7
  const execute_docker_1 = require("../executer/execute-docker");
7
8
  const node_os_1 = require("node:os");
8
9
  const path_1 = require("path");
@@ -199,9 +200,17 @@ function getServiceInstructions(service, options) {
199
200
  ? service.data.volumes.map((v) => `RUN chown -R 1000:1000 ${toContainerPath((0, path_1.relative)(dependencyCwd, v.containerPath))}`)
200
201
  : []),
201
202
  options.overrideUser ? 'USER 1000:1000' : '',
203
+ // last, so the COPY destinations above stay relative to the build directory
204
+ service.data.workdir ? `WORKDIR ${service.data.workdir}` : '',
202
205
  ];
203
- if (service.data.cmd) {
204
- instructions.push(`CMD [${[service.data.cmd.parsed.command, ...service.data.cmd.parsed.args].map((p) => `"${p}"`).join(', ')}]`);
206
+ // exec form, JSON-encoded so arguments with quotes or a whole shell command
207
+ // survive intact
208
+ const command = (0, work_service_1.getServiceCommand)(service.data);
209
+ if (command.entrypoint) {
210
+ instructions.push(`ENTRYPOINT ${JSON.stringify(command.entrypoint)}`);
211
+ }
212
+ if (command.cmd) {
213
+ instructions.push(`CMD ${JSON.stringify(command.cmd)}`);
205
214
  }
206
215
  return {
207
216
  id: service.id(),
@@ -221,6 +230,7 @@ function getServiceInstructions(service, options) {
221
230
  ],
222
231
  };
223
232
  }
233
+ exports.getServiceInstructions = getServiceInstructions;
224
234
  function getDependencyInstructions(cwd, task, taskIds) {
225
235
  return task.deps.map((dep) => {
226
236
  if (taskIds[dep.id()]) {
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.removeOrphanedContainers = exports.isProcessAlive = exports.isOrphanedContainer = void 0;
4
+ const os_1 = require("os");
5
+ const remove_container_1 = require("./remove-container");
6
+ // A hammerkit container nothing will remove any more: a task container (they
7
+ // never outlive their run) or a service of a run that is gone, started by a
8
+ // hammerkit process of this machine that no longer exists, and the paused
9
+ // state records hammerkit kept before 1.9. Services of `up --daemon` and
10
+ // containers of other machines or of running processes stay.
11
+ function isOrphanedContainer(container, isProcessAlive) {
12
+ const labels = container.Labels;
13
+ const type = labels['hammerkit-type'];
14
+ if (type === 'task' && container.State === 'paused') {
15
+ return true;
16
+ }
17
+ if (type !== 'task' && type !== 'service') {
18
+ return false;
19
+ }
20
+ if (labels['hammerkit-daemon'] === 'true') {
21
+ return false;
22
+ }
23
+ if (labels['hammerkit-host'] !== (0, os_1.hostname)()) {
24
+ return false;
25
+ }
26
+ const pid = parseInt(labels['hammerkit-pid'] ?? '', 10);
27
+ if (!pid) {
28
+ return false;
29
+ }
30
+ return !isProcessAlive(pid);
31
+ }
32
+ exports.isOrphanedContainer = isOrphanedContainer;
33
+ function isProcessAlive(pid) {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch (e) {
39
+ // EPERM: it exists, it just belongs to another user
40
+ return e.code === 'EPERM';
41
+ }
42
+ }
43
+ exports.isProcessAlive = isProcessAlive;
44
+ async function removeOrphanedContainers(docker) {
45
+ const containers = await docker.listContainers({ all: true, filters: { label: ['app=hammerkit'] } });
46
+ const orphaned = containers.filter((c) => isOrphanedContainer(c, isProcessAlive));
47
+ for (const container of orphaned) {
48
+ await (0, remove_container_1.removeContainer)(docker.getContainer(container.Id));
49
+ }
50
+ return orphaned.map((c) => c.Id);
51
+ }
52
+ exports.removeOrphanedContainers = removeOrphanedContainers;
53
+ //# sourceMappingURL=remove-orphaned-containers.js.map
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getRunLabels = void 0;
4
+ const os_1 = require("os");
5
+ // Which hammerkit process on which machine started a container. `clean` uses
6
+ // them to tell a container a killed run left behind from one still in use.
7
+ function getRunLabels() {
8
+ return {
9
+ 'hammerkit-pid': process.pid.toString(),
10
+ 'hammerkit-host': (0, os_1.hostname)(),
11
+ };
12
+ }
13
+ exports.getRunLabels = getRunLabels;
14
+ //# sourceMappingURL=run-labels.js.map
@@ -5,40 +5,23 @@ const remove_container_1 = require("./remove-container");
5
5
  const log_1 = require("../log");
6
6
  const execute_docker_1 = require("../executer/execute-docker");
7
7
  const work_instance_id_1 = require("../planner/work-instance-id");
8
- async function usingContainer(docker, item, createOptions, stateKey, callback) {
8
+ const remove_orphaned_containers_1 = require("./remove-orphaned-containers");
9
+ // Runs the callback in a fresh container and removes it afterwards, whatever
10
+ // the outcome. What state the outputs hold is recorded outside docker (see
11
+ // container-task-state.ts), so no container outlives the run.
12
+ async function usingContainer(docker, item, createOptions, callback) {
13
+ await removeLeftoverContainers(docker, item);
9
14
  let container = null;
10
- let succeeded = false;
11
- let result;
12
15
  try {
13
16
  container = await docker.createContainer(createOptions);
14
17
  item.status.write('debug', `starting container with image ${item.data.image}`);
15
18
  await (0, execute_docker_1.startContainer)(item.status, container);
16
- result = await callback(container);
17
- succeeded = result !== false;
18
- return result;
19
+ return await callback(container);
19
20
  }
20
21
  finally {
21
22
  try {
22
- if (stateKey && succeeded) {
23
- if (container) {
24
- await container.pause();
25
- }
26
- const containers = await docker.listContainers({
27
- all: true,
28
- filters: { label: [`hammerkit-id=${(0, work_instance_id_1.getWorkInstanceId)(item)}`] },
29
- });
30
- for (const container of containers) {
31
- item.status.write('debug', `found container ${container.Id}`);
32
- if (container.Labels['hammerkit-state'] != stateKey) {
33
- const oldContainer = docker.getContainer(container.Id);
34
- await (0, remove_container_1.removeContainer)(oldContainer);
35
- }
36
- }
37
- }
38
- else {
39
- if (container) {
40
- await (0, remove_container_1.removeContainer)(container);
41
- }
23
+ if (container) {
24
+ await (0, remove_container_1.removeContainer)(container);
42
25
  }
43
26
  }
44
27
  catch (e) {
@@ -47,4 +30,20 @@ async function usingContainer(docker, item, createOptions, stateKey, callback) {
47
30
  }
48
31
  }
49
32
  exports.usingContainer = usingContainer;
33
+ // Containers of this item nothing will remove: the paused state records
34
+ // hammerkit kept before 1.9, and stopped ones of a hammerkit process that is
35
+ // gone. A container of a live process (one another call of this run has just
36
+ // created, say) stays.
37
+ async function removeLeftoverContainers(docker, item) {
38
+ const containers = await docker.listContainers({
39
+ all: true,
40
+ filters: { label: [`hammerkit-id=${(0, work_instance_id_1.getWorkInstanceId)(item)}`] },
41
+ });
42
+ const leftovers = containers.filter((c) => c.State === 'paused' ||
43
+ (c.State !== 'running' && !(0, remove_orphaned_containers_1.isProcessAlive)(parseInt(c.Labels['hammerkit-pid'] ?? '', 10) || process.pid)));
44
+ for (const leftover of leftovers) {
45
+ item.status.write('debug', `remove leftover container ${leftover.Id}`);
46
+ await (0, remove_container_1.removeContainer)(docker.getContainer(leftover.Id));
47
+ }
48
+ }
50
49
  //# sourceMappingURL=using-container.js.map
@@ -2,8 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.checkReadiness = void 0;
4
4
  const execute_docker_1 = require("./execute-docker");
5
- async function checkReadiness(status, healthCheck, environment, container, abort) {
6
- const result = await (0, execute_docker_1.execCommand)(status, environment, container, undefined, [healthCheck.cmd.parsed.command, ...healthCheck.cmd.parsed.args], null, 2000, abort);
5
+ async function checkReadiness(status, command, environment, container, user, abort) {
6
+ const result = await (0, execute_docker_1.execCommand)(status, environment, container, undefined, command, user, 2000, abort);
7
7
  if (result.type === 'timeout') {
8
8
  return false;
9
9
  }
@@ -12,11 +12,11 @@ async function checkReadiness(status, healthCheck, environment, container, abort
12
12
  }
13
13
  else {
14
14
  if (result.result.ExitCode === 0) {
15
- status.write('debug', `healthcheck ${healthCheck.cmd} succeeded`);
15
+ status.write('debug', `healthcheck ${command.join(' ')} succeeded`);
16
16
  return true;
17
17
  }
18
18
  else {
19
- status.write('debug', `healthcheck ${healthCheck.cmd} failed with ${result.result.ExitCode}`);
19
+ status.write('debug', `healthcheck ${command.join(' ')} failed with ${result.result.ExitCode}`);
20
20
  return false;
21
21
  }
22
22
  }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.removeContainerTaskState = exports.writeContainerTaskState = exports.readContainerTaskState = exports.getContainerTaskStateFile = void 0;
4
+ const path_1 = require("path");
5
+ const get_cache_directory_1 = require("../optimizer/get-cache-directory");
6
+ const work_instance_id_1 = require("../planner/work-instance-id");
7
+ // The record says "the outputs of this container task belong to this state
8
+ // key", like the state file of a local task. It lives in the hammerkit
9
+ // directory, not the project, so writing it never touches a task's src.
10
+ function getContainerTaskStateFile(task) {
11
+ return (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'state', (0, work_instance_id_1.getWorkInstanceId)(task));
12
+ }
13
+ exports.getContainerTaskStateFile = getContainerTaskStateFile;
14
+ async function readContainerTaskState(environment, task) {
15
+ const stateFile = getContainerTaskStateFile(task);
16
+ if (!(await environment.file.exists(stateFile))) {
17
+ return null;
18
+ }
19
+ return (await environment.file.read(stateFile)).trim() || null;
20
+ }
21
+ exports.readContainerTaskState = readContainerTaskState;
22
+ async function writeContainerTaskState(environment, task, stateKey) {
23
+ await environment.file.createDirectory((0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'state'));
24
+ await environment.file.writeFile(getContainerTaskStateFile(task), stateKey);
25
+ }
26
+ exports.writeContainerTaskState = writeContainerTaskState;
27
+ async function removeContainerTaskState(environment, task) {
28
+ const stateFile = getContainerTaskStateFile(task);
29
+ if (await environment.file.exists(stateFile)) {
30
+ await environment.file.remove(stateFile);
31
+ }
32
+ }
33
+ exports.removeContainerTaskState = removeContainerTaskState;
34
+ //# sourceMappingURL=container-task-state.js.map
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.dockerService = void 0;
4
+ const work_service_1 = require("../planner/work-service");
5
+ const run_labels_1 = require("../docker/run-labels");
4
6
  const abort_1 = require("./abort");
5
7
  const execute_docker_1 = require("./execute-docker");
6
8
  const stream_1 = require("../docker/stream");
@@ -8,11 +10,14 @@ const abort_event_1 = require("../utils/abort-event");
8
10
  const log_1 = require("../log");
9
11
  const remove_container_1 = require("../docker/remove-container");
10
12
  const check_readiness_1 = require("./check-readiness");
13
+ const main_process_user_1 = require("./main-process-user");
11
14
  const execution_steps_1 = require("./execution-steps");
12
15
  const docker_task_1 = require("./docker-task");
13
16
  const replace_env_variables_1 = require("../environment/replace-env-variables");
14
17
  const get_service_containers_1 = require("./get-service-containers");
15
18
  const work_instance_id_1 = require("../planner/work-instance-id");
19
+ const work_healthcheck_1 = require("../planner/work-healthcheck");
20
+ const HEALTHCHECK_INTERVAL_MS = 1000;
16
21
  async function dockerService(docker, item, options, environment) {
17
22
  let container = null;
18
23
  try {
@@ -26,22 +31,25 @@ async function dockerService(docker, item, options, environment) {
26
31
  const network = (0, docker_task_1.getNeedsNetwork)(serviceContainers, item.needs);
27
32
  item.status.write('debug', `create container with image ${item.data.image}`);
28
33
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(item.data.envs);
34
+ const command = (0, work_service_1.getServiceCommand)(item.data);
29
35
  container = await docker.createContainer({
30
36
  Image: item.data.image,
31
37
  Env: Object.keys(envs).map((k) => `${k}=${envs[k]}`),
32
38
  Labels: {
33
39
  app: 'hammerkit',
34
40
  'hammerkit-id': (0, work_instance_id_1.getWorkInstanceId)(item),
35
- 'hammerkit-pid': process.pid.toString(),
41
+ ...(0, run_labels_1.getRunLabels)(),
36
42
  'hammerkit-type': 'service',
37
43
  'hammerkit-state': options.stateKey,
44
+ 'hammerkit-daemon': options.daemon ? 'true' : 'false',
38
45
  },
39
46
  ExposedPorts: item.data.ports.reduce((map, port) => {
40
47
  map[`${port.containerPort}/tcp`] = {};
41
48
  return map;
42
49
  }, {}),
43
- Cmd: item.data.cmd ? [item.data.cmd.parsed.command, ...item.data.cmd.parsed.args] : undefined,
44
- WorkingDir: item.data.cwd ? (0, execute_docker_1.convertToPosixPath)(item.data.cwd) : undefined,
50
+ Entrypoint: command.entrypoint ?? undefined,
51
+ Cmd: command.cmd ?? undefined,
52
+ WorkingDir: (0, execute_docker_1.convertToPosixPath)((0, work_service_1.getServiceWorkingDir)(item.data)),
45
53
  HostConfig: {
46
54
  ExtraHosts: network.hosts,
47
55
  Links: network.links,
@@ -70,11 +78,20 @@ async function dockerService(docker, item, options, environment) {
70
78
  });
71
79
  }
72
80
  else {
81
+ const healthcheck = item.data.healthcheck;
82
+ const deadline = Date.now() + healthcheck.timeout;
73
83
  let ready = false;
74
84
  do {
75
- ready = await (0, check_readiness_1.checkReadiness)(item.status, item.data.healthcheck, environment, container, options.abort);
76
- if (!ready) {
77
- await new Promise((resolve) => setTimeout(() => resolve(), 1000));
85
+ // The first check waits one interval too, so an entrypoint that drops
86
+ // privileges (`exec su-exec app …`) has done so before anything runs.
87
+ await new Promise((resolve) => setTimeout(() => resolve(), HEALTHCHECK_INTERVAL_MS));
88
+ if (options.abort.aborted) {
89
+ break;
90
+ }
91
+ const user = await (0, main_process_user_1.getMainProcessUser)(container);
92
+ ready = await (0, check_readiness_1.checkReadiness)(item.status, (0, work_service_1.getHealthcheckCommand)(item.data) ?? [], environment, container, user, options.abort);
93
+ if (!ready && Date.now() >= deadline) {
94
+ throw new Error((0, work_healthcheck_1.getHealthcheckTimeoutMessage)(item.name, healthcheck));
78
95
  }
79
96
  } while (!ready && !options.abort.aborted);
80
97
  if (ready) {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.dockerTask = exports.getNeedsNetwork = void 0;
3
+ exports.dockerTask = exports.buildCreateOptions = exports.getNeedsNetwork = void 0;
4
+ const run_labels_1 = require("../docker/run-labels");
4
5
  const service_dns_1 = require("./service-dns");
5
6
  const execute_docker_1 = require("./execute-docker");
6
7
  const abort_1 = require("./abort");
@@ -13,6 +14,8 @@ const replace_env_variables_1 = require("../environment/replace-env-variables");
13
14
  const get_container_binds_1 = require("./get-container-binds");
14
15
  const get_service_containers_1 = require("./get-service-containers");
15
16
  const work_instance_id_1 = require("../planner/work-instance-id");
17
+ const get_outputs_to_reset_1 = require("../planner/utils/get-outputs-to-reset");
18
+ const container_task_state_1 = require("./container-task-state");
16
19
  function getNeedsNetwork(serviceContainers, needs) {
17
20
  const links = [];
18
21
  const hosts = [];
@@ -35,18 +38,22 @@ function buildCreateOptions(item, stateKey, serviceContainers, environment) {
35
38
  const network = getNeedsNetwork(serviceContainers, item.needs);
36
39
  const binds = (0, get_container_binds_1.getContainerBinds)(item);
37
40
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(item.data.envs);
41
+ // Running as the host's uid (Linux), the user has no home in the image and
42
+ // Docker sets HOME=/, which isn't writable: tools then fail to create their
43
+ // caches. Set on the container only, so it's not part of the cache key.
44
+ const home = item.data.user && !('HOME' in envs) ? { HOME: '/tmp' } : {};
38
45
  return {
39
46
  abortSignal: environment.abortCtrl.signal,
40
47
  Image: item.data.image,
41
48
  Tty: true,
42
49
  Entrypoint: [item.data.shell],
43
50
  Cmd: ['-c', 'sleep 3600'],
44
- Env: Object.entries(envs).map(([key, value]) => `${key}=${value}`),
51
+ Env: Object.entries({ ...envs, ...home }).map(([key, value]) => `${key}=${value}`),
45
52
  WorkingDir: (0, execute_docker_1.convertToPosixPath)(item.data.cwd),
46
53
  Labels: {
47
54
  app: 'hammerkit',
48
55
  'hammerkit-id': (0, work_instance_id_1.getWorkInstanceId)(item),
49
- 'hammerkit-pid': process.pid.toString(),
56
+ ...(0, run_labels_1.getRunLabels)(),
50
57
  'hammerkit-type': 'task',
51
58
  'hammerkit-state': stateKey,
52
59
  },
@@ -58,9 +65,18 @@ function buildCreateOptions(item, stateKey, serviceContainers, environment) {
58
65
  },
59
66
  };
60
67
  }
68
+ exports.buildCreateOptions = buildCreateOptions;
61
69
  async function dockerTask(docker, item, environment, options) {
62
70
  item.status.write('info', `execute ${item.name} in container`);
63
71
  try {
72
+ // A run that fails overwrites outputs, so the previous record goes first,
73
+ // and only a run that succeeded writes a new one.
74
+ await (0, container_task_state_1.removeContainerTaskState)(environment, item);
75
+ const outputsToReset = (0, get_outputs_to_reset_1.getOutputsToReset)(item.data);
76
+ // outputs on the host: file outputs (bind mounts) and exported directories
77
+ for (const generate of outputsToReset.filter((g) => g.isFile || g.export)) {
78
+ await environment.file.remove(generate.path);
79
+ }
64
80
  await (0, execution_steps_1.prepareMounts)(item, environment);
65
81
  (0, abort_1.checkForAbort)(options.abort);
66
82
  await (0, execution_steps_1.pullImage)(item, docker);
@@ -70,8 +86,11 @@ async function dockerTask(docker, item, environment, options) {
70
86
  const serviceContainers = (0, get_service_containers_1.getServiceContainers)(item.needs);
71
87
  const containerOptions = buildCreateOptions(item, options.stateKey, serviceContainers, environment);
72
88
  (0, print_container_options_1.printContainerOptions)(item.status, containerOptions);
73
- await (0, using_container_1.usingContainer)(docker, item, containerOptions, options.stateKey, async (container) => {
89
+ const succeeded = await (0, using_container_1.usingContainer)(docker, item, containerOptions, async (container) => {
74
90
  await (0, execution_steps_1.setUserPermissions)(item, container, environment);
91
+ for (const generate of outputsToReset.filter((g) => !g.isFile)) {
92
+ await (0, execute_docker_1.clearContainerDirectory)(item.status, environment, container, generate.path);
93
+ }
75
94
  for (const cmd of item.data.cmds) {
76
95
  (0, abort_1.checkForAbort)(options.abort);
77
96
  item.status.write('info', `execute cmd "${cmd.cmd}" in container`);
@@ -112,6 +131,9 @@ async function dockerTask(docker, item, environment, options) {
112
131
  }
113
132
  return true;
114
133
  });
134
+ if (succeeded) {
135
+ await (0, container_task_state_1.writeContainerTaskState)(environment, item, options.stateKey);
136
+ }
115
137
  }
116
138
  catch (e) {
117
139
  if (e instanceof abort_1.AbortError) {
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.cleanCache = exports.storeCache = exports.getArchivePaths = exports.restoreCache = void 0;
4
+ const remove_orphaned_containers_1 = require("../docker/remove-orphaned-containers");
5
+ const execute_docker_1 = require("./execute-docker");
4
6
  const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
5
7
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
6
8
  const path_1 = require("path");
@@ -90,6 +92,12 @@ async function cleanCache(workTree, environment, options) {
90
92
  await service.data.caching.backend.clear(service.id(), environment);
91
93
  }
92
94
  }
95
+ if (workTree.environment.type === 'docker') {
96
+ const removed = await (0, remove_orphaned_containers_1.removeOrphanedContainers)((0, execute_docker_1.getContainerCli)(workTree.environment));
97
+ for (const id of removed) {
98
+ environment.console.info(`removed container ${id.substring(0, 12)} left behind by a stopped run`);
99
+ }
100
+ }
93
101
  }
94
102
  exports.cleanCache = cleanCache;
95
103
  //# sourceMappingURL=event-cache.js.map
@@ -6,6 +6,12 @@ const os_1 = require("os");
6
6
  const log_1 = require("../log");
7
7
  const abort_1 = require("./abort");
8
8
  const abort_event_1 = require("../utils/abort-event");
9
+ const environment_config_1 = require("../utils/environment-config");
10
+ // how long a local command's process group gets to exit after SIGTERM before
11
+ // it is killed
12
+ function getStopTimeout() {
13
+ return (0, environment_config_1.getEnvironmentConfig)('HAMMERKIT_STOP_TIMEOUT_MS', 10000);
14
+ }
9
15
  async function executeCommand(status, abortSignal, cwd, command, envs, environment) {
10
16
  const windows = (0, os_1.platform)() === 'win32';
11
17
  return new Promise((resolve, reject) => {
@@ -48,14 +54,38 @@ async function executeCommand(status, abortSignal, cwd, command, envs, environme
48
54
  ps.kill();
49
55
  return;
50
56
  }
51
- try {
52
- process.kill(-ps.pid, 'SIGTERM');
53
- }
54
- catch {
55
- // the group is already gone
56
- }
57
+ stopGroup(ps.pid);
57
58
  });
58
59
  });
59
60
  }
60
61
  exports.executeCommand = executeCommand;
62
+ // SIGTERM the whole group, then SIGKILL whatever is still in it after
63
+ // HAMMERKIT_STOP_TIMEOUT_MS. The shell exiting doesn't end the wait: a child that ignores
64
+ // SIGTERM would otherwise outlive hammerkit.
65
+ function stopGroup(pid) {
66
+ if (!signalGroup(pid, 'SIGTERM')) {
67
+ return;
68
+ }
69
+ const deadline = Date.now() + getStopTimeout();
70
+ const poll = setInterval(() => {
71
+ if (!signalGroup(pid, 0)) {
72
+ clearInterval(poll);
73
+ return;
74
+ }
75
+ if (Date.now() >= deadline) {
76
+ signalGroup(pid, 'SIGKILL');
77
+ clearInterval(poll);
78
+ }
79
+ }, 100);
80
+ }
81
+ function signalGroup(pid, signal) {
82
+ try {
83
+ process.kill(-pid, signal);
84
+ return true;
85
+ }
86
+ catch {
87
+ // the group is already gone
88
+ return false;
89
+ }
90
+ }
61
91
  //# sourceMappingURL=execute-command.js.map
@@ -3,16 +3,22 @@ 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.execCommand = exports.convertToPosixPath = exports.startContainer = exports.getContainerCli = void 0;
6
+ exports.clearContainerDirectory = exports.execCommand = exports.convertToPosixPath = exports.startContainer = exports.getContainerCli = exports.createDockerClient = void 0;
7
7
  const stream_1 = require("../docker/stream");
8
8
  const dockerode_1 = __importDefault(require("dockerode"));
9
9
  const path_1 = require("path");
10
10
  const os_1 = require("os");
11
11
  const abort_event_1 = require("../utils/abort-event");
12
12
  let dockerInstance = null;
13
+ // Without options dockerode reads DOCKER_HOST and the TLS variables; passing
14
+ // `{ host: undefined }` would overwrite the host it parsed from DOCKER_HOST.
15
+ function createDockerClient(host) {
16
+ return host ? new dockerode_1.default({ host }) : new dockerode_1.default();
17
+ }
18
+ exports.createDockerClient = createDockerClient;
13
19
  function getContainerCli(workEnvironment) {
14
20
  if (!dockerInstance) {
15
- dockerInstance = new dockerode_1.default({ host: workEnvironment.host });
21
+ dockerInstance = createDockerClient(workEnvironment.host);
16
22
  }
17
23
  return dockerInstance;
18
24
  }
@@ -127,4 +133,14 @@ async function execCommand(status, environment, container, cwd, cmd, user, timeo
127
133
  });
128
134
  }
129
135
  exports.execCommand = execCommand;
136
+ // Empty a directory inside a running container, as root. Volumes are emptied
137
+ // this way rather than recreated: Docker refuses to remove a volume while any
138
+ // container (a running dependent's, say) still mounts it.
139
+ async function clearContainerDirectory(status, environment, container, path) {
140
+ const result = await execCommand(status, environment, container, '/', ['sh', '-c', 'rm -rf "$1"/* "$1"/.[!.]* "$1"/..?*', 'sh', convertToPosixPath(path)], null, undefined, environment.abortCtrl.signal);
141
+ if (result.type !== 'result' || result.result.ExitCode !== 0) {
142
+ throw new Error(`unable to empty ${path}`);
143
+ }
144
+ }
145
+ exports.clearContainerDirectory = clearContainerDirectory;
130
146
  //# sourceMappingURL=execute-docker.js.map
@@ -32,8 +32,12 @@ async function executeWorkService(work, environment, options) {
32
32
  });
33
33
  await (0, await_completed_dependencies_1.awaitRunningNeeds)(work, work.needs.map((n) => n.service), abort);
34
34
  if (options.type === 'execute') {
35
+ // rejects when the run is aborted; unhandled, that rejection would
36
+ // crash hammerkit before the service container is removed
35
37
  (0, await_completed_dependencies_1.awaitNoRequirements)(work, abort).then(() => {
36
38
  stop();
39
+ }, () => {
40
+ // aborted: the service is stopping anyway
37
41
  });
38
42
  }
39
43
  await work.runtime.execute(environment, {