hammerkit 1.8.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.
- package/build.schema.json +9 -0
- package/dist/docker/package.js +13 -3
- package/dist/docker/remove-orphaned-containers.js +53 -0
- package/dist/docker/run-labels.js +14 -0
- package/dist/docker/using-container.js +25 -38
- package/dist/executer/check-readiness.js +4 -4
- package/dist/executer/container-task-state.js +34 -0
- package/dist/executer/docker-service.js +23 -6
- package/dist/executer/docker-task.js +10 -2
- package/dist/executer/event-cache.js +8 -0
- package/dist/executer/execute-command.js +36 -6
- package/dist/executer/execute-docker.js +1 -1
- package/dist/executer/execute-work-service.js +4 -0
- package/dist/executer/get-container-binds.js +1 -1
- package/dist/executer/main-process-user.js +52 -0
- package/dist/index.js +2 -3
- package/dist/kubernetes/await-running-state.js +19 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +27 -6
- package/dist/planner/unreferenced-build-file-envs.js +35 -0
- package/dist/planner/utils/append-work-service.js +26 -2
- package/dist/planner/validate.js +10 -0
- package/dist/planner/work-healthcheck.js +8 -0
- package/dist/planner/work-instance-id.js +3 -3
- package/dist/planner/work-runtime-docker.js +13 -18
- package/dist/planner/work-service-id.js +3 -0
- package/dist/planner/work-service.js +27 -1
- package/dist/program.js +6 -6
- package/dist/schema/build-file-container-service-schema.js +8 -0
- package/dist/schema/build-file-service-container-healthcheck.js +4 -0
- package/dist/utils/abort-on-signals.js +17 -0
- 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": [
|
package/dist/docker/package.js
CHANGED
|
@@ -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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
succeeded = result !== false;
|
|
18
|
-
return result;
|
|
19
|
+
return await callback(container);
|
|
19
20
|
}
|
|
20
21
|
finally {
|
|
21
22
|
try {
|
|
22
|
-
if (
|
|
23
|
-
|
|
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,
|
|
6
|
-
const result = await (0, execute_docker_1.execCommand)(status, environment, container, undefined,
|
|
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 ${
|
|
15
|
+
status.write('debug', `healthcheck ${command.join(' ')} succeeded`);
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
18
|
else {
|
|
19
|
-
status.write('debug', `healthcheck ${
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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) {
|
|
@@ -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, {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
package/dist/planner/validate.js
CHANGED
|
@@ -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)
|
|
8
|
-
// scoped to one checkout — two worktrees of the same repo on
|
|
9
|
-
// otherwise see each other's
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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