hammerkit 1.11.1 → 1.12.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 +38 -0
- package/dist/cli.js +19 -1
- package/dist/executer/await-completed-dependencies.js +11 -5
- package/dist/executer/docker-resources.js +43 -0
- package/dist/executer/docker-service.js +5 -2
- package/dist/executer/docker-task.js +5 -2
- package/dist/executer/dry-run.js +3 -1
- package/dist/executer/execute-work-service.js +16 -10
- package/dist/executer/execute-work-task.js +3 -3
- package/dist/executer/execute-work-tree.js +14 -11
- package/dist/executer/print-container-options.js +9 -0
- package/dist/executer/process-manager.js +58 -2
- package/dist/executer/resource-budget.js +169 -0
- package/dist/kubernetes/container-resources.js +33 -0
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +2 -0
- package/dist/planner/service-definition.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -1
- package/dist/planner/utils/append-work-task.js +2 -0
- package/dist/planner/validate.js +3 -0
- package/dist/planner/work-resources.js +40 -0
- package/dist/planner/work-runtime-kubernetes.js +2 -0
- package/dist/program.js +9 -5
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/resources-schema.js +77 -0
- package/dist/utils/units.js +14 -1
- package/package.json +1 -1
package/build.schema.json
CHANGED
|
@@ -208,6 +208,37 @@
|
|
|
208
208
|
"description": "secret from env:NAME or file:path, injected as env or a read-only file"
|
|
209
209
|
}
|
|
210
210
|
},
|
|
211
|
+
"resources": {
|
|
212
|
+
"type": "object",
|
|
213
|
+
"properties": {
|
|
214
|
+
"cpus": {
|
|
215
|
+
"type": [
|
|
216
|
+
"number",
|
|
217
|
+
"string"
|
|
218
|
+
]
|
|
219
|
+
},
|
|
220
|
+
"memory": {
|
|
221
|
+
"type": "string"
|
|
222
|
+
},
|
|
223
|
+
"requests": {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"properties": {
|
|
226
|
+
"cpus": {
|
|
227
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources/properties/cpus"
|
|
228
|
+
},
|
|
229
|
+
"memory": {
|
|
230
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources/properties/memory"
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
"additionalProperties": false
|
|
234
|
+
},
|
|
235
|
+
"limits": {
|
|
236
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources/properties/requests"
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
"additionalProperties": false,
|
|
240
|
+
"description": "cpu and memory requests and limits"
|
|
241
|
+
},
|
|
211
242
|
"image": {
|
|
212
243
|
"type": "string"
|
|
213
244
|
},
|
|
@@ -269,6 +300,9 @@
|
|
|
269
300
|
"secrets": {
|
|
270
301
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/secrets"
|
|
271
302
|
},
|
|
303
|
+
"resources": {
|
|
304
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources"
|
|
305
|
+
},
|
|
272
306
|
"platform": {
|
|
273
307
|
"type": "object",
|
|
274
308
|
"properties": {
|
|
@@ -392,6 +426,10 @@
|
|
|
392
426
|
"items": {
|
|
393
427
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/secrets/items"
|
|
394
428
|
}
|
|
429
|
+
},
|
|
430
|
+
"resources": {
|
|
431
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources",
|
|
432
|
+
"description": "cpu and memory requests and limits"
|
|
395
433
|
}
|
|
396
434
|
},
|
|
397
435
|
"required": [
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ const get_logger_1 = require("./console/get-logger");
|
|
|
7
7
|
const event_cache_1 = require("./executer/event-cache");
|
|
8
8
|
const validate_1 = require("./planner/validate");
|
|
9
9
|
const process_manager_1 = require("./executer/process-manager");
|
|
10
|
+
const resource_budget_1 = require("./executer/resource-budget");
|
|
10
11
|
const update_service_status_1 = require("./service/update-service-status");
|
|
11
12
|
const check_for_loop_1 = require("./executer/scheduler/check-for-loop");
|
|
12
13
|
const state_1 = require("./executer/state");
|
|
@@ -29,7 +30,6 @@ class Cli {
|
|
|
29
30
|
this.environment = environment;
|
|
30
31
|
}
|
|
31
32
|
setup(type, options) {
|
|
32
|
-
const processManager = new process_manager_1.ProcessManager(options?.workers ?? 0);
|
|
33
33
|
const logMode = options?.logMode ?? (ci_1.isCI ? 'live' : 'interactive');
|
|
34
34
|
const workTree = (0, reset_work_tree_1.resetWorkTree)(this.workTree, type);
|
|
35
35
|
const processWorkTree = new state_1.State(workTree, {
|
|
@@ -43,6 +43,11 @@ class Cli {
|
|
|
43
43
|
state: processWorkTree,
|
|
44
44
|
start: async () => {
|
|
45
45
|
(0, check_for_loop_1.checkForLoop)(workTree);
|
|
46
|
+
const capacity = (0, plan_work_tasks_1.hasError)(workTree) ? null : await (0, resource_budget_1.getResourceCapacity)(workTree, this.environment);
|
|
47
|
+
if (capacity && type !== 'down' && (options?.resourceCheck ?? true)) {
|
|
48
|
+
rejectOversizedWork(workTree, capacity, type === 'up');
|
|
49
|
+
}
|
|
50
|
+
const processManager = new process_manager_1.ProcessManager(options?.workers ?? 0, capacity ? new resource_budget_1.ResourceBudget(capacity, workTree.environment) : null);
|
|
46
51
|
if (!(0, plan_work_tasks_1.hasError)(workTree)) {
|
|
47
52
|
await (0, update_service_status_1.updateServiceStatus)(workTree);
|
|
48
53
|
await (0, execute_work_tree_1.executeWorkTree)(workTree, this.environment, {
|
|
@@ -58,6 +63,7 @@ class Cli {
|
|
|
58
63
|
timeout: options?.timeout ?? null,
|
|
59
64
|
skipDeps: options?.skipDeps ?? true,
|
|
60
65
|
wait: options?.wait ?? 'ready',
|
|
66
|
+
resourceCheck: options?.resourceCheck ?? true,
|
|
61
67
|
});
|
|
62
68
|
}
|
|
63
69
|
const result = (0, get_scheduler_execute_result_1.getSchedulerExecuteResult)(workTree);
|
|
@@ -66,6 +72,11 @@ class Cli {
|
|
|
66
72
|
},
|
|
67
73
|
};
|
|
68
74
|
}
|
|
75
|
+
// The worker count when none is given: 4, or unbounded when every task and
|
|
76
|
+
// service requests cpus and memory, so the requests alone bound the run.
|
|
77
|
+
defaultWorkers() {
|
|
78
|
+
return (0, resource_budget_1.requestsEverything)(this.workTree) ? 0 : 4;
|
|
79
|
+
}
|
|
69
80
|
up(options) {
|
|
70
81
|
return this.setup('up', options);
|
|
71
82
|
}
|
|
@@ -168,4 +179,11 @@ function getCli(workTree, environment) {
|
|
|
168
179
|
return new Cli(workTree, environment);
|
|
169
180
|
}
|
|
170
181
|
exports.getCli = getCli;
|
|
182
|
+
// Fails the run before anything starts when a task, with the services it
|
|
183
|
+
// needs, requests more than the host has: it could never run.
|
|
184
|
+
function rejectOversizedWork(workTree, capacity, up) {
|
|
185
|
+
for (const { item, message } of (0, resource_budget_1.findOversizedWork)(workTree, capacity, up)) {
|
|
186
|
+
item.state.set({ type: 'error', errorMessage: `${message} (--skip-resource-check runs anyway)`, stateKey: null });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
171
189
|
//# sourceMappingURL=cli.js.map
|
|
@@ -16,12 +16,18 @@ async function awaitRequirement(svc, abort, options = { untilAllDone: false }) {
|
|
|
16
16
|
return Promise.race([ready, allDone]);
|
|
17
17
|
}
|
|
18
18
|
exports.awaitRequirement = awaitRequirement;
|
|
19
|
-
// Resolve true once any dependent
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// Resolve true once any dependent needs this task's outputs — a task that is
|
|
20
|
+
// `ready` (it missed the cache and is about to run), a service that is
|
|
21
|
+
// `starting` (a task needing it runs) — and false once every dependent
|
|
22
|
+
// finished without needing it: tasks that were cache hits or skipped, services
|
|
23
|
+
// that never started.
|
|
22
24
|
async function awaitDependentNeed(dependents, abort) {
|
|
23
|
-
const ready = Promise.race(dependents.map((dependent) => (0,
|
|
24
|
-
|
|
25
|
+
const ready = Promise.race(dependents.map((dependent) => (0, work_item_1.isWorkTaskItem)(dependent)
|
|
26
|
+
? (0, state_resolver_1.awaitState)('await-dependent', dependent.state, (s) => s.type === 'ready', abort)
|
|
27
|
+
: (0, state_resolver_1.awaitState)('await-dependent', dependent.state, (s) => s.type === 'starting' || s.type === 'ready' || s.type === 'running', abort))).then(() => true);
|
|
28
|
+
const allDone = Promise.all(dependents.map((dependent) => (0, work_item_1.isWorkTaskItem)(dependent)
|
|
29
|
+
? (0, state_resolver_1.awaitState)('await-dependent-done', dependent.state, (s) => s.type === 'completed' || s.type === 'error' || s.type === 'crash' || s.type === 'canceled', abort)
|
|
30
|
+
: (0, state_resolver_1.awaitState)('await-dependent-done', dependent.state, (s) => s.type === 'end' || s.type === 'error' || s.type === 'canceled', abort))).then(() => false);
|
|
25
31
|
return Promise.race([ready, allDone]);
|
|
26
32
|
}
|
|
27
33
|
exports.awaitDependentNeed = awaitDependentNeed;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveDockerResources = exports.getDockerResources = void 0;
|
|
4
|
+
// The host config limits of a container with these resources; none without.
|
|
5
|
+
// A memory request below the limit is a soft reservation; Docker has no CPU
|
|
6
|
+
// request, the run schedules by it (see ResourceBudget). Docker refuses more CPUs than the host has, while a limit at or above the
|
|
7
|
+
// host's count limits nothing: such a container runs unlimited, with a
|
|
8
|
+
// warning, instead of failing or being clamped to fewer CPUs.
|
|
9
|
+
function getDockerResources(resources, hostCpus, status) {
|
|
10
|
+
if (!resources) {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
const { limits, requests } = resources;
|
|
14
|
+
const config = {};
|
|
15
|
+
if (limits.cpus !== null) {
|
|
16
|
+
if (hostCpus !== null && limits.cpus > hostCpus) {
|
|
17
|
+
status.write('warn', `cpus ${limits.cpus} exceeds the ${hostCpus} cpus of the docker host, running without a cpu limit`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
config.NanoCpus = Math.round(limits.cpus * 1e9);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (limits.memory !== null) {
|
|
24
|
+
config.Memory = limits.memory;
|
|
25
|
+
}
|
|
26
|
+
if (requests.memory !== null && (limits.memory === null || requests.memory < limits.memory)) {
|
|
27
|
+
config.MemoryReservation = requests.memory;
|
|
28
|
+
}
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
exports.getDockerResources = getDockerResources;
|
|
32
|
+
// The limits for the docker host, asking it for its CPU count only when a
|
|
33
|
+
// CPU limit is declared.
|
|
34
|
+
async function resolveDockerResources(docker, resources, status) {
|
|
35
|
+
const hostCpus = resources?.limits.cpus != null ? await getDockerHostCpus(docker) : null;
|
|
36
|
+
return getDockerResources(resources, hostCpus, status);
|
|
37
|
+
}
|
|
38
|
+
exports.resolveDockerResources = resolveDockerResources;
|
|
39
|
+
async function getDockerHostCpus(docker) {
|
|
40
|
+
const info = await docker.info();
|
|
41
|
+
return typeof info.NCPU === 'number' && info.NCPU > 0 ? info.NCPU : null;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=docker-resources.js.map
|
|
@@ -20,8 +20,9 @@ const replace_env_variables_1 = require("../environment/replace-env-variables");
|
|
|
20
20
|
const get_service_containers_1 = require("./get-service-containers");
|
|
21
21
|
const work_instance_id_1 = require("../planner/work-instance-id");
|
|
22
22
|
const work_healthcheck_1 = require("../planner/work-healthcheck");
|
|
23
|
+
const docker_resources_1 = require("./docker-resources");
|
|
23
24
|
const HEALTHCHECK_INTERVAL_MS = 1000;
|
|
24
|
-
function buildServiceCreateOptions(item, options, network, secrets) {
|
|
25
|
+
function buildServiceCreateOptions(item, options, network, secrets, resources = {}) {
|
|
25
26
|
const envs = (0, replace_env_variables_1.getEnvironmentVariables)(item.data.envs);
|
|
26
27
|
const command = (0, work_service_1.getServiceCommand)(item.data);
|
|
27
28
|
return {
|
|
@@ -64,6 +65,7 @@ function buildServiceCreateOptions(item, options, network, secrets) {
|
|
|
64
65
|
return map;
|
|
65
66
|
}, {})
|
|
66
67
|
: {},
|
|
68
|
+
...resources,
|
|
67
69
|
},
|
|
68
70
|
};
|
|
69
71
|
}
|
|
@@ -81,7 +83,8 @@ async function dockerService(docker, item, options, environment) {
|
|
|
81
83
|
const network = (0, docker_task_1.getNeedsNetwork)(serviceContainers, item.needs);
|
|
82
84
|
item.status.write('debug', `create container with image ${item.data.image}`);
|
|
83
85
|
const secrets = await (0, container_secrets_1.prepareContainerSecrets)(item.data.secrets, (0, work_instance_id_1.getWorkInstanceId)(item), environment);
|
|
84
|
-
|
|
86
|
+
const resources = await (0, docker_resources_1.resolveDockerResources)(docker, item.data.resources, item.status);
|
|
87
|
+
container = await docker.createContainer(buildServiceCreateOptions(item, options, network, secrets, resources));
|
|
85
88
|
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
|
|
86
89
|
(0, stream_1.logStream)(item.status, stream);
|
|
87
90
|
await container.start();
|
|
@@ -17,6 +17,7 @@ const work_instance_id_1 = require("../planner/work-instance-id");
|
|
|
17
17
|
const get_outputs_to_reset_1 = require("../planner/utils/get-outputs-to-reset");
|
|
18
18
|
const container_secrets_1 = require("./container-secrets");
|
|
19
19
|
const container_task_state_1 = require("./container-task-state");
|
|
20
|
+
const docker_resources_1 = require("./docker-resources");
|
|
20
21
|
function getNeedsNetwork(serviceContainers, needs) {
|
|
21
22
|
const links = [];
|
|
22
23
|
const hosts = [];
|
|
@@ -35,7 +36,7 @@ function getNeedsNetwork(serviceContainers, needs) {
|
|
|
35
36
|
return { links, hosts };
|
|
36
37
|
}
|
|
37
38
|
exports.getNeedsNetwork = getNeedsNetwork;
|
|
38
|
-
function buildCreateOptions(item, stateKey, serviceContainers, environment, secrets) {
|
|
39
|
+
function buildCreateOptions(item, stateKey, serviceContainers, environment, secrets, resources = {}) {
|
|
39
40
|
const network = getNeedsNetwork(serviceContainers, item.needs);
|
|
40
41
|
const binds = (0, get_container_binds_1.getContainerBinds)(item);
|
|
41
42
|
const envs = (0, replace_env_variables_1.getEnvironmentVariables)(item.data.envs);
|
|
@@ -66,6 +67,7 @@ function buildCreateOptions(item, stateKey, serviceContainers, environment, secr
|
|
|
66
67
|
ExtraHosts: network.hosts,
|
|
67
68
|
Links: network.links,
|
|
68
69
|
AutoRemove: true,
|
|
70
|
+
...resources,
|
|
69
71
|
},
|
|
70
72
|
};
|
|
71
73
|
}
|
|
@@ -89,7 +91,8 @@ async function dockerTask(docker, item, environment, options) {
|
|
|
89
91
|
(0, abort_1.checkForAbort)(options.abort);
|
|
90
92
|
const serviceContainers = (0, get_service_containers_1.getServiceContainers)(item.needs);
|
|
91
93
|
const secrets = await (0, container_secrets_1.prepareContainerSecrets)(item.data.secrets, (0, work_instance_id_1.getWorkInstanceId)(item), environment);
|
|
92
|
-
const
|
|
94
|
+
const resources = await (0, docker_resources_1.resolveDockerResources)(docker, item.data.resources, item.status);
|
|
95
|
+
const containerOptions = buildCreateOptions(item, options.stateKey, serviceContainers, environment, secrets, resources);
|
|
93
96
|
(0, print_container_options_1.printContainerOptions)(item.status, containerOptions);
|
|
94
97
|
const succeeded = await (0, using_container_1.usingContainer)(docker, item, containerOptions, async (container) => {
|
|
95
98
|
await (0, execution_steps_1.setUserPermissions)(item, container, environment);
|
package/dist/executer/dry-run.js
CHANGED
|
@@ -48,6 +48,7 @@ async function planDryRun(workTree, defaultCacheMethod, environment) {
|
|
|
48
48
|
taskName: task.name,
|
|
49
49
|
status: explanation?.status ?? 'miss',
|
|
50
50
|
causes: explanation?.causes ?? [],
|
|
51
|
+
...(task.data.type === 'local-task' && task.data.resources ? { unenforcedResources: true } : {}),
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
54
|
return { entries, cycle: null };
|
|
@@ -66,7 +67,8 @@ function printDryRun(environment, plan) {
|
|
|
66
67
|
? colors_1.default.grey('uncacheable')
|
|
67
68
|
: colors_1.default.yellow('cache miss');
|
|
68
69
|
const causes = entry.causes.length > 0 ? colors_1.default.grey(` (${entry.causes.map(explain_1.describeCause).join(', ')})`) : '';
|
|
69
|
-
|
|
70
|
+
const resources = entry.unenforcedResources ? colors_1.default.grey(' [resources not enforced for a local task]') : '';
|
|
71
|
+
environment.stdout.write(` ${index + 1}. ${entry.taskName}: ${label}${causes}${resources}\n`);
|
|
70
72
|
});
|
|
71
73
|
}
|
|
72
74
|
exports.printDryRun = printDryRun;
|
|
@@ -50,16 +50,22 @@ async function executeWorkService(work, environment, options) {
|
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
const init = getServiceInit(work);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
const release = options.processManager.service(work);
|
|
54
|
+
try {
|
|
55
|
+
await work.runtime.execute(environment, {
|
|
56
|
+
cache: cacheState,
|
|
57
|
+
abort,
|
|
58
|
+
state: init ? gateOnInit(work, init) : work.state,
|
|
59
|
+
stateKey: cacheState.stateKey,
|
|
60
|
+
daemon: options.daemon,
|
|
61
|
+
publishPorts: options.type === 'up' || work.requiredBy.some(isLocalTaskItem) || (!!init && isLocalTaskItem(init.task)),
|
|
62
|
+
// an init needs a healthy service, so a service with one is always awaited
|
|
63
|
+
waitForReady: options.wait === 'ready' || work.requiredBy.length > 0 || !!init,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
release();
|
|
68
|
+
}
|
|
63
69
|
});
|
|
64
70
|
}
|
|
65
71
|
catch (e) {
|
|
@@ -38,14 +38,14 @@ function onlyGroupsCacheHits(work) {
|
|
|
38
38
|
work.deps.every((dep) => dep.state.current.type === 'completed' && dep.state.current.cached));
|
|
39
39
|
}
|
|
40
40
|
async function executeWorkTask(work, environment, options,
|
|
41
|
-
// when set, the task was not requested and only runs if one of these
|
|
42
|
-
// depending on it
|
|
41
|
+
// when set, the task was not requested and only runs if one of these items
|
|
42
|
+
// depending on it needs it (see executeWorkTree)
|
|
43
43
|
dependents = null) {
|
|
44
44
|
try {
|
|
45
45
|
if (dependents) {
|
|
46
46
|
const needed = await (0, await_completed_dependencies_1.awaitDependentNeed)(dependents, environment.abortCtrl.signal);
|
|
47
47
|
if (!needed) {
|
|
48
|
-
work.status.write('debug', `${work.name} skipped,
|
|
48
|
+
work.status.write('debug', `${work.name} skipped, nothing depending on it had to run`);
|
|
49
49
|
work.state.set({ type: 'completed', cached: false, skipped: true, duration: 0, stateKey: '' });
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
@@ -51,26 +51,29 @@ function mirrorServicesToInitViews(work) {
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
-
// For every task that may be skipped when nothing needs it, the tasks
|
|
55
|
-
// run that depend on it. Requested tasks always run;
|
|
56
|
-
//
|
|
54
|
+
// For every task that may be skipped when nothing needs it, the tasks and
|
|
55
|
+
// services in this run that depend on it. Requested tasks always run; a
|
|
56
|
+
// service's dependency runs once the service starts. Everything runs in watch
|
|
57
|
+
// mode, under `up` and with skipping turned off.
|
|
57
58
|
function skippableDependents(work, options) {
|
|
58
59
|
const result = new Map();
|
|
59
60
|
if (options.type !== 'execute' || !options.skipDeps || options.watch || !work.requested) {
|
|
60
61
|
return result;
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// a dependency of nothing in the run (its service needs no one here) has no
|
|
64
|
+
// dependents left, and is skipped
|
|
65
|
+
for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(work)) {
|
|
66
|
+
if (!work.requested.includes(task.name) && !(0, execute_init_task_1.findInitOf)(task)) {
|
|
67
|
+
result.set(task.name, []);
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
const items = [...(0, plan_work_tasks_1.iterateWorkTasks)(work), ...(0, plan_work_tasks_1.iterateWorkServices)(work)];
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
for (const dep of item.deps) {
|
|
73
|
+
if (work.requested.includes(dep.name)) {
|
|
71
74
|
continue;
|
|
72
75
|
}
|
|
73
|
-
result.set(dep.name, [...(result.get(dep.name) ?? []),
|
|
76
|
+
result.set(dep.name, [...(result.get(dep.name) ?? []), item]);
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
return result;
|
|
@@ -9,6 +9,15 @@ function printContainerOptions(status, containerOptions) {
|
|
|
9
9
|
for (const mount of containerOptions.HostConfig?.Binds || []) {
|
|
10
10
|
status.write('debug', `bind ${mount}`);
|
|
11
11
|
}
|
|
12
|
+
if (containerOptions.HostConfig?.NanoCpus) {
|
|
13
|
+
status.write('debug', `limit cpus to ${containerOptions.HostConfig.NanoCpus / 1e9}`);
|
|
14
|
+
}
|
|
15
|
+
if (containerOptions.HostConfig?.Memory) {
|
|
16
|
+
status.write('debug', `limit memory to ${containerOptions.HostConfig.Memory} bytes`);
|
|
17
|
+
}
|
|
18
|
+
if (containerOptions.HostConfig?.MemoryReservation) {
|
|
19
|
+
status.write('debug', `reserve memory ${containerOptions.HostConfig.MemoryReservation} bytes`);
|
|
20
|
+
}
|
|
12
21
|
for (const link of containerOptions.HostConfig?.Links || []) {
|
|
13
22
|
status.write('debug', `link ${link}`);
|
|
14
23
|
}
|
|
@@ -3,13 +3,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ProcessManager = void 0;
|
|
4
4
|
const log_1 = require("../log");
|
|
5
5
|
class ProcessManager {
|
|
6
|
-
|
|
6
|
+
// With a budget a task also starts only once its requests fit next to what
|
|
7
|
+
// runs already; one starts regardless when nothing else runs, so a run
|
|
8
|
+
// whose services alone fill the budget, or a task bigger than it, still
|
|
9
|
+
// makes progress.
|
|
10
|
+
constructor(workerLimit, budget = null) {
|
|
7
11
|
this.workerLimit = workerLimit;
|
|
12
|
+
this.budget = budget;
|
|
8
13
|
this.processes = [];
|
|
9
14
|
this.pendingProcesses = [];
|
|
10
15
|
}
|
|
11
16
|
task(item, factory) {
|
|
12
17
|
return new Promise((resolve, reject) => {
|
|
18
|
+
if (this.budget) {
|
|
19
|
+
this.pendingProcesses.push({ item, factory, resolve, reject });
|
|
20
|
+
this.startFitting();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
13
23
|
const hasFreeWorker = this.workerLimit === 0 || this.workerLimit > this.processes.length;
|
|
14
24
|
if (hasFreeWorker) {
|
|
15
25
|
this.startProcess({ factory, item, resolve, reject });
|
|
@@ -19,7 +29,29 @@ class ProcessManager {
|
|
|
19
29
|
}
|
|
20
30
|
});
|
|
21
31
|
}
|
|
32
|
+
// A running service holds its requests until it ends; it never waits, the
|
|
33
|
+
// tasks needing it do.
|
|
34
|
+
service(item) {
|
|
35
|
+
const budget = this.budget;
|
|
36
|
+
if (!budget) {
|
|
37
|
+
return () => undefined;
|
|
38
|
+
}
|
|
39
|
+
const requests = budget.requestsOf(item);
|
|
40
|
+
budget.acquire(requests);
|
|
41
|
+
let released = false;
|
|
42
|
+
return () => {
|
|
43
|
+
if (!released) {
|
|
44
|
+
released = true;
|
|
45
|
+
budget.release(requests);
|
|
46
|
+
this.startFitting();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
22
50
|
enqueuePendingProcesses() {
|
|
51
|
+
if (this.budget) {
|
|
52
|
+
this.startFitting();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
23
55
|
const nextProcess = this.pendingProcesses[0];
|
|
24
56
|
if (nextProcess) {
|
|
25
57
|
const hasBlockingProcess = this.processes.some((p) => p.id === nextProcess.item.name);
|
|
@@ -32,7 +64,28 @@ class ProcessManager {
|
|
|
32
64
|
this.startProcess(pendingProcess);
|
|
33
65
|
}
|
|
34
66
|
}
|
|
35
|
-
|
|
67
|
+
// In order, every pending task that fits; a later, smaller one may start
|
|
68
|
+
// while an earlier one waits for room.
|
|
69
|
+
startFitting() {
|
|
70
|
+
for (const pending of [...this.pendingProcesses]) {
|
|
71
|
+
if (this.workerLimit !== 0 && this.processes.length >= this.workerLimit) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (this.processes.some((p) => p.id === pending.item.name)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const requests = this.budget.requestsOf(pending.item);
|
|
78
|
+
if (this.processes.length > 0 && !this.budget.fits(requests)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
this.pendingProcesses.splice(this.pendingProcesses.indexOf(pending), 1);
|
|
82
|
+
this.startProcess(pending, requests);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
startProcess(pendingProcess, requests = null) {
|
|
86
|
+
if (requests) {
|
|
87
|
+
this.budget?.acquire(requests);
|
|
88
|
+
}
|
|
36
89
|
const item = {
|
|
37
90
|
id: pendingProcess.item.name,
|
|
38
91
|
promise: pendingProcess
|
|
@@ -47,6 +100,9 @@ class ProcessManager {
|
|
|
47
100
|
.finally(() => {
|
|
48
101
|
const index = this.processes.indexOf(item);
|
|
49
102
|
this.processes.splice(index, 1);
|
|
103
|
+
if (requests) {
|
|
104
|
+
this.budget?.release(requests);
|
|
105
|
+
}
|
|
50
106
|
this.enqueuePendingProcesses();
|
|
51
107
|
}),
|
|
52
108
|
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findOversizedWork = exports.getResourceCapacity = exports.requestsEverything = exports.ResourceBudget = exports.formatAmount = exports.getRequests = exports.runsOnHost = void 0;
|
|
4
|
+
const os_1 = require("os");
|
|
5
|
+
const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
|
|
6
|
+
const work_resources_1 = require("../planner/work-resources");
|
|
7
|
+
const execute_docker_1 = require("./execute-docker");
|
|
8
|
+
const units_1 = require("../utils/units");
|
|
9
|
+
const log_1 = require("../log");
|
|
10
|
+
const NONE = { millicores: 0, memory: 0 };
|
|
11
|
+
// Whether an item runs on the machine the run's budget covers: local tasks
|
|
12
|
+
// always, containers when they run on Docker. On Kubernetes the cluster
|
|
13
|
+
// schedules by requests itself, and a kubernetes service only forwards.
|
|
14
|
+
function runsOnHost(item, environment) {
|
|
15
|
+
if (item.data.type === 'local-task') {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (item.data.type === 'container-task' || item.data.type === 'container-service') {
|
|
19
|
+
return environment.type === 'docker';
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
exports.runsOnHost = runsOnHost;
|
|
24
|
+
function getRequests(item, environment) {
|
|
25
|
+
const resources = 'resources' in item.data ? item.data.resources : null;
|
|
26
|
+
if (!resources || !runsOnHost(item, environment)) {
|
|
27
|
+
return NONE;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
millicores: Math.round((resources.requests.cpus ?? 0) * 1000),
|
|
31
|
+
memory: resources.requests.memory ?? 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
exports.getRequests = getRequests;
|
|
35
|
+
function add(a, b) {
|
|
36
|
+
return { millicores: a.millicores + b.millicores, memory: a.memory + b.memory };
|
|
37
|
+
}
|
|
38
|
+
function fitsIn(amount, capacity) {
|
|
39
|
+
return amount.millicores <= capacity.millicores && amount.memory <= capacity.memory;
|
|
40
|
+
}
|
|
41
|
+
function formatAmount(amount) {
|
|
42
|
+
return (0, work_resources_1.formatQuantities)({ cpus: amount.millicores / 1000, memory: amount.memory });
|
|
43
|
+
}
|
|
44
|
+
exports.formatAmount = formatAmount;
|
|
45
|
+
// The requests of the work running on the host, against its capacity.
|
|
46
|
+
class ResourceBudget {
|
|
47
|
+
constructor(capacity, environment) {
|
|
48
|
+
this.capacity = capacity;
|
|
49
|
+
this.environment = environment;
|
|
50
|
+
this.used = NONE;
|
|
51
|
+
}
|
|
52
|
+
requestsOf(item) {
|
|
53
|
+
return getRequests(item, this.environment);
|
|
54
|
+
}
|
|
55
|
+
fits(amount) {
|
|
56
|
+
return fitsIn(add(this.used, amount), this.capacity);
|
|
57
|
+
}
|
|
58
|
+
acquire(amount) {
|
|
59
|
+
this.used = add(this.used, amount);
|
|
60
|
+
}
|
|
61
|
+
release(amount) {
|
|
62
|
+
this.used = { millicores: this.used.millicores - amount.millicores, memory: this.used.memory - amount.memory };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.ResourceBudget = ResourceBudget;
|
|
66
|
+
function hasRequests(workTree) {
|
|
67
|
+
const items = [...(0, plan_work_tasks_1.iterateWorkTasks)(workTree), ...(0, plan_work_tasks_1.iterateWorkServices)(workTree)];
|
|
68
|
+
return items.some((item) => {
|
|
69
|
+
const requests = getRequests(item, workTree.environment);
|
|
70
|
+
return requests.millicores > 0 || requests.memory > 0;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Every task and container service on the host requests both cpus and
|
|
74
|
+
// memory: the run is then bounded by its requests instead of a worker count.
|
|
75
|
+
function requestsEverything(workTree) {
|
|
76
|
+
const items = [...(0, plan_work_tasks_1.iterateWorkTasks)(workTree), ...(0, plan_work_tasks_1.iterateWorkServices)(workTree)].filter((item) => runsOnHost(item, workTree.environment));
|
|
77
|
+
return (items.length > 0 &&
|
|
78
|
+
items.every((item) => {
|
|
79
|
+
const requests = 'resources' in item.data ? item.data.resources?.requests : null;
|
|
80
|
+
return !!requests && requests.cpus !== null && requests.memory !== null;
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
exports.requestsEverything = requestsEverything;
|
|
84
|
+
// What the run may request at once: HAMMERKIT_CPUS / HAMMERKIT_MEMORY when
|
|
85
|
+
// set, else the Docker host's when containers run there, else this
|
|
86
|
+
// machine's. Null when nothing requests resources, so such a run never asks.
|
|
87
|
+
async function getResourceCapacity(workTree, environment) {
|
|
88
|
+
if (!hasRequests(workTree)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const capacity = await getDetectedCapacity(workTree, environment);
|
|
92
|
+
const cpus = environment.processEnvs.HAMMERKIT_CPUS;
|
|
93
|
+
const memory = environment.processEnvs.HAMMERKIT_MEMORY;
|
|
94
|
+
try {
|
|
95
|
+
return {
|
|
96
|
+
millicores: cpus ? Math.round((0, units_1.parseCpus)(cpus) * 1000) : capacity.millicores,
|
|
97
|
+
memory: memory ? (0, units_1.parseSize)(memory) : capacity.memory,
|
|
98
|
+
source: cpus || memory ? 'HAMMERKIT_CPUS/HAMMERKIT_MEMORY' : capacity.source,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
throw new Error(`HAMMERKIT_CPUS/HAMMERKIT_MEMORY: ${(0, log_1.getErrorMessage)(e)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.getResourceCapacity = getResourceCapacity;
|
|
106
|
+
async function getDetectedCapacity(workTree, environment) {
|
|
107
|
+
const host = { millicores: (0, os_1.cpus)().length * 1000, memory: (0, os_1.totalmem)(), source: 'this machine' };
|
|
108
|
+
const containers = [...(0, plan_work_tasks_1.iterateWorkTasks)(workTree), ...(0, plan_work_tasks_1.iterateWorkServices)(workTree)].some((item) => item.data.type !== 'local-task' && runsOnHost(item, workTree.environment));
|
|
109
|
+
if (!containers || workTree.environment.type !== 'docker') {
|
|
110
|
+
return host;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const info = await (0, execute_docker_1.getContainerCli)(workTree.environment).info();
|
|
114
|
+
if (info.NCPU > 0 && info.MemTotal > 0) {
|
|
115
|
+
return { millicores: info.NCPU * 1000, memory: info.MemTotal, source: 'the docker host' };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
environment.status
|
|
120
|
+
.context({ type: 'cli', name: 'hammerkit' })
|
|
121
|
+
.write('debug', `unable to read the docker host's resources: ${(0, log_1.getErrorMessage)(e)}`);
|
|
122
|
+
}
|
|
123
|
+
return host;
|
|
124
|
+
}
|
|
125
|
+
// Everything a task has running while it runs: itself and every service it
|
|
126
|
+
// needs, directly or through other services.
|
|
127
|
+
function getFootprint(task, environment) {
|
|
128
|
+
const services = new Map();
|
|
129
|
+
const visit = (item) => {
|
|
130
|
+
for (const need of item.needs) {
|
|
131
|
+
if (!services.has(need.service.name)) {
|
|
132
|
+
services.set(need.service.name, need.service);
|
|
133
|
+
visit(need.service);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
visit(task);
|
|
138
|
+
return [...services.values()].reduce((sum, service) => add(sum, getRequests(service, environment)), {
|
|
139
|
+
...getRequests(task, environment),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// The work that can never fit, whatever runs after what: a task whose
|
|
143
|
+
// requests and those of the services it needs together exceed the capacity,
|
|
144
|
+
// and for `up` the services, which all run at once.
|
|
145
|
+
function findOversizedWork(workTree, capacity, up) {
|
|
146
|
+
const describe = (what, amount) => `${what} request ${formatAmount(amount)} but ${capacity.source} has ${formatAmount(capacity)}`;
|
|
147
|
+
const problems = [];
|
|
148
|
+
for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
|
|
149
|
+
const footprint = getFootprint(task, workTree.environment);
|
|
150
|
+
if (!fitsIn(footprint, capacity)) {
|
|
151
|
+
problems.push({
|
|
152
|
+
item: task,
|
|
153
|
+
message: describe(task.needs.length > 0 ? 'the task and the services it needs' : 'the task', footprint),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (up) {
|
|
158
|
+
const services = [...(0, plan_work_tasks_1.iterateWorkServices)(workTree)];
|
|
159
|
+
const total = services.reduce((sum, service) => add(sum, getRequests(service, workTree.environment)), NONE);
|
|
160
|
+
if (!fitsIn(total, capacity)) {
|
|
161
|
+
for (const service of services) {
|
|
162
|
+
problems.push({ item: service, message: describe('the services together', total) });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return problems;
|
|
167
|
+
}
|
|
168
|
+
exports.findOversizedWork = findOversizedWork;
|
|
169
|
+
//# sourceMappingURL=resource-budget.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getKubernetesResources = void 0;
|
|
4
|
+
// A container's `resources` for its declared requests and limits; nothing
|
|
5
|
+
// without, so the spec stays as before. A request beyond the cluster's
|
|
6
|
+
// capacity leaves the pod Pending, which the running-state wait surfaces.
|
|
7
|
+
function getKubernetesResources(resources) {
|
|
8
|
+
if (!resources) {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
const requirements = {};
|
|
12
|
+
const requests = toQuantities(resources.requests);
|
|
13
|
+
const limits = toQuantities(resources.limits);
|
|
14
|
+
if (requests) {
|
|
15
|
+
requirements.requests = requests;
|
|
16
|
+
}
|
|
17
|
+
if (limits) {
|
|
18
|
+
requirements.limits = limits;
|
|
19
|
+
}
|
|
20
|
+
return { resources: requirements };
|
|
21
|
+
}
|
|
22
|
+
exports.getKubernetesResources = getKubernetesResources;
|
|
23
|
+
function toQuantities(quantities) {
|
|
24
|
+
const result = {};
|
|
25
|
+
if (quantities.cpus !== null) {
|
|
26
|
+
result.cpu = `${Math.round(quantities.cpus * 1000)}m`;
|
|
27
|
+
}
|
|
28
|
+
if (quantities.memory !== null) {
|
|
29
|
+
result.memory = `${quantities.memory}`;
|
|
30
|
+
}
|
|
31
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=container-resources.js.map
|
|
@@ -11,6 +11,7 @@ const replace_env_variables_1 = require("../environment/replace-env-variables");
|
|
|
11
11
|
const await_running_state_1 = require("./await-running-state");
|
|
12
12
|
const resources_1 = require("./resources");
|
|
13
13
|
const work_healthcheck_1 = require("../planner/work-healthcheck");
|
|
14
|
+
const container_resources_1 = require("./container-resources");
|
|
14
15
|
async function ensureKubernetesDeploymentExists(instance, env, service, persistence, secrets, stateKey) {
|
|
15
16
|
const hostAliases = [];
|
|
16
17
|
for (const need of service.needs) {
|
|
@@ -89,6 +90,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
|
|
|
89
90
|
})),
|
|
90
91
|
readinessProbe: probe,
|
|
91
92
|
livenessProbe: probe,
|
|
93
|
+
...(0, container_resources_1.getKubernetesResources)(service.data.resources),
|
|
92
94
|
volumeMounts: [...persistence.mounts.map((m) => m.mount), ...secrets.mounts],
|
|
93
95
|
},
|
|
94
96
|
{
|
|
@@ -33,6 +33,8 @@ function getServiceDefinitionHash(item) {
|
|
|
33
33
|
needs: item.needs
|
|
34
34
|
.filter((need) => (0, work_service_1.isContainerWorkService)(need.service.data))
|
|
35
35
|
.map((need) => `${need.name}=${getServiceDefinitionHash(need.service)}`),
|
|
36
|
+
// only when declared, so a service without keeps its hash
|
|
37
|
+
...(service.resources ? { resources: service.resources } : {}),
|
|
36
38
|
};
|
|
37
39
|
return (0, crypto_1.createHash)('sha1').update(JSON.stringify(definition)).digest('hex');
|
|
38
40
|
}
|
|
@@ -22,6 +22,7 @@ const get_work_runtime_1 = require("./get-work-runtime");
|
|
|
22
22
|
const resolve_cache_1 = require("../../cache/resolve-cache");
|
|
23
23
|
const units_1 = require("../../utils/units");
|
|
24
24
|
const log_1 = require("../../log");
|
|
25
|
+
const work_resources_1 = require("../work-resources");
|
|
25
26
|
function appendWorkService(workTree, service, environment, context) {
|
|
26
27
|
const workService = parseService(workTree, service, environment, context);
|
|
27
28
|
if (!workTree.services[workService.name]) {
|
|
@@ -114,6 +115,7 @@ function parseService(workTree, service, environment, context) {
|
|
|
114
115
|
// set once the service is planned, see appendServiceInit
|
|
115
116
|
init: null,
|
|
116
117
|
secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment),
|
|
118
|
+
resources: (0, work_resources_1.parseWorkResources)(service.schema.resources),
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
121
|
}
|
|
@@ -139,7 +141,8 @@ function appendServiceInit(workTree, service, item, environment, context) {
|
|
|
139
141
|
task.needs.push({ name: item.name, service: view });
|
|
140
142
|
}
|
|
141
143
|
removeRequirer(item, task.name);
|
|
142
|
-
|
|
144
|
+
// explicit, so neither the default method nor `--cache` turns it back on
|
|
145
|
+
task.data.caching = { ...task.data.caching, method: 'none', implicit: false };
|
|
143
146
|
if (task.data.timeout === null) {
|
|
144
147
|
task.data.timeout = getInitTimeout(undefined, environment);
|
|
145
148
|
}
|
|
@@ -18,6 +18,7 @@ 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
20
|
const units_1 = require("../../utils/units");
|
|
21
|
+
const work_resources_1 = require("../work-resources");
|
|
21
22
|
function appendWorkTask(workTree, cwd, referenceTask, environment, context) {
|
|
22
23
|
const task = parseTask(cwd, referenceTask, environment, context);
|
|
23
24
|
if (!workTree.tasks[task.name]) {
|
|
@@ -69,6 +70,7 @@ function parseTask(cwd, task, environment, context) {
|
|
|
69
70
|
continuous: task.schema.continuous ?? false,
|
|
70
71
|
timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
|
|
71
72
|
secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment),
|
|
73
|
+
resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
|
|
72
74
|
};
|
|
73
75
|
if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
|
|
74
76
|
return {
|
package/dist/planner/validate.js
CHANGED
|
@@ -47,6 +47,9 @@ async function* validate(workTree, context) {
|
|
|
47
47
|
if (!task.description) {
|
|
48
48
|
yield { type: 'warn', message: `missing description`, item: task };
|
|
49
49
|
}
|
|
50
|
+
if (task.type === 'local-task' && task.resources) {
|
|
51
|
+
yield { type: 'warn', message: 'resources are not enforced for a local task', item: task };
|
|
52
|
+
}
|
|
50
53
|
if ((!task.cmds || task.cmds.length === 0) && (!item.deps || item.deps.length === 0)) {
|
|
51
54
|
yield { type: 'warn', message: `task is empty`, item: task };
|
|
52
55
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatQuantities = exports.parseWorkResources = void 0;
|
|
4
|
+
const units_1 = require("../utils/units");
|
|
5
|
+
function parseWorkResources(schema) {
|
|
6
|
+
if (!schema) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const shorthand = schema.cpus !== undefined || schema.memory !== undefined;
|
|
10
|
+
const limits = parseQuantities(shorthand ? schema : schema.limits);
|
|
11
|
+
const requests = parseQuantities(schema.requests);
|
|
12
|
+
const resources = {
|
|
13
|
+
limits,
|
|
14
|
+
requests: {
|
|
15
|
+
cpus: requests.cpus ?? limits.cpus,
|
|
16
|
+
memory: requests.memory ?? limits.memory,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
return isEmpty(resources.requests) && isEmpty(resources.limits) ? null : resources;
|
|
20
|
+
}
|
|
21
|
+
exports.parseWorkResources = parseWorkResources;
|
|
22
|
+
function parseQuantities(schema) {
|
|
23
|
+
return {
|
|
24
|
+
cpus: schema?.cpus !== undefined ? (0, units_1.parseCpus)(schema.cpus) : null,
|
|
25
|
+
memory: schema?.memory !== undefined ? (0, units_1.parseSize)(schema.memory) : null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function isEmpty(quantities) {
|
|
29
|
+
return quantities.cpus === null && quantities.memory === null;
|
|
30
|
+
}
|
|
31
|
+
function formatQuantities(quantities) {
|
|
32
|
+
return [
|
|
33
|
+
quantities.cpus !== null ? `${quantities.cpus} cpus` : null,
|
|
34
|
+
quantities.memory !== null ? `${(0, units_1.formatSize)(quantities.memory)} memory` : null,
|
|
35
|
+
]
|
|
36
|
+
.filter((part) => part !== null)
|
|
37
|
+
.join(', ');
|
|
38
|
+
}
|
|
39
|
+
exports.formatQuantities = formatQuantities;
|
|
40
|
+
//# sourceMappingURL=work-resources.js.map
|
|
@@ -25,6 +25,7 @@ const version_1 = require("../version");
|
|
|
25
25
|
const restore_kubernetes_data_1 = require("../kubernetes/restore-kubernetes-data");
|
|
26
26
|
const store_kubernetes_data_1 = require("../kubernetes/store-kubernetes-data");
|
|
27
27
|
const remove_persistent_data_1 = require("../kubernetes/remove-persistent-data");
|
|
28
|
+
const container_resources_1 = require("../kubernetes/container-resources");
|
|
28
29
|
const secrets_1 = require("../kubernetes/secrets");
|
|
29
30
|
async function listJobNames(instance, kubernetes, task) {
|
|
30
31
|
const jobs = await instance.batchApi.listNamespacedJob({
|
|
@@ -117,6 +118,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
|
|
|
117
118
|
],
|
|
118
119
|
name: `cmd-${++i}`,
|
|
119
120
|
volumeMounts: [...persistence.mounts.map((m) => m.mount), ...secrets.mounts],
|
|
121
|
+
...(0, container_resources_1.getKubernetesResources)(task.data.resources),
|
|
120
122
|
},
|
|
121
123
|
],
|
|
122
124
|
volumes: [...persistence.volumes, ...secrets.volumes],
|
package/dist/program.js
CHANGED
|
@@ -421,7 +421,8 @@ async function getProgram(environment, argv) {
|
|
|
421
421
|
.description('start services(s)')
|
|
422
422
|
.addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
|
|
423
423
|
.addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
|
|
424
|
-
.addOption(new commander_1.Option('-c, --concurrency <number>', 'parallel worker count').argParser(parseInt)
|
|
424
|
+
.addOption(new commander_1.Option('-c, --concurrency <number>', 'parallel worker count (default 4, unbounded when every task and service requests cpus and memory)').argParser(parseInt))
|
|
425
|
+
.addOption(new commander_1.Option('--skip-resource-check', 'start even when a task with its services requests more than the host has'))
|
|
425
426
|
.addOption(new commander_1.Option('-w, --watch', 'watch tasks').default(false))
|
|
426
427
|
.addOption(new commander_1.Option('-d, --daemon', 'run services in background').default(false))
|
|
427
428
|
.addOption(new commander_1.Option('--wait <mode>', 'with --daemon, return once services are ready or once they started')
|
|
@@ -443,7 +444,8 @@ async function getProgram(environment, argv) {
|
|
|
443
444
|
const result = await cli.runUp({
|
|
444
445
|
cacheDefault: options.cache,
|
|
445
446
|
watch: options.watch,
|
|
446
|
-
workers: options.concurrency,
|
|
447
|
+
workers: options.concurrency ?? cli.defaultWorkers(),
|
|
448
|
+
resourceCheck: !options.skipResourceCheck,
|
|
447
449
|
logMode: options.log,
|
|
448
450
|
daemon: options.daemon,
|
|
449
451
|
wait: options.wait,
|
|
@@ -493,7 +495,8 @@ async function getProgram(environment, argv) {
|
|
|
493
495
|
.description('execute task')
|
|
494
496
|
.addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
|
|
495
497
|
.addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
|
|
496
|
-
.addOption(new commander_1.Option('-c, --concurrency <number>', 'parallel worker count').argParser(parseInt)
|
|
498
|
+
.addOption(new commander_1.Option('-c, --concurrency <number>', 'parallel worker count (default 4, unbounded when every task and service requests cpus and memory)').argParser(parseInt))
|
|
499
|
+
.addOption(new commander_1.Option('--skip-resource-check', 'start even when a task with its services requests more than the host has'))
|
|
497
500
|
.addOption(new commander_1.Option('-w, --watch', 'watch tasks').default(false))
|
|
498
501
|
.addOption(new commander_1.Option('--env <name>', 'environment'))
|
|
499
502
|
.addOption(new commander_1.Option('-l, --log <mode>', 'log mode')
|
|
@@ -508,7 +511,7 @@ async function getProgram(environment, argv) {
|
|
|
508
511
|
.addOption(new commander_1.Option('--dry-run', 'print the execution plan with predicted cache hits/misses without running').default(false))
|
|
509
512
|
.addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
|
|
510
513
|
.addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
|
|
511
|
-
.addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when
|
|
514
|
+
.addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when nothing needing them has to run'))
|
|
512
515
|
.action(async (task, options) => {
|
|
513
516
|
try {
|
|
514
517
|
// For machine-readable JSON, suppress the human progress logger (which
|
|
@@ -534,7 +537,8 @@ async function getProgram(environment, argv) {
|
|
|
534
537
|
const result = await cli.runExec({
|
|
535
538
|
cacheDefault: options.cache,
|
|
536
539
|
watch: options.watch,
|
|
537
|
-
workers: options.concurrency,
|
|
540
|
+
workers: options.concurrency ?? cli.defaultWorkers(),
|
|
541
|
+
resourceCheck: !options.skipResourceCheck,
|
|
538
542
|
logMode: options.log,
|
|
539
543
|
explain: options.explain,
|
|
540
544
|
cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
|
|
@@ -10,6 +10,7 @@ const build_file_task_command_schema_1 = require("./build-file-task-command-sche
|
|
|
10
10
|
const build_file_volume_schema_1 = require("./build-file-volume-schema");
|
|
11
11
|
const duration_schema_1 = require("./duration-schema");
|
|
12
12
|
const build_file_secret_schema_1 = require("./build-file-secret-schema");
|
|
13
|
+
const resources_schema_1 = require("./resources-schema");
|
|
13
14
|
exports.buildFileBaseTaskSchema = (0, zod_1.object)({
|
|
14
15
|
deps: (0, zod_1.array)((0, zod_1.string)()).optional(),
|
|
15
16
|
needs: (0, zod_1.array)(build_file_need_schema_1.buildFileNeedSchema).optional(),
|
|
@@ -29,5 +30,7 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
|
|
|
29
30
|
// runs longer. Does not affect the cache key (ADR-0002).
|
|
30
31
|
timeout: duration_schema_1.durationSchema.optional(),
|
|
31
32
|
secrets: (0, zod_1.array)(build_file_secret_schema_1.buildFileSecretSchema).optional(),
|
|
33
|
+
// CPU and memory limits of the container; a local task is not limited
|
|
34
|
+
resources: resources_schema_1.resourcesSchema.optional(),
|
|
32
35
|
});
|
|
33
36
|
//# sourceMappingURL=build-file-base-task-schema.js.map
|
|
@@ -7,6 +7,7 @@ const build_file_need_schema_1 = require("./build-file-need-schema");
|
|
|
7
7
|
const labels_schema_1 = require("./labels-schema");
|
|
8
8
|
const build_file_service_container_healthcheck_1 = require("./build-file-service-container-healthcheck");
|
|
9
9
|
const build_file_secret_schema_1 = require("./build-file-secret-schema");
|
|
10
|
+
const resources_schema_1 = require("./resources-schema");
|
|
10
11
|
exports.buildFileContainerServiceSchema = (0, zod_1.object)({
|
|
11
12
|
image: (0, zod_1.string)(),
|
|
12
13
|
description: (0, zod_1.string)().optional(),
|
|
@@ -35,6 +36,7 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
|
|
|
35
36
|
// passed; the service counts as ready for dependents only after it succeeded
|
|
36
37
|
init: (0, zod_1.string)().describe('task run once the service is healthy, before dependents start').optional(),
|
|
37
38
|
secrets: (0, zod_1.array)(build_file_secret_schema_1.buildFileSecretSchema).optional(),
|
|
39
|
+
resources: resources_schema_1.resourcesSchema.optional(),
|
|
38
40
|
})
|
|
39
41
|
.strict()
|
|
40
42
|
.describe('container service');
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resourcesSchema = exports.MIN_MEMORY_BYTES = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const units_1 = require("../utils/units");
|
|
6
|
+
// Docker refuses a container with less memory than this
|
|
7
|
+
exports.MIN_MEMORY_BYTES = 6 * 1024 ** 2;
|
|
8
|
+
function isCpus(value) {
|
|
9
|
+
try {
|
|
10
|
+
(0, units_1.parseCpus)(value);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isMemory(value) {
|
|
18
|
+
try {
|
|
19
|
+
return (0, units_1.parseSize)(value) >= exports.MIN_MEMORY_BYTES;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const cpusSchema = (0, zod_1.union)([(0, zod_1.number)(), (0, zod_1.string)()]).refine(isCpus, (value) => ({
|
|
26
|
+
message: `invalid cpus "${value}", expected cores or millicores, e.g. 2, 0.5 or 500m`,
|
|
27
|
+
}));
|
|
28
|
+
const memorySchema = (0, zod_1.string)().refine(isMemory, (value) => ({
|
|
29
|
+
message: `invalid memory "${value}", expected at least 6Mi, e.g. 512Mi or 2Gi`,
|
|
30
|
+
}));
|
|
31
|
+
const quantitiesSchema = (0, zod_1.object)({
|
|
32
|
+
cpus: cpusSchema.optional(),
|
|
33
|
+
memory: memorySchema.optional(),
|
|
34
|
+
}).strict();
|
|
35
|
+
// CPU and memory in Kubernetes notation: `{cpus, memory}` limits the
|
|
36
|
+
// container and requests the same, `{requests, limits}` sets them apart (a
|
|
37
|
+
// request without a limit is not capped). Requests are what the run schedules
|
|
38
|
+
// by. Not part of the cache key (ADR-0002).
|
|
39
|
+
exports.resourcesSchema = (0, zod_1.object)({
|
|
40
|
+
cpus: cpusSchema.optional(),
|
|
41
|
+
memory: memorySchema.optional(),
|
|
42
|
+
requests: quantitiesSchema.optional(),
|
|
43
|
+
limits: quantitiesSchema.optional(),
|
|
44
|
+
})
|
|
45
|
+
.strict()
|
|
46
|
+
.superRefine((value, ctx) => {
|
|
47
|
+
const shorthand = value.cpus !== undefined || value.memory !== undefined;
|
|
48
|
+
if (shorthand && (value.requests || value.limits)) {
|
|
49
|
+
ctx.addIssue({
|
|
50
|
+
code: 'custom',
|
|
51
|
+
message: 'set either cpus/memory or requests/limits, not both',
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const key of ['cpus', 'memory']) {
|
|
56
|
+
const request = value.requests?.[key];
|
|
57
|
+
const limit = value.limits?.[key];
|
|
58
|
+
if (request === undefined || limit === undefined || !isQuantity(key, request) || !isQuantity(key, limit)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (parseQuantity(key, request) > parseQuantity(key, limit)) {
|
|
62
|
+
ctx.addIssue({
|
|
63
|
+
code: 'custom',
|
|
64
|
+
path: ['requests', key],
|
|
65
|
+
message: `requested ${key} ${request} exceeds the limit ${limit}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
.describe('cpu and memory requests and limits');
|
|
71
|
+
function isQuantity(key, value) {
|
|
72
|
+
return key === 'cpus' ? isCpus(value) : typeof value === 'string' && isMemory(value);
|
|
73
|
+
}
|
|
74
|
+
function parseQuantity(key, value) {
|
|
75
|
+
return key === 'cpus' ? (0, units_1.parseCpus)(value) : (0, units_1.parseSize)(`${value}`);
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=resources-schema.js.map
|
package/dist/utils/units.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.formatSize = exports.parseSize = exports.formatDuration = exports.parseDuration = void 0;
|
|
3
|
+
exports.parseCpus = exports.formatSize = exports.parseSize = exports.formatDuration = exports.parseDuration = void 0;
|
|
4
4
|
const DURATION_UNITS = {
|
|
5
5
|
ms: 1,
|
|
6
6
|
s: 1000,
|
|
@@ -84,4 +84,17 @@ function formatSize(bytes) {
|
|
|
84
84
|
return `${bytes}B`;
|
|
85
85
|
}
|
|
86
86
|
exports.formatSize = formatSize;
|
|
87
|
+
// Parse a CPU quantity in Kubernetes notation, `2`, `0.5` or `500m`
|
|
88
|
+
// (millicores), into cores. Kubernetes' precision is a millicore, so anything
|
|
89
|
+
// finer, and zero, is rejected.
|
|
90
|
+
function parseCpus(value) {
|
|
91
|
+
const text = `${value}`;
|
|
92
|
+
const match = /^(\d+(?:\.\d+)?)(m)?$/.exec(text);
|
|
93
|
+
const millicores = match ? parseFloat(match[1]) * (match[2] ? 1 : 1000) : NaN;
|
|
94
|
+
if (!Number.isInteger(millicores) || millicores <= 0) {
|
|
95
|
+
throw new Error(`invalid cpus "${text}", expected cores or millicores, e.g. 2, 0.5 or 500m`);
|
|
96
|
+
}
|
|
97
|
+
return millicores / 1000;
|
|
98
|
+
}
|
|
99
|
+
exports.parseCpus = parseCpus;
|
|
87
100
|
//# sourceMappingURL=units.js.map
|
package/package.json
CHANGED