hammerkit 1.8.0 → 1.9.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 (32) hide show
  1. package/build.schema.json +9 -0
  2. package/dist/docker/package.js +13 -3
  3. package/dist/docker/remove-orphaned-containers.js +53 -0
  4. package/dist/docker/run-labels.js +14 -0
  5. package/dist/docker/using-container.js +25 -38
  6. package/dist/executer/check-readiness.js +4 -4
  7. package/dist/executer/container-task-state.js +34 -0
  8. package/dist/executer/docker-service.js +23 -6
  9. package/dist/executer/docker-task.js +10 -2
  10. package/dist/executer/event-cache.js +8 -0
  11. package/dist/executer/execute-command.js +36 -6
  12. package/dist/executer/execute-docker.js +1 -1
  13. package/dist/executer/execute-work-service.js +11 -0
  14. package/dist/executer/get-container-binds.js +1 -1
  15. package/dist/executer/main-process-user.js +52 -0
  16. package/dist/index.js +2 -3
  17. package/dist/kubernetes/await-running-state.js +19 -1
  18. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +27 -6
  19. package/dist/log.js +3 -0
  20. package/dist/planner/unreferenced-build-file-envs.js +35 -0
  21. package/dist/planner/utils/append-work-service.js +26 -2
  22. package/dist/planner/validate.js +10 -0
  23. package/dist/planner/work-healthcheck.js +8 -0
  24. package/dist/planner/work-instance-id.js +3 -3
  25. package/dist/planner/work-runtime-docker.js +13 -18
  26. package/dist/planner/work-service-id.js +3 -0
  27. package/dist/planner/work-service.js +27 -1
  28. package/dist/program.js +6 -6
  29. package/dist/schema/build-file-container-service-schema.js +8 -0
  30. package/dist/schema/build-file-service-container-healthcheck.js +4 -0
  31. package/dist/utils/abort-on-signals.js +17 -0
  32. package/package.json +1 -1
package/build.schema.json CHANGED
@@ -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": [
@@ -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,52 +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
- }
42
- // A task run that failed has written into the outputs an earlier run's
43
- // container still vouches for (its state label is what currentStateKey
44
- // reads), so that record goes too.
45
- if (stateKey) {
46
- const containers = await docker.listContainers({
47
- all: true,
48
- filters: { label: [`hammerkit-id=${(0, work_instance_id_1.getWorkInstanceId)(item)}`] },
49
- });
50
- for (const old of containers) {
51
- await (0, remove_container_1.removeContainer)(docker.getContainer(old.Id));
52
- }
53
- }
23
+ if (container) {
24
+ await (0, remove_container_1.removeContainer)(container);
54
25
  }
55
26
  }
56
27
  catch (e) {
@@ -59,4 +30,20 @@ async function usingContainer(docker, item, createOptions, stateKey, callback) {
59
30
  }
60
31
  }
61
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
+ }
62
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
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");
@@ -14,6 +15,7 @@ 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");
16
17
  const get_outputs_to_reset_1 = require("../planner/utils/get-outputs-to-reset");
18
+ const container_task_state_1 = require("./container-task-state");
17
19
  function getNeedsNetwork(serviceContainers, needs) {
18
20
  const links = [];
19
21
  const hosts = [];
@@ -51,7 +53,7 @@ function buildCreateOptions(item, stateKey, serviceContainers, environment) {
51
53
  Labels: {
52
54
  app: 'hammerkit',
53
55
  'hammerkit-id': (0, work_instance_id_1.getWorkInstanceId)(item),
54
- 'hammerkit-pid': process.pid.toString(),
56
+ ...(0, run_labels_1.getRunLabels)(),
55
57
  'hammerkit-type': 'task',
56
58
  'hammerkit-state': stateKey,
57
59
  },
@@ -67,6 +69,9 @@ exports.buildCreateOptions = buildCreateOptions;
67
69
  async function dockerTask(docker, item, environment, options) {
68
70
  item.status.write('info', `execute ${item.name} in container`);
69
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);
70
75
  const outputsToReset = (0, get_outputs_to_reset_1.getOutputsToReset)(item.data);
71
76
  // outputs on the host: file outputs (bind mounts) and exported directories
72
77
  for (const generate of outputsToReset.filter((g) => g.isFile || g.export)) {
@@ -81,7 +86,7 @@ async function dockerTask(docker, item, environment, options) {
81
86
  const serviceContainers = (0, get_service_containers_1.getServiceContainers)(item.needs);
82
87
  const containerOptions = buildCreateOptions(item, options.stateKey, serviceContainers, environment);
83
88
  (0, print_container_options_1.printContainerOptions)(item.status, containerOptions);
84
- 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) => {
85
90
  await (0, execution_steps_1.setUserPermissions)(item, container, environment);
86
91
  for (const generate of outputsToReset.filter((g) => !g.isFile)) {
87
92
  await (0, execute_docker_1.clearContainerDirectory)(item.status, environment, container, generate.path);
@@ -126,6 +131,9 @@ async function dockerTask(docker, item, environment, options) {
126
131
  }
127
132
  return true;
128
133
  });
134
+ if (succeeded) {
135
+ await (0, container_task_state_1.writeContainerTaskState)(environment, item, options.stateKey);
136
+ }
129
137
  }
130
138
  catch (e) {
131
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
@@ -135,7 +135,7 @@ async function execCommand(status, environment, container, cwd, cmd, user, timeo
135
135
  exports.execCommand = execCommand;
136
136
  // Empty a directory inside a running container, as root. Volumes are emptied
137
137
  // this way rather than recreated: Docker refuses to remove a volume while any
138
- // container (a dependent's paused one, say) still mounts it.
138
+ // container (a running dependent's, say) still mounts it.
139
139
  async function clearContainerDirectory(status, environment, container, path) {
140
140
  const result = await execCommand(status, environment, container, '/', ['sh', '-c', 'rm -rf "$1"/* "$1"/.[!.]* "$1"/..?*', 'sh', convertToPosixPath(path)], null, undefined, environment.abortCtrl.signal);
141
141
  if (result.type !== 'result' || result.result.ExitCode !== 0) {
@@ -17,6 +17,13 @@ async function executeWorkService(work, environment, options) {
17
17
  const required = await (0, await_completed_dependencies_1.awaitRequirement)(work, environment.abortCtrl.signal, { untilAllDone: !options.watch });
18
18
  if (!required) {
19
19
  work.status.write('debug', `${work.name} not started, no task needing it had to run`);
20
+ // ended, so the services it needs see it done too; left pending they
21
+ // would wait for it forever and the run would never finish
22
+ work.state.set({
23
+ type: 'end',
24
+ reason: 'not-started',
25
+ stateKey: null,
26
+ });
20
27
  return;
21
28
  }
22
29
  }
@@ -32,8 +39,12 @@ async function executeWorkService(work, environment, options) {
32
39
  });
33
40
  await (0, await_completed_dependencies_1.awaitRunningNeeds)(work, work.needs.map((n) => n.service), abort);
34
41
  if (options.type === 'execute') {
42
+ // rejects when the run is aborted; unhandled, that rejection would
43
+ // crash hammerkit before the service container is removed
35
44
  (0, await_completed_dependencies_1.awaitNoRequirements)(work, abort).then(() => {
36
45
  stop();
46
+ }, () => {
47
+ // aborted: the service is stopping anyway
37
48
  });
38
49
  }
39
50
  await work.runtime.execute(environment, {
@@ -13,7 +13,7 @@ function getContainerBinds(item) {
13
13
  // A source inside another source (a dependency's `app/app.csproj` under the
14
14
  // task's own `app`) is already visible through the outer bind. Binding it
15
15
  // again would nest bind mounts, which Docker Desktop then refuses to let the
16
- // host delete while the paused container exists.
16
+ // host delete while the container exists.
17
17
  ...sources
18
18
  .filter((path) => !sources.some((other) => isInside(path, other)))
19
19
  .map((path) => ({ localPath: path, containerPath: path })),
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getMainProcessUser = exports.parseProcessStatusUser = void 0;
4
+ const stream_1 = require("stream");
5
+ // `uid:gid` from the text of /proc/<pid>/status (effective ids), or null.
6
+ function parseProcessStatusUser(status) {
7
+ const uid = status.match(/^Uid:\s+\d+\s+(\d+)/m);
8
+ const gid = status.match(/^Gid:\s+\d+\s+(\d+)/m);
9
+ if (!uid || !gid) {
10
+ return null;
11
+ }
12
+ return `${uid[1]}:${gid[1]}`;
13
+ }
14
+ exports.parseProcessStatusUser = parseProcessStatusUser;
15
+ // The user the container's main process (PID 1) runs as, read inside the
16
+ // container so user namespaces and rootless docker report the container's ids.
17
+ // Healthchecks run as this user, like a Kubernetes exec probe runs as the
18
+ // container's user: a check run as root could otherwise create files the
19
+ // service then cannot read (RabbitMQ's Erlang cookie). Null when it can't be
20
+ // read (no `cat` in the image, a windows container); the check then runs as
21
+ // the exec default, as before.
22
+ async function getMainProcessUser(container, timeoutMs = 2000) {
23
+ try {
24
+ const exec = await container.exec({ Cmd: ['cat', '/proc/1/status'], AttachStdout: true, AttachStderr: true });
25
+ const stream = await exec.start({ hijack: true, stdin: false });
26
+ const stdout = new stream_1.PassThrough();
27
+ const chunks = [];
28
+ stdout.on('data', (chunk) => chunks.push(chunk));
29
+ container.modem.demuxStream(stream, stdout, new stream_1.PassThrough());
30
+ const finished = await Promise.race([
31
+ new Promise((resolve) => {
32
+ stream.on('end', () => resolve(true));
33
+ stream.on('error', () => resolve(false));
34
+ }),
35
+ new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)),
36
+ ]);
37
+ if (!finished) {
38
+ stream.destroy();
39
+ return null;
40
+ }
41
+ const result = await exec.inspect();
42
+ if (result.ExitCode !== 0) {
43
+ return null;
44
+ }
45
+ return parseProcessStatusUser(Buffer.concat(chunks).toString('utf8'));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ exports.getMainProcessUser = getMainProcessUser;
52
+ //# sourceMappingURL=main-process-user.js.map
package/dist/index.js CHANGED
@@ -6,10 +6,9 @@ const get_file_context_1 = require("./file/get-file-context");
6
6
  const work_item_status_1 = require("./planner/work-item-status");
7
7
  const empty_writable_1 = require("./utils/empty-writable");
8
8
  const run_program_1 = require("./run-program");
9
+ const abort_on_signals_1 = require("./utils/abort-on-signals");
9
10
  const abortCtrl = new AbortController();
10
- process.on('SIGINT', function () {
11
- abortCtrl.abort();
12
- });
11
+ (0, abort_on_signals_1.abortOnSignals)(process, abortCtrl);
13
12
  let settled = false;
14
13
  // A run that never settles (a task waiting on something that can't happen)
15
14
  // lets the event loop drain, and Node then exits 0: CI would report success
@@ -76,12 +76,30 @@ async function deleteJob(instance, env, name) {
76
76
  }
77
77
  }
78
78
  exports.deleteJob = deleteJob;
79
- async function awaitDeployRunningState(instance, env, name, pollInterval = 1000) {
79
+ // Waits for a ready replica. The deadline only starts once a pod runs, so an
80
+ // image pull doesn't count against it (as on docker, where it starts after
81
+ // the container started).
82
+ async function awaitDeployRunningState(instance, env, name, readiness, pollInterval = 1000, now = Date.now) {
83
+ let runningSince = null;
80
84
  for (;;) {
81
85
  const deployment = await instance.appsApi.readNamespacedDeployment({ name, namespace: env.namespace });
82
86
  if ((deployment.status?.readyReplicas ?? 0) > 0) {
83
87
  return;
84
88
  }
89
+ if (readiness.timeout !== null) {
90
+ if (runningSince === null) {
91
+ const pods = await instance.coreApi.listNamespacedPod({
92
+ namespace: env.namespace,
93
+ labelSelector: readiness.podSelector,
94
+ });
95
+ if (pods.items.some((pod) => pod.status?.phase === 'Running')) {
96
+ runningSince = now();
97
+ }
98
+ }
99
+ else if (now() - runningSince >= readiness.timeout) {
100
+ throw new Error(readiness.timeoutMessage);
101
+ }
102
+ }
85
103
  await (0, sleep_1.sleep)(pollInterval);
86
104
  }
87
105
  }
@@ -2,12 +2,14 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ensureKubernetesDeploymentExists = void 0;
4
4
  const work_item_1 = require("../planner/work-item");
5
+ const work_service_1 = require("../planner/work-service");
5
6
  const apply_1 = require("./apply");
6
7
  const get_service_ip_1 = require("./get-service-ip");
7
8
  const version_1 = require("../version");
8
9
  const replace_env_variables_1 = require("../environment/replace-env-variables");
9
10
  const await_running_state_1 = require("./await-running-state");
10
11
  const resources_1 = require("./resources");
12
+ const work_healthcheck_1 = require("../planner/work-healthcheck");
11
13
  async function ensureKubernetesDeploymentExists(instance, env, service, persistence, stateKey) {
12
14
  const hostAliases = [];
13
15
  for (const need of service.needs) {
@@ -25,10 +27,12 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
25
27
  }
26
28
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(service.data.envs);
27
29
  const name = (0, resources_1.getResourceName)(service);
28
- const probe = service.data.healthcheck
30
+ const healthcheckCommand = (0, work_service_1.getHealthcheckCommand)(service.data);
31
+ const serviceCommand = (0, work_service_1.getServiceCommand)(service.data);
32
+ const probe = healthcheckCommand
29
33
  ? {
30
34
  exec: {
31
- command: [service.data.healthcheck.cmd.parsed.command, ...service.data.healthcheck.cmd.parsed.args],
35
+ command: healthcheckCommand,
32
36
  },
33
37
  periodSeconds: 5,
34
38
  failureThreshold: 3,
@@ -68,13 +72,12 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
68
72
  {
69
73
  name: service.name.replace(/:/, '-'),
70
74
  image: service.data.image,
71
- workingDir: service.data.cwd ?? undefined,
75
+ workingDir: (0, work_service_1.getServiceWorkingDir)(service.data),
72
76
  env: Object.entries(envs).map(([key, value]) => ({
73
77
  name: key,
74
78
  value: value,
75
79
  })),
76
- command: service.data.cmd ? [service.data.cmd.parsed.command] : undefined,
77
- args: service.data.cmd ? service.data.cmd.parsed.args : undefined,
80
+ ...getKubernetesCommand(serviceCommand),
78
81
  ports: service.data.ports.map((p) => ({
79
82
  containerPort: p.containerPort,
80
83
  })),
@@ -99,7 +102,25 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
99
102
  if (deploy.status?.readyReplicas ?? 0 > 0) {
100
103
  return;
101
104
  }
102
- await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name);
105
+ await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, {
106
+ podSelector: `hammerkit.dev/id=${service.id()}`,
107
+ timeout: service.data.healthcheck?.timeout ?? null,
108
+ timeoutMessage: service.data.healthcheck
109
+ ? (0, work_healthcheck_1.getHealthcheckTimeoutMessage)(service.name, service.data.healthcheck)
110
+ : '',
111
+ });
103
112
  }
104
113
  exports.ensureKubernetesDeploymentExists = ensureKubernetesDeploymentExists;
114
+ // Kubernetes `command` replaces the image entrypoint and `args` its command.
115
+ // A shell service sets both; an exec-form `cmd` keeps the established mapping
116
+ // (first token as `command`, the rest as `args`).
117
+ function getKubernetesCommand(command) {
118
+ if (command.entrypoint && command.cmd) {
119
+ return { command: command.entrypoint, args: command.cmd };
120
+ }
121
+ if (command.cmd) {
122
+ return { command: [command.cmd[0]], args: command.cmd.slice(1) };
123
+ }
124
+ return {};
125
+ }
105
126
  //# sourceMappingURL=ensure-kubernetes-deployment-exists.js.map
package/dist/log.js CHANGED
@@ -100,6 +100,9 @@ async function printWorkTreeResult(schedulerState, env) {
100
100
  for (const service of (0, plan_work_tasks_1.iterateWorkServices)(schedulerState)) {
101
101
  let message = `${formatDate(new Date())} ${getType('service')} ${getNodeName(service.name, maxNodeNameLength)} - ${getStateText(service.state.current)}`;
102
102
  if (service.state.current.type === 'end') {
103
+ if (service.state.current.reason === 'not-started') {
104
+ continue;
105
+ }
103
106
  if (service.state.current.reason === 'crash') {
104
107
  message += ` crashed`;
105
108
  }
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getUnreferencedBuildFileEnvs = void 0;
4
+ // Build-file `envs` are passed to every task of the file and are part of every
5
+ // task's cache key, so a value only some tasks use (an image tag, a test token)
6
+ // reruns all of them when it changes. Returns the build-file envs the task's own
7
+ // definition never mentions as `$NAME` / `${NAME}`. The task may still read
8
+ // them implicitly at runtime (NODE_ENV), so this is only worth a warning.
9
+ function getUnreferencedBuildFileEnvs(task) {
10
+ const buildFileEnvs = task.scope.schema.envs ?? {};
11
+ const taskSchema = getTaskSchema(task);
12
+ // an aggregate (deps only) runs nothing, so a rerun costs nothing
13
+ if (!taskSchema || 'extend' in taskSchema || (taskSchema.cmds ?? []).length === 0) {
14
+ return [];
15
+ }
16
+ const taskEnvs = taskSchema.envs ?? {};
17
+ const definition = JSON.stringify(taskSchema);
18
+ return Object.keys(buildFileEnvs)
19
+ .filter((name) => !(name in taskEnvs))
20
+ .filter((name) => !isReferenced(definition, name));
21
+ }
22
+ exports.getUnreferencedBuildFileEnvs = getUnreferencedBuildFileEnvs;
23
+ function getTaskSchema(task) {
24
+ const prefix = task.scope.namePrefix.length > 0 ? `${task.scope.namePrefix}:` : '';
25
+ if (!task.name.startsWith(prefix)) {
26
+ return null;
27
+ }
28
+ const tasks = task.scope.schema.tasks ?? {};
29
+ return tasks[task.name.substring(prefix.length)] ?? null;
30
+ }
31
+ function isReferenced(definition, name) {
32
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ return new RegExp(`\\$(\\{${escaped}\\}|${escaped}(?![A-Za-z0-9_]))`, 'i').test(definition);
34
+ }
35
+ //# sourceMappingURL=unreferenced-build-file-envs.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.appendWorkService = void 0;
3
+ exports.getHealthcheckTimeout = exports.DEFAULT_HEALTHCHECK_TIMEOUT = exports.appendWorkService = void 0;
4
4
  const work_service_id_1 = require("../work-service-id");
5
5
  const parse_work_ports_1 = require("./parse-work-ports");
6
6
  const template_value_1 = require("./template-value");
@@ -17,6 +17,8 @@ const state_1 = require("../../executer/state");
17
17
  const lazy_resolver_1 = require("../../executer/lazy-resolver");
18
18
  const get_work_runtime_1 = require("./get-work-runtime");
19
19
  const resolve_cache_1 = require("../../cache/resolve-cache");
20
+ const units_1 = require("../../utils/units");
21
+ const log_1 = require("../../log");
20
22
  function appendWorkService(workTree, service, environment, context) {
21
23
  const workService = parseService(workTree, service, environment, context);
22
24
  if (!workTree.services[workService.name]) {
@@ -91,13 +93,16 @@ function parseService(workTree, service, environment, context) {
91
93
  continuous: service.schema.continuous ?? false,
92
94
  healthcheck: service.schema.healthcheck
93
95
  ? {
94
- cmd: (0, parse_work_command_1.parseWorkCommand)(service.cwd, service.schema.healthcheck, envs),
96
+ cmd: (0, parse_work_command_1.parseWorkCommand)(service.cwd, service.schema.healthcheck.cmd, envs),
97
+ timeout: getHealthcheckTimeout(service.schema.healthcheck.timeout, environment),
95
98
  }
96
99
  : null,
97
100
  envs: (0, replace_env_variables_1.buildEnvironmentVariables)(service.envs, environment, context),
98
101
  image: (0, template_value_1.templateValue)(service.schema.image, envs),
99
102
  cwd: service.cwd,
100
103
  cmd: service.schema.cmd ? (0, parse_work_command_1.parseWorkCommand)(service.cwd, service.schema.cmd, envs) : null,
104
+ workdir: service.schema.workdir ? (0, template_value_1.templateValue)(service.schema.workdir, envs) : null,
105
+ shell: service.schema.shell ? (0, template_value_1.templateValue)(service.schema.shell, envs) : null,
101
106
  volumes: (0, parse_work_volume_1.parseWorkVolumes)(service.cwd, service.schema.volumes, envs),
102
107
  mounts: (0, parse_work_mounts_1.parseWorkMounts)(service.cwd, service.schema, envs),
103
108
  src: (0, parse_work_source_1.parseWorkSource)(service.cwd, service.schema.src, envs),
@@ -105,4 +110,23 @@ function parseService(workTree, service, environment, context) {
105
110
  };
106
111
  }
107
112
  }
113
+ exports.DEFAULT_HEALTHCHECK_TIMEOUT = '20s';
114
+ // A service's own `healthcheck.timeout`, else HAMMERKIT_HEALTHCHECK_TIMEOUT,
115
+ // else 20s: a check that never passes fails the run instead of hanging it.
116
+ function getHealthcheckTimeout(timeout, environment) {
117
+ if (timeout) {
118
+ return (0, units_1.parseDuration)(timeout);
119
+ }
120
+ const fromEnv = environment.processEnvs['HAMMERKIT_HEALTHCHECK_TIMEOUT'];
121
+ if (fromEnv) {
122
+ try {
123
+ return (0, units_1.parseDuration)(fromEnv);
124
+ }
125
+ catch (e) {
126
+ throw new Error(`HAMMERKIT_HEALTHCHECK_TIMEOUT: ${(0, log_1.getErrorMessage)(e)}`);
127
+ }
128
+ }
129
+ return (0, units_1.parseDuration)(exports.DEFAULT_HEALTHCHECK_TIMEOUT);
130
+ }
131
+ exports.getHealthcheckTimeout = getHealthcheckTimeout;
108
132
  //# sourceMappingURL=append-work-service.js.map
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.checkIfContextExists = exports.hasDependencyCycle = exports.hasNeedCycle = exports.hasMixedCycle = exports.validate = void 0;
4
4
  const read_build_file_1 = require("../parser/read-build-file");
5
5
  const plan_work_tasks_1 = require("./utils/plan-work-tasks");
6
+ const unreferenced_build_file_envs_1 = require("./unreferenced-build-file-envs");
6
7
  async function* validate(workTree, context) {
7
8
  const cycleItems = [];
8
9
  for (const item of (0, plan_work_tasks_1.iterateWorkServices)(workTree)) {
@@ -49,6 +50,15 @@ async function* validate(workTree, context) {
49
50
  if ((!task.cmds || task.cmds.length === 0) && (!item.deps || item.deps.length === 0)) {
50
51
  yield { type: 'warn', message: `task is empty`, item: task };
51
52
  }
53
+ const unreferencedEnvs = (0, unreferenced_build_file_envs_1.getUnreferencedBuildFileEnvs)(task);
54
+ if (unreferencedEnvs.length > 0) {
55
+ yield {
56
+ type: 'warn',
57
+ message: `build-file envs ${unreferencedEnvs.join(', ')} are part of this task's cache key but not referenced ` +
58
+ `by it: changing them reruns it. If it doesn't read them at runtime, define them on the tasks that do`,
59
+ item: task,
60
+ };
61
+ }
52
62
  for (const src of task.src) {
53
63
  if (!(await context.file.exists(src.absolutePath))) {
54
64
  yield {
@@ -1,3 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getHealthcheckTimeoutMessage = void 0;
4
+ const units_1 = require("../utils/units");
5
+ function getHealthcheckTimeoutMessage(serviceName, healthcheck) {
6
+ return (`service ${serviceName} did not pass its healthcheck "${healthcheck.cmd.cmd}" within ` +
7
+ `${(0, units_1.formatDuration)(healthcheck.timeout)} of starting (see its log above; raise healthcheck.timeout ` +
8
+ `or HAMMERKIT_HEALTHCHECK_TIMEOUT if it is just slow)`);
9
+ }
10
+ exports.getHealthcheckTimeoutMessage = getHealthcheckTimeoutMessage;
3
11
  //# sourceMappingURL=work-healthcheck.js.map
@@ -4,9 +4,9 @@ exports.getWorkInstanceId = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  // `id()` is the portable cache identity: identical for the same task in every
6
6
  // checkout, so a shared cache hits across machines. Machine-local runtime state
7
- // (docker container labels, the cache staging directory) must instead stay
8
- // scoped to one checkout — two worktrees of the same repo on one host would
9
- // otherwise see each other's paused containers and staging files. The instance
7
+ // (docker container labels, task state files, the cache staging directory)
8
+ // must instead stay scoped to one checkout — two worktrees of the same repo on
9
+ // one host would otherwise see each other's state records and staging files. The instance
10
10
  // id folds the absolute project root into the portable id for that purpose.
11
11
  function getWorkInstanceId(item) {
12
12
  return (0, crypto_1.createHash)('sha1').update(`${item.id()}\0${item.data.projectRoot}`).digest('hex');
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.dockerServiceRuntime = exports.dockerTaskRuntime = void 0;
4
+ const run_labels_1 = require("../docker/run-labels");
4
5
  const execute_docker_1 = require("../executer/execute-docker");
5
6
  const remove_container_1 = require("../docker/remove-container");
6
7
  const docker_task_1 = require("../executer/docker-task");
@@ -13,6 +14,7 @@ const tar_1 = require("tar");
13
14
  const plan_work_volume_1 = require("./utils/plan-work-volume");
14
15
  const work_instance_id_1 = require("./work-instance-id");
15
16
  const stream_1 = require("stream");
17
+ const container_task_state_1 = require("../executer/container-task-state");
16
18
  function dockerTaskRuntime(task, workEnvironment) {
17
19
  const docker = (0, execute_docker_1.getContainerCli)(workEnvironment);
18
20
  return {
@@ -22,7 +24,8 @@ function dockerTaskRuntime(task, workEnvironment) {
22
24
  label: [`hammerkit-id=${(0, work_instance_id_1.getWorkInstanceId)(task)}`],
23
25
  },
24
26
  });
25
- const currentTask = currentTasks[0];
27
+ // a paused container is a state record of hammerkit before 1.9, not a run
28
+ const currentTask = currentTasks.find((c) => c.State === 'running');
26
29
  if (!currentTask) {
27
30
  return;
28
31
  }
@@ -53,8 +56,9 @@ function dockerTaskRuntime(task, workEnvironment) {
53
56
  async execute(environment, options) {
54
57
  await (0, docker_task_1.dockerTask)(docker, task, environment, options);
55
58
  },
56
- async remove() {
59
+ async remove(environment) {
57
60
  await this.stop();
61
+ await (0, container_task_state_1.removeContainerTaskState)(environment, task);
58
62
  for (const generate of task.data.generates) {
59
63
  if (generate.inherited) {
60
64
  continue;
@@ -70,17 +74,8 @@ function dockerTaskRuntime(task, workEnvironment) {
70
74
  }
71
75
  },
72
76
  async currentStateKey(environment) {
73
- const containers = await docker.listContainers({
74
- all: true,
75
- filters: {
76
- label: [`hammerkit-id=${(0, work_instance_id_1.getWorkInstanceId)(task)}`],
77
- },
78
- });
79
- const container = containers[0];
80
- if (!container) {
81
- return null;
82
- }
83
- if (!container.Labels['hammerkit-state']) {
77
+ const stateKey = await (0, container_task_state_1.readContainerTaskState)(environment, task);
78
+ if (!stateKey) {
84
79
  return null;
85
80
  }
86
81
  // outputs removed since the run (a deleted export, a pruned volume)
@@ -93,7 +88,7 @@ function dockerTaskRuntime(task, workEnvironment) {
93
88
  return null;
94
89
  }
95
90
  }
96
- return container.Labels['hammerkit-state'];
91
+ return stateKey;
97
92
  },
98
93
  };
99
94
  }
@@ -114,7 +109,7 @@ async function restoreContainer(docker, environment, item, path) {
114
109
  Labels: {
115
110
  app: 'hammerkit',
116
111
  'hammerkit-id': (0, work_instance_id_1.getWorkInstanceId)(item),
117
- 'hammerkit-pid': process.pid.toString(),
112
+ ...(0, run_labels_1.getRunLabels)(),
118
113
  'hammerkit-type': 'task',
119
114
  },
120
115
  HostConfig: {
@@ -130,7 +125,7 @@ async function restoreContainer(docker, environment, item, path) {
130
125
  .filter((v) => !v.inherited && !v.isFile)
131
126
  .map((v) => `${v.volumeName}:${(0, execute_docker_1.convertToPosixPath)(v.path)}`),
132
127
  },
133
- }, null, async (container) => {
128
+ }, async (container) => {
134
129
  for (const generate of (0, event_cache_1.getArchivePaths)(item.data, path)) {
135
130
  if (await environment.file.exists(generate.filename)) {
136
131
  // a cache hit means exactly the stored outputs: whatever a failed or
@@ -236,7 +231,7 @@ async function archiveContainer(docker, environment, item, path) {
236
231
  Labels: {
237
232
  app: 'hammerkit',
238
233
  'hammerkit-id': (0, work_instance_id_1.getWorkInstanceId)(item),
239
- 'hammerkit-pid': process.pid.toString(),
234
+ ...(0, run_labels_1.getRunLabels)(),
240
235
  'hammerkit-type': 'task',
241
236
  },
242
237
  HostConfig: {
@@ -249,7 +244,7 @@ async function archiveContainer(docker, environment, item, path) {
249
244
  .filter((v) => !v.inherited && !v.isFile)
250
245
  .map((v) => `${v.volumeName}:${(0, execute_docker_1.convertToPosixPath)(v.path)}`),
251
246
  },
252
- }, null, async (container) => {
247
+ }, async (container) => {
253
248
  const hostFiles = new Set(item.data.type === 'container-task'
254
249
  ? item.data.generates.filter((g) => !g.inherited && g.isFile).map((g) => g.path)
255
250
  : []);
@@ -23,6 +23,9 @@ function getWorkServiceId(service) {
23
23
  .sort(),
24
24
  src: service.src.map((s) => portable(s.absolutePath)).sort(),
25
25
  mounts: service.mounts.map((m) => m.mount).sort(),
26
+ // only when declared, so existing services keep their ids
27
+ ...(service.workdir ? { workdir: service.workdir } : {}),
28
+ ...(service.shell ? { shell: service.shell } : {}),
26
29
  }
27
30
  : {
28
31
  context: service.context,
@@ -1,6 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isContainerWorkService = void 0;
3
+ exports.getHealthcheckCommand = exports.getServiceCommand = exports.getServiceWorkingDir = exports.isContainerWorkService = void 0;
4
4
  const isContainerWorkService = (svc) => 'image' in svc && svc.type === 'container-service';
5
5
  exports.isContainerWorkService = isContainerWorkService;
6
+ // The container's working directory: the declared `workdir`, else the build
7
+ // file's directory (the default every runtime applied before `workdir`).
8
+ const getServiceWorkingDir = (service) => service.workdir ?? service.cwd;
9
+ exports.getServiceWorkingDir = getServiceWorkingDir;
10
+ // The container's entrypoint and command for a service: with a `shell`, its
11
+ // `cmd` runs as `<shell> -c "<cmd>"` in place of the image entrypoint; without
12
+ // one the tokenized `cmd` becomes the command and the image entrypoint stays.
13
+ function getServiceCommand(service) {
14
+ if (!service.cmd) {
15
+ return { entrypoint: null, cmd: null };
16
+ }
17
+ if (service.shell) {
18
+ return { entrypoint: [service.shell, '-c'], cmd: [service.cmd.cmd] };
19
+ }
20
+ return { entrypoint: null, cmd: [service.cmd.parsed.command, ...service.cmd.parsed.args] };
21
+ }
22
+ exports.getServiceCommand = getServiceCommand;
23
+ // The healthcheck as an exec command, through the service's `shell` if it has one.
24
+ function getHealthcheckCommand(service) {
25
+ if (!service.healthcheck) {
26
+ return null;
27
+ }
28
+ const cmd = service.healthcheck.cmd;
29
+ return service.shell ? [service.shell, '-c', cmd.cmd] : [cmd.parsed.command, ...cmd.parsed.args];
30
+ }
31
+ exports.getHealthcheckCommand = getHealthcheckCommand;
6
32
  //# sourceMappingURL=work-service.js.map
package/dist/program.js CHANGED
@@ -228,13 +228,13 @@ async function getProgram(environment, argv) {
228
228
  if (validation.type === 'error') {
229
229
  errors++;
230
230
  }
231
- for (const [buildFilename, errors] of Object.entries(fileErrors)) {
232
- environment.stdout.write(`${colors_1.default.underline(colors_1.default.gray(buildFilename))}\n`);
233
- for (const error of errors) {
234
- environment.stdout.write(` ${colors_1.default.underline(colors_1.default.blue(error.type))} at ${colors_1.default.gray(error.item.name)} ${error.message}\n`);
235
- }
236
- environment.stdout.write('\n');
231
+ }
232
+ for (const [buildFilename, errors] of Object.entries(fileErrors)) {
233
+ environment.stdout.write(`${colors_1.default.underline(colors_1.default.gray(buildFilename))}\n`);
234
+ for (const error of errors) {
235
+ environment.stdout.write(` ${colors_1.default.underline(colors_1.default.blue(error.type))} at ${colors_1.default.gray(error.item.name)} ${error.message}\n`);
237
236
  }
237
+ environment.stdout.write('\n');
238
238
  }
239
239
  if (errors !== 0) {
240
240
  program.error('Detected errors in the hammerkit configuration', { exitCode: 1 });
@@ -18,6 +18,14 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
18
18
  src: (0, zod_1.array)((0, zod_1.string)()).optional(),
19
19
  needs: (0, zod_1.array)(build_file_need_schema_1.buildFileNeedSchema).optional(),
20
20
  cmd: (0, zod_1.string)().optional(),
21
+ // Absolute path inside the container. Without it the service runs in the
22
+ // build file's directory, like a task; images whose entrypoint expects their
23
+ // own WORKDIR (relative `./dist/…` paths, `npm start`) need it.
24
+ workdir: (0, zod_1.string)().optional(),
25
+ // Runs `cmd` and the healthcheck as `<shell> -c "<cmd>"` (replacing the
26
+ // image entrypoint for `cmd`), like a task's `shell`. Without it both are
27
+ // split into exec-form arguments.
28
+ shell: (0, zod_1.string)().optional(),
21
29
  volumes: (0, zod_1.array)((0, zod_1.string)()).optional(),
22
30
  labels: labels_schema_1.labelsSchema.optional(),
23
31
  continuous: (0, zod_1.boolean)().optional(),
@@ -2,7 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildFileServiceContainerHealthcheck = void 0;
4
4
  const zod_1 = require("zod");
5
+ const duration_schema_1 = require("./duration-schema");
5
6
  exports.buildFileServiceContainerHealthcheck = (0, zod_1.object)({
6
7
  cmd: (0, zod_1.string)(),
8
+ // How long the service may take to pass `cmd` once its container runs
9
+ // (default 20s, HAMMERKIT_HEALTHCHECK_TIMEOUT changes the default).
10
+ timeout: duration_schema_1.durationSchema.optional(),
7
11
  });
8
12
  //# sourceMappingURL=build-file-service-container-healthcheck.js.map
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
4
+ // Ctrl-C (SIGINT), a CI job being cancelled or a supervisor stopping hammerkit
5
+ // (SIGTERM) and a closed terminal (SIGHUP) all stop the run the same way: abort,
6
+ // so services are removed and local tasks' process groups are killed. Without a
7
+ // handler Node exits on SIGTERM/SIGHUP at once and leaves both behind.
8
+ exports.ABORT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
9
+ function abortOnSignals(source, abortCtrl) {
10
+ for (const signal of exports.ABORT_SIGNALS) {
11
+ source.on(signal, () => {
12
+ abortCtrl.abort();
13
+ });
14
+ }
15
+ }
16
+ exports.abortOnSignals = abortOnSignals;
17
+ //# sourceMappingURL=abort-on-signals.js.map
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.8.0",
4
+ "version": "1.9.1",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",