hammerkit 1.11.3 → 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 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
@@ -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
- container = await docker.createContainer(buildServiceCreateOptions(item, options, network, secrets));
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 containerOptions = buildCreateOptions(item, options.stateKey, serviceContainers, environment, secrets);
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);
@@ -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
- environment.stdout.write(` ${index + 1}. ${entry.taskName}: ${label}${causes}\n`);
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
- await work.runtime.execute(environment, {
54
- cache: cacheState,
55
- abort,
56
- state: init ? gateOnInit(work, init) : work.state,
57
- stateKey: cacheState.stateKey,
58
- daemon: options.daemon,
59
- publishPorts: options.type === 'up' || work.requiredBy.some(isLocalTaskItem) || (!!init && isLocalTaskItem(init.task)),
60
- // an init needs a healthy service, so a service with one is always awaited
61
- waitForReady: options.wait === 'ready' || work.requiredBy.length > 0 || !!init,
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) {
@@ -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
- constructor(workerLimit) {
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
- startProcess(pendingProcess) {
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
  }
@@ -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 {
@@ -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).default(4))
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).default(4))
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')
@@ -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
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.11.3",
4
+ "version": "1.12.0",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",