hammerkit 1.27.0-next.17 → 1.27.0-next.19

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.explainWorkTree = exports.explainTask = exports.describeCause = void 0;
4
+ const work_task_1 = require("../planner/work-task");
4
5
  const state_key_1 = require("../executer/scheduler/state-key");
5
6
  const work_task_cache_description_1 = require("../optimizer/work-task-cache-description");
6
7
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
@@ -157,6 +158,22 @@ async function explainTask(item, defaultCacheMethod, environment, memo = new Map
157
158
  visiting.delete(item.id());
158
159
  return explanation;
159
160
  }
161
+ // Mirrors executeWorkTask: an aggregate is never looked up itself, and is reported
162
+ // cached when all its dependencies were. What changed is in its dependencies.
163
+ if ((0, work_task_1.isAggregateTask)(item.data)) {
164
+ const causes = [];
165
+ for (const dep of item.deps) {
166
+ const depExplanation = await explainTask(dep, defaultCacheMethod, environment, memo, visiting);
167
+ if (depExplanation.status !== 'hit') {
168
+ causes.push({ kind: 'dependency-changed', identifier: dep.name });
169
+ }
170
+ }
171
+ const status = causes.length === 0 ? 'hit' : 'miss';
172
+ const explanation = { taskId: item.id(), taskName: item.name, status, causes };
173
+ memo.set(item.id(), explanation);
174
+ visiting.delete(item.id());
175
+ return explanation;
176
+ }
160
177
  const runtimeStateKey = await safeCurrentStateKey(item, environment);
161
178
  const hasEntry = await safeHas(item, stateKey, environment);
162
179
  const predictedHit = runtimeStateKey === stateKey || hasEntry;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.executeWorkTask = void 0;
4
+ const work_task_1 = require("../planner/work-task");
4
5
  const states_1 = require("./states");
5
6
  const abort_1 = require("./abort");
6
7
  const log_1 = require("../log");
@@ -12,7 +13,8 @@ const archive_task_entry_1 = require("./archive-task-entry");
12
13
  const units_1 = require("../utils/units");
13
14
  async function pushToBackend(work, environment, cacheState, options) {
14
15
  const { resolved, stateKey, provable } = cacheState;
15
- if (resolved.method === 'none' || !provable) {
16
+ // an aggregate has nothing to store, see checkCacheState
17
+ if (resolved.method === 'none' || !provable || (0, work_task_1.isAggregateTask)(work.data)) {
16
18
  return;
17
19
  }
18
20
  if (options.cacheReadOnly) {
@@ -29,7 +31,7 @@ async function pushToBackend(work, environment, cacheState, options) {
29
31
  }
30
32
  }
31
33
  // A task without cmds only groups its dependencies (`ci: { deps: [test, build] }`).
32
- // Having no src of its own it is never a cache hit, so its dependencies are
34
+ // It is never a cache hit itself (see checkCacheState), so its dependencies are
33
35
  // always checked and their outputs restored. When every one of them was a hit,
34
36
  // nothing ran: report the group as cached too. Reporting only, the cache
35
37
  // decision is untouched.
@@ -89,8 +89,11 @@ exports.prepareMounts = prepareMounts;
89
89
  async function setUserPermissions(item, container, environment) {
90
90
  if (item.data.user) {
91
91
  // On a server only the volumes: a chown as the container's root on a path of the host
92
- // would follow a link a task put there after the binds were checked
93
- const binds = (0, get_container_binds_1.getContainerBinds)(item).filter((bind) => !environment.hosted || !(0, path_1.isAbsolute)(bind.localPath));
92
+ // would follow a link a task put there after the binds were checked. Never a mount: it
93
+ // is a path of the host the build file names, such as /var/run/docker.sock, whose owner
94
+ // is not hammerkit's to change
95
+ const mounted = new Set(item.data.mounts.map((mount) => mount.containerPath));
96
+ const binds = (0, get_container_binds_1.getContainerBinds)(item).filter((bind) => !mounted.has(bind.containerPath) && (!environment.hosted || !(0, path_1.isAbsolute)(bind.localPath)));
94
97
  if (!environment.hosted) {
95
98
  await (0, set_user_permission_1.setUserPermission)(item.data.cwd, item.status, environment, container, item.data.user);
96
99
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.checkCacheState = exports.UNCACHED_STATE_KEY = void 0;
4
4
  const work_item_1 = require("../../planner/work-item");
5
+ const work_task_1 = require("../../planner/work-task");
5
6
  const get_cache_directory_1 = require("../../optimizer/get-cache-directory");
6
7
  const cache_metadata_1 = require("../cache-metadata");
7
8
  const work_task_cache_description_1 = require("../../optimizer/work-task-cache-description");
@@ -35,6 +36,12 @@ options = {}) {
35
36
  item.status.write('debug', `${item.name} always runs, it or one of its dependencies has no src files`);
36
37
  return { cached: false, stateKey, resolved, provable };
37
38
  }
39
+ // An aggregate runs nothing, so running it costs nothing, and it is never a hit itself:
40
+ // asked for, it still has its dependencies checked and their outputs restored. Its key
41
+ // counts for what depends on it, and it is reported cached when they all were.
42
+ if ((0, work_item_1.isWorkTaskItem)(item) && (0, work_task_1.isAggregateTask)(item.data)) {
43
+ return { cached: false, stateKey, resolved, provable };
44
+ }
38
45
  if ((0, work_item_1.isWorkTaskItem)(item)) {
39
46
  const runtimeStateKey = await item.runtime.currentStateKey(environment);
40
47
  if (runtimeStateKey === stateKey) {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.computeStateKey = exports.combineStateKeys = exports.isOwnProvable = exports.resolveEffective = void 0;
4
4
  const get_work_cache_stats_1 = require("../../optimizer/get-work-cache-stats");
5
+ const work_task_1 = require("../../planner/work-task");
5
6
  // The state-key engine is shared by the scheduler's hit/miss decision
6
7
  // (`checkCacheState`) and the read-only cache-explain engine, so a prediction
7
8
  // can never diverge from the decision it predicts — there is one implementation.
@@ -20,6 +21,10 @@ exports.resolveEffective = resolveEffective;
20
21
  // whose `src` entries all match no file (a typo, a moved path). Services are not
21
22
  // affected.
22
23
  function isOwnProvable(item, unmatched) {
24
+ if ((0, work_task_1.isAggregateTask)(item.data)) {
25
+ // it runs nothing and reads nothing: what its dependencies prove is all there is
26
+ return true;
27
+ }
23
28
  // unmatched ⊆ declared, so this is also false when nothing is declared
24
29
  return unmatched.length < item.data.src.filter((src) => !src.inherited).length;
25
30
  }
@@ -17,8 +17,10 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
17
17
  }
18
18
  }
19
19
  }
20
- if (((0, work_task_1.isContainerWorkTask)(depNode.data) || (0, work_service_1.isContainerWorkService)(depNode.data)) &&
21
- ((0, work_task_1.isContainerWorkTask)(item.data) || (0, work_service_1.isContainerWorkService)(item.data))) {
20
+ // an aggregate hands on what its dependencies generate, as if what depends on it
21
+ // depended on them
22
+ if (((0, work_task_1.isContainerWorkTask)(depNode.data) || (0, work_service_1.isContainerWorkService)(depNode.data) || (0, work_task_1.isAggregateTask)(depNode.data)) &&
23
+ ((0, work_task_1.isContainerWorkTask)(item.data) || (0, work_service_1.isContainerWorkService)(item.data) || (0, work_task_1.isAggregateTask)(item.data))) {
22
24
  // the outputs of the dep's own deps too, which stay those of the task that made them
23
25
  // (on Kubernetes, they are in its claim)
24
26
  for (const generate of depNode.data.generates) {
@@ -39,7 +41,7 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
39
41
  });
40
42
  }
41
43
  }
42
- else {
44
+ else if ((0, work_task_1.isWorkTask)(item.data)) {
43
45
  if (!item.data.generates.some((v) => v.volumeName === generate.volumeName)) {
44
46
  item.data.generates.push({
45
47
  volumeName: generate.volumeName,
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.appendWorkTask = void 0;
4
4
  const work_secret_1 = require("../work-secret");
5
+ const work_task_1 = require("../work-task");
5
6
  const parse_work_generate_1 = require("./parse-work-generate");
6
7
  const template_value_1 = require("./template-value");
7
8
  const parse_work_mounts_1 = require("./parse-work-mounts");
@@ -25,7 +26,8 @@ function appendWorkTask(workTree, cwd, referenceTask, environment, context) {
25
26
  const parsed = parseTask(cwd, referenceTask, environment, context);
26
27
  // on a server's cluster a local task runs in a tool image like a container task
27
28
  const server = workTree.environment.type === 'kubernetes' ? workTree.environment.server : undefined;
28
- const task = !server
29
+ // an aggregate runs nothing, so it needs no pod either
30
+ const task = !server || (0, work_task_1.isAggregateTask)(parsed)
29
31
  ? parsed
30
32
  : parsed.type === 'local-task'
31
33
  ? (0, job_placement_1.runInToolImage)(parsed, server, referenceTask.schema.platform)
@@ -107,9 +109,17 @@ function parseTask(cwd, task, environment, context) {
107
109
  throw new Error(`task ${task.relativeName}: network ${(0, work_network_1.formatNetwork)(baseWorkNode.network)} needs a container, ` +
108
110
  `hammerkit can't limit the network of a local task`);
109
111
  }
112
+ const aggregate = task.deps.length > 0 &&
113
+ task.needs.length === 0 &&
114
+ baseWorkNode.src.length === 0 &&
115
+ !baseWorkNode.continuous &&
116
+ baseWorkNode.cmds.length === 0 &&
117
+ baseWorkNode.generates.length === 0 &&
118
+ baseWorkNode.secrets.length === 0;
110
119
  return {
111
120
  ...baseWorkNode,
112
121
  type: 'local-task',
122
+ ...(aggregate ? { aggregate } : {}),
113
123
  };
114
124
  }
115
125
  }
@@ -4,6 +4,8 @@ exports.getWorkTaskRuntime = exports.getWorkServiceRuntime = void 0;
4
4
  const work_runtime_docker_1 = require("../work-runtime-docker");
5
5
  const work_runtime_kubernetes_1 = require("../work-runtime-kubernetes");
6
6
  const work_runtime_local_1 = require("../work-runtime-local");
7
+ const work_runtime_aggregate_1 = require("../work-runtime-aggregate");
8
+ const work_task_1 = require("../work-task");
7
9
  const work_item_1 = require("../work-item");
8
10
  function getWorkServiceRuntime(workTree, service) {
9
11
  if ((0, work_item_1.isContainerWorkServiceItem)(service)) {
@@ -24,7 +26,10 @@ function getWorkServiceRuntime(workTree, service) {
24
26
  }
25
27
  exports.getWorkServiceRuntime = getWorkServiceRuntime;
26
28
  function getWorkTaskRuntime(workTree, task) {
27
- if ((0, work_item_1.isLocalWorkTaskItem)(task)) {
29
+ if ((0, work_task_1.isAggregateTask)(task.data)) {
30
+ return (0, work_runtime_aggregate_1.aggregateWorkRuntime)();
31
+ }
32
+ else if ((0, work_item_1.isLocalWorkTaskItem)(task)) {
28
33
  return (0, work_runtime_local_1.getLocalWorkRuntime)(task);
29
34
  }
30
35
  else if ((0, work_item_1.isContainerWorkTaskItem)(task)) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizePath = void 0;
3
+ exports.normalizeHostPath = exports.getHomeReference = exports.normalizePath = void 0;
4
4
  const path_1 = require("path");
5
5
  function normalizePath(cwd, pwd, path) {
6
6
  if (path.startsWith('/')) {
@@ -14,4 +14,22 @@ function normalizePath(cwd, pwd, path) {
14
14
  }
15
15
  }
16
16
  exports.normalizePath = normalizePath;
17
+ // What a path of the host starts with to name the home directory: `~` and `$HOME`, and
18
+ // `$PWD`, which in a mount has always meant the home directory, not the project it reads like
19
+ function getHomeReference(path) {
20
+ for (const reference of ['~', '$HOME']) {
21
+ if (path === reference || path.startsWith(`${reference}/`) || path.startsWith(`${reference}\\`)) {
22
+ return reference;
23
+ }
24
+ }
25
+ return path.startsWith('$PWD') ? '$PWD' : null;
26
+ }
27
+ exports.getHomeReference = getHomeReference;
28
+ // A path of the host in a mount: in the home directory when it starts with one of the
29
+ // references above, else in the directory of the build file unless it is absolute
30
+ function normalizeHostPath(cwd, home, path) {
31
+ const reference = getHomeReference(path);
32
+ return reference ? (0, path_1.join)(home, path.substring(reference.length)) : normalizePath(cwd, home, path);
33
+ }
34
+ exports.normalizeHostPath = normalizeHostPath;
17
35
  //# sourceMappingURL=normalize-path.js.map
@@ -7,6 +7,12 @@ const path_1 = require("path");
7
7
  function parseWorkMount(cwd, dir) {
8
8
  const parts = dir.split(':');
9
9
  if (parts.length === 1) {
10
+ const home = (0, normalize_path_1.getHomeReference)(dir);
11
+ if (home === '~' || home === '$HOME') {
12
+ // the container has no such directory: the same path on both sides would be one of the
13
+ // project there (`$PWD` has always been, so it stays)
14
+ throw new Error(`mount ${dir} is in the home directory, name where it goes in the container (${dir}:/path)`);
15
+ }
10
16
  return parseLocalMount(cwd, dir, dir, dir, false);
11
17
  }
12
18
  else if (parts.length === 2) {
@@ -22,7 +28,7 @@ function parseWorkMount(cwd, dir) {
22
28
  exports.parseWorkMount = parseWorkMount;
23
29
  function parseLocalMount(cwd, localPath, containerPath, mount, readOnly) {
24
30
  return {
25
- localPath: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), localPath),
31
+ localPath: (0, normalize_path_1.normalizeHostPath)(cwd, (0, os_1.homedir)(), localPath),
26
32
  containerPath: (0, normalize_path_1.normalizePath)(cwd, cwd, containerPath),
27
33
  isFile: (0, path_1.extname)(localPath).length > 1,
28
34
  mount,
@@ -4,6 +4,7 @@ exports.checkIfContextExists = exports.hasDependencyCycle = exports.hasNeedCycle
4
4
  const read_build_file_1 = require("../parser/read-build-file");
5
5
  const plan_work_tasks_1 = require("./utils/plan-work-tasks");
6
6
  const unreferenced_build_file_envs_1 = require("./unreferenced-build-file-envs");
7
+ const normalize_path_1 = require("./utils/normalize-path");
7
8
  async function* validate(workTree, context) {
8
9
  const cycleItems = [];
9
10
  for (const item of (0, plan_work_tasks_1.iterateWorkServices)(workTree)) {
@@ -16,6 +17,10 @@ async function* validate(workTree, context) {
16
17
  yield { type: 'warn', message: 'missing healthcheck', item: service };
17
18
  }
18
19
  for (const mount of service.mounts) {
20
+ const pwd = getPwdMountWarning(mount);
21
+ if (pwd) {
22
+ yield { type: 'warn', message: pwd, item: service };
23
+ }
19
24
  if (!(await context.file.exists(mount.localPath))) {
20
25
  yield {
21
26
  type: 'warn',
@@ -62,6 +67,12 @@ async function* validate(workTree, context) {
62
67
  item: task,
63
68
  };
64
69
  }
70
+ for (const mount of task.type === 'container-task' ? task.mounts : []) {
71
+ const pwd = getPwdMountWarning(mount);
72
+ if (pwd) {
73
+ yield { type: 'warn', message: pwd, item: task };
74
+ }
75
+ }
65
76
  for (const src of task.src) {
66
77
  if (!(await context.file.exists(src.absolutePath))) {
67
78
  yield {
@@ -146,4 +157,15 @@ async function checkIfContextExists(service, context) {
146
157
  return false;
147
158
  }
148
159
  exports.checkIfContextExists = checkIfContextExists;
160
+ // `$PWD` in a mount is the home directory, which reads like a path of the project
161
+ function getPwdMountWarning(mount) {
162
+ const source = mount.mount.split(':')[0];
163
+ if ((0, normalize_path_1.getHomeReference)(source) !== '$PWD') {
164
+ return null;
165
+ }
166
+ const rest = source.substring('$PWD'.length);
167
+ const home = rest === '' || rest.startsWith('/') || rest.startsWith('\\') ? `~${rest}` : '~/…';
168
+ return (`mount ${source} is ${mount.localPath}: $PWD in a mount is the home directory, not the project. ` +
169
+ `Write ${home} for the home directory, or a relative path for the project`);
170
+ }
149
171
  //# sourceMappingURL=validate.js.map
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.applyNetworkCeiling = exports.formatNetwork = exports.getTcpForwards = exports.matchesHost = exports.isLimitedNetwork = exports.toNetworkSchema = exports.exceedsNetwork = exports.getEffectiveNetwork = exports.limitNetwork = exports.parseNetworkOption = exports.parseWorkNetwork = exports.OPEN_NETWORK = void 0;
4
4
  const network_schema_1 = require("../schema/network-schema");
5
+ const work_task_1 = require("./work-task");
5
6
  exports.OPEN_NETWORK = { type: 'open' };
6
7
  function parseWorkNetwork(schema) {
7
8
  if (!schema || schema === 'open') {
@@ -153,7 +154,8 @@ exports.formatNetwork = formatNetwork;
153
154
  function applyNetworkCeiling(workTree, ceiling) {
154
155
  const limit = ceiling && isLimitedNetwork(ceiling) ? ceiling : null;
155
156
  if (limit) {
156
- const local = Object.values(workTree.tasks).find((task) => task.data.type === 'local-task');
157
+ // an aggregate runs nothing, so there is nothing of it to limit
158
+ const local = Object.values(workTree.tasks).find((task) => task.data.type === 'local-task' && !(0, work_task_1.isAggregateTask)(task.data));
157
159
  if (local) {
158
160
  throw new Error(`--network ${formatNetwork(limit)} needs every task in a container, ` +
159
161
  `hammerkit can't limit the network of the local task ${local.name}`);
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.aggregateWorkRuntime = void 0;
4
+ // An aggregate runs nothing, so it touches nothing: no pid or state file in its
5
+ // directory (on a server that is a checkout it doesn't trust), no outputs.
6
+ function aggregateWorkRuntime() {
7
+ return {
8
+ async initialize() {
9
+ // nothing to prepare
10
+ },
11
+ async remove() {
12
+ // it has no outputs of its own
13
+ },
14
+ async execute() {
15
+ // its state stays running, and executeWorkTask completes it
16
+ },
17
+ async stop() {
18
+ // nothing is running
19
+ },
20
+ async archive() {
21
+ // never pushed, see pushToBackend
22
+ },
23
+ async restore() {
24
+ // never a hit, see checkCacheState
25
+ },
26
+ async currentStateKey() {
27
+ return null;
28
+ },
29
+ };
30
+ }
31
+ exports.aggregateWorkRuntime = aggregateWorkRuntime;
32
+ //# sourceMappingURL=work-runtime-aggregate.js.map
@@ -1,8 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isWorkTask = exports.isContainerWorkTask = void 0;
3
+ exports.isAggregateTask = exports.isWorkTask = exports.isContainerWorkTask = void 0;
4
4
  const isContainerWorkTask = (val) => val.type === 'container-task';
5
5
  exports.isContainerWorkTask = isContainerWorkTask;
6
6
  const isWorkTask = (val) => val.type === 'container-task' || val.type === 'local-task';
7
7
  exports.isWorkTask = isWorkTask;
8
+ // A task that only groups its dependencies: `deps`, and no `image`, `cmds`, `src`,
9
+ // `generates`, `secrets`, `needs` or `continuous`. It needs no runtime, hands on what its dependencies generate to what
10
+ // depends on it, and is up to date (or verified) when all its dependencies are.
11
+ const isAggregateTask = (val) => val.type === 'local-task' && val.aggregate === true;
12
+ exports.isAggregateTask = isAggregateTask;
8
13
  //# sourceMappingURL=work-task.js.map
@@ -334,7 +334,7 @@ function registerWorkCommands(remote, context) {
334
334
  throw new Error(`the build file has no task ${(0, server_client_1.printable)(task)}`);
335
335
  }
336
336
  if (found.key === null) {
337
- unkeyed.push(`${(0, server_client_1.printable)(task)}: can not be verified, ${(0, server_client_1.printable)(found.reason)}`);
337
+ unkeyed.push({ task, reason: found.reason });
338
338
  }
339
339
  else {
340
340
  keys.push({ task, key: found.key });
@@ -357,12 +357,14 @@ function registerWorkCommands(remote, context) {
357
357
  environment.stdout.write(`${(0, server_client_1.printable)(task)}: no run on ${serverName} passed it for these files${check?.reason ? `, ${(0, server_client_1.printable)(check.reason)}` : ''}\n`);
358
358
  }
359
359
  }
360
- for (const line of unkeyed) {
361
- environment.stdout.write(`${line}\n`);
360
+ for (const { task, reason } of unkeyed) {
361
+ environment.stdout.write(`${(0, server_client_1.printable)(task)}: can not be verified, ${(0, server_client_1.printable)(reason)}\n`);
362
362
  }
363
363
  if (missing.length > 0 || unkeyed.length > 0) {
364
364
  const run = missing.length > 0 ? `; run on the server: hammerkit remote run ${missing.map(server_client_1.printable).join(' ')}` : '';
365
- fail(`not verified: ${[...missing, ...unkeyed.map((line) => line.split(':')[0])].join(', ')}${run}`, 1);
365
+ // whole names: a task of an include is named like build:all
366
+ const names = [...missing, ...unkeyed.map(({ task }) => task)].map(server_client_1.printable);
367
+ fail(`not verified: ${names.join(', ')}${run}`, 1);
366
368
  }
367
369
  }));
368
370
  remote
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.callServer = exports.getMe = exports.getDiscovery = exports.printableWhole = exports.printable = exports.ServerError = void 0;
3
+ exports.callServer = exports.getMe = exports.getDiscovery = exports.printableWhole = exports.printableLines = exports.printable = exports.ServerError = void 0;
4
4
  const discovery_1 = require("../server/discovery");
5
5
  const TIMEOUT_MS = 10000;
6
6
  const WRITE_TIMEOUT_MS = 30000;
@@ -21,6 +21,17 @@ function printable(value) {
21
21
  return text.length > 300 ? `${text.slice(0, 300)}… (+${text.length - 300} chars)` : text;
22
22
  }
23
23
  exports.printable = printable;
24
+ // Text of several lines, such as every problem of a refused publish: each line made
25
+ // printable and cut like `printable`, one per line, and at most `MAX_LINES` of them.
26
+ const MAX_LINES = 100;
27
+ function printableLines(value) {
28
+ const lines = String(value)
29
+ .split(/\r?\n/)
30
+ .filter((line) => line.trim().length > 0);
31
+ const shown = lines.slice(0, MAX_LINES).map(printable);
32
+ return lines.length > MAX_LINES ? [...shown, `… (+${lines.length - MAX_LINES} lines)`] : shown;
33
+ }
34
+ exports.printableLines = printableLines;
24
35
  // The same, never cut: for what somebody decides on, such as a command.
25
36
  function printableWhole(value) {
26
37
  return String(value)
@@ -92,7 +103,9 @@ async function callServer(server, token, method, path, body) {
92
103
  throw new ServerError(`${server.url} did not accept the token`);
93
104
  }
94
105
  if (!response.ok) {
95
- const said = answer && typeof answer.error === 'string' ? `: ${printable(answer.error)}` : '';
106
+ const lines = answer && typeof answer.error === 'string' ? printableLines(answer.error) : [];
107
+ // a refused publish says every problem, each on a line of its own
108
+ const said = lines.length > 1 ? `:\n${lines.map((line) => ` ${line}`).join('\n')}` : lines.length === 1 ? `: ${lines[0]}` : '';
96
109
  throw new ServerError(`${server.url} answered ${response.status}${said}`, response.status);
97
110
  }
98
111
  return { status: response.status, body: answer };
@@ -7,6 +7,8 @@ exports.runProgram = void 0;
7
7
  const program_1 = require("./program");
8
8
  const parse_error_1 = require("./schema/parse-error");
9
9
  const colors_1 = __importDefault(require("colors"));
10
+ const commander_1 = require("commander");
11
+ const log_1 = require("./log");
10
12
  function runProgram(env, argv, exitOverride) {
11
13
  return (0, program_1.getProgram)(env, argv)
12
14
  .then(({ program, args }) => {
@@ -18,8 +20,15 @@ function runProgram(env, argv, exitOverride) {
18
20
  for (const error of err.zod.errors) {
19
21
  env.stderr.write(` ${colors_1.default.underline(colors_1.default.blue(`error`))} at ${colors_1.default.gray(error.path.join('.'))} ${error.message} ${colors_1.default.gray(error.code)}\n`);
20
22
  }
23
+ // printed, but still a failure: CI must not pass on a build file that doesn't parse
24
+ throw err;
21
25
  }
22
26
  else {
27
+ // what commander reports (program.error, a wrong option) is printed already; anything
28
+ // else, such as a build file that is no YAML, would otherwise end the process silently
29
+ if (!(err instanceof commander_1.CommanderError)) {
30
+ env.stderr.write(`${colors_1.default.red('error')}: ${(0, log_1.getErrorMessage)(err)}\n`);
31
+ }
23
32
  throw err;
24
33
  }
25
34
  });
@@ -210,7 +210,9 @@ function resolveNeeds(reference, taskOrService, scope, schema) {
210
210
  for (const needName of schema.needs || []) {
211
211
  if (typeof needName === 'string') {
212
212
  taskOrService.needs.push({
213
- relativeName: needName,
213
+ // the host name it is reached by: a service of an include, `services:postgres`, as
214
+ // `services-postgres`, since Docker and DNS take no colon
215
+ relativeName: needName.replace(/:/g, '-'),
214
216
  service: findService(reference, scope, needName),
215
217
  });
216
218
  }
@@ -221,6 +223,11 @@ function resolveNeeds(reference, taskOrService, scope, schema) {
221
223
  });
222
224
  }
223
225
  }
226
+ const names = taskOrService.needs.map((need) => need.relativeName);
227
+ const twice = names.find((name, index) => names.indexOf(name) !== index);
228
+ if (twice) {
229
+ throw new Error(`${taskOrService.relativeName} needs two services as ${twice}, give one another name with name:`);
230
+ }
224
231
  }
225
232
  function findTask(ctx, scope, name, type) {
226
233
  const ref = (0, split_name_1.splitName)(name);
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerGithubAppTokenCommand = void 0;
4
+ const commander_1 = require("commander");
4
5
  const log_1 = require("../log");
5
6
  const github_app_token_1 = require("./github-app-token");
6
7
  // `hammerkit github-app-token <owner/repo>`: a secret provider command for the token a
@@ -18,7 +19,8 @@ function registerGithubAppTokenCommand(program, environment) {
18
19
  }
19
20
  catch (e) {
20
21
  environment.stderr.write(`github-app-token: ${(0, log_1.getErrorMessage)(e)}\n`);
21
- throw e;
22
+ // said already: the CLI does not print it again
23
+ throw new commander_1.CommanderError(1, 'hammerkit.githubAppToken', (0, log_1.getErrorMessage)(e));
22
24
  }
23
25
  });
24
26
  }
@@ -13,6 +13,7 @@ const work_scope_parser_1 = require("../schema/work-scope-parser");
13
13
  const work_task_1 = require("../planner/work-task");
14
14
  const work_service_1 = require("../planner/work-service");
15
15
  const hosted_volumes_1 = require("../planner/utils/hosted-volumes");
16
+ const normalize_path_1 = require("../planner/utils/normalize-path");
16
17
  const log_1 = require("../log");
17
18
  const parse_error_1 = require("../schema/parse-error");
18
19
  const task_arguments_1 = require("../planner/utils/task-arguments");
@@ -165,101 +166,127 @@ function describeSecret(secret) {
165
166
  // server has no account, so nothing would stand between the build file and the
166
167
  // operator's own credentials.
167
168
  function findPolicyProblems(items, policy, root) {
168
- const problems = [];
169
+ // the problems of each item, said once for the items of a directory that share one, such
170
+ // as the tasks of an included file that all mount the docker socket
171
+ const grouped = new Map();
169
172
  for (const item of items) {
170
- const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
171
- if (policy.runtimes && !policy.runtimes.includes(runtime)) {
172
- problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
173
- }
174
- const data = item.data;
175
- // what a task reads and writes is the bundle's: `src: ../..` would have the server
176
- // copy its own files (its credentials, other callers' caches) into the task
177
- for (const source of 'src' in data ? data.src : []) {
178
- if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
179
- problems.push(`${item.name}: src ${source.source} is outside of the bundle`);
173
+ const prefix = `${item.name}: `;
174
+ for (const problem of findItemPolicyProblems(item, policy, root)) {
175
+ const said = problem.startsWith(prefix) ? problem.slice(prefix.length) : problem;
176
+ const key = `${item.data.cwd}\0${said}`;
177
+ const group = grouped.get(key);
178
+ if (group) {
179
+ group.names.push(item.name);
180
180
  }
181
- }
182
- for (const generate of (0, work_task_1.isWorkTask)(data) ? data.generates : []) {
183
- if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
184
- problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
181
+ else {
182
+ grouped.set(key, { names: [item.name], problem: said });
185
183
  }
186
184
  }
187
- // what holds a credential gets at most `runs.secretNetwork`, so a tool it runs can't
188
- // send the credential anywhere else
189
- const reads = getCredentialReads({
190
- secrets: secretsOf(item).map(describeSecret),
191
- serverEnvs: 'envs' in data ? data.envs.processNames ?? [] : [],
192
- });
193
- const ceilings = [];
194
- if (policy.network && (0, work_network_1.isLimitedNetwork)(policy.network)) {
195
- ceilings.push({ ceiling: policy.network, setting: 'runs.network', reason: '' });
185
+ }
186
+ return [...grouped.values()].map(({ names: [name, ...others], problem }) => others.length > 0 ? `${name} (also ${others.join(', ')}): ${problem}` : `${name}: ${problem}`);
187
+ }
188
+ function findItemPolicyProblems(item, policy, root) {
189
+ const problems = [];
190
+ const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
191
+ // an aggregate runs nothing, on no runtime
192
+ if (policy.runtimes && !(0, work_task_1.isAggregateTask)(item.data) && !policy.runtimes.includes(runtime)) {
193
+ problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
194
+ }
195
+ const data = item.data;
196
+ // what a task reads and writes is the bundle's: `src: ../..` would have the server
197
+ // copy its own files (its credentials, other callers' caches) into the task
198
+ for (const source of 'src' in data ? data.src : []) {
199
+ if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
200
+ problems.push(`${item.name}: src ${source.source} is outside of the bundle`);
196
201
  }
197
- if (reads.length > 0 && policy.secretNetwork && (0, work_network_1.isLimitedNetwork)(policy.secretNetwork)) {
198
- ceilings.push({
199
- ceiling: policy.secretNetwork,
200
- setting: 'runs.secretNetwork',
201
- reason: ` to what reads ${reads.join(', ')}`,
202
- });
202
+ }
203
+ for (const generate of (0, work_task_1.isWorkTask)(data) ? data.generates : []) {
204
+ if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
205
+ problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
203
206
  }
204
- for (const { ceiling, setting, reason } of ceilings) {
205
- // on a cluster a local task is a pod of a pool's tool image, limited like any other
206
- if (data.type === 'local-task' && policy.backend !== 'kubernetes') {
207
- problems.push(`${item.name}: is a local task, which can't be limited to the network this server allows${reason} ` +
208
- `(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
209
- }
210
- else if ('network' in data && (0, work_network_1.exceedsNetwork)(data.network, ceiling)) {
211
- problems.push(`${item.name}: asks for network ${(0, work_network_1.formatNetwork)(data.network)}, more than this server allows${reason} ` +
212
- `(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
213
- }
207
+ }
208
+ // what holds a credential gets at most `runs.secretNetwork`, so a tool it runs can't
209
+ // send the credential anywhere else
210
+ const reads = getCredentialReads({
211
+ secrets: secretsOf(item).map(describeSecret),
212
+ serverEnvs: 'envs' in data ? data.envs.processNames ?? [] : [],
213
+ });
214
+ const ceilings = [];
215
+ if (policy.network && (0, work_network_1.isLimitedNetwork)(policy.network)) {
216
+ ceilings.push({ ceiling: policy.network, setting: 'runs.network', reason: '' });
217
+ }
218
+ if (reads.length > 0 && policy.secretNetwork && (0, work_network_1.isLimitedNetwork)(policy.secretNetwork)) {
219
+ ceilings.push({
220
+ ceiling: policy.secretNetwork,
221
+ setting: 'runs.secretNetwork',
222
+ reason: ` to what reads ${reads.join(', ')}`,
223
+ });
224
+ }
225
+ // an aggregate reaches nothing: it runs no command
226
+ for (const { ceiling, setting, reason } of (0, work_task_1.isAggregateTask)(data) ? [] : ceilings) {
227
+ // on a cluster a local task is a pod of a pool's tool image, limited like any other
228
+ if (data.type === 'local-task' && policy.backend !== 'kubernetes') {
229
+ problems.push(`${item.name}: is a local task, which can't be limited to the network this server allows${reason} ` +
230
+ `(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
214
231
  }
215
- if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
216
- for (const mount of data.mounts) {
217
- // a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
218
- if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
219
- problems.push(`${item.name}: mounts ${mount.localPath}, which is outside of the bundle`);
220
- }
221
- }
232
+ else if ('network' in data && (0, work_network_1.exceedsNetwork)(data.network, ceiling)) {
233
+ problems.push(`${item.name}: asks for network ${(0, work_network_1.formatNetwork)(data.network)}, more than this server allows${reason} ` +
234
+ `(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
222
235
  }
223
- for (const volume of (0, work_service_1.isContainerWorkService)(data) ? data.volumes : []) {
224
- // docker takes anything else for a path of the host, such as the docker socket
225
- if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
226
- problems.push(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
236
+ }
237
+ if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
238
+ for (const mount of data.mounts) {
239
+ // a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
240
+ if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
241
+ problems.push(`${item.name}: mounts ${describeMountSource(mount)}, which is outside of the bundle`);
227
242
  }
228
243
  }
229
- for (const secret of secretsOf(item)) {
230
- const source = secret.source;
231
- if (policy.backend === 'kubernetes' && source.type === 'provider') {
232
- // the server reads no secret of a provider on a cluster: the Job of a task does
233
- if (!(0, work_task_1.isWorkTask)(item.data)) {
234
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider}, which ` +
235
- 'this server does for tasks only, a service on its cluster gets no provider secret yet');
236
- }
237
- else if (secret.cache) {
238
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
239
- 'the value before the Job of the task reads it, which this server does not support yet');
240
- }
241
- else if ((0, work_network_1.isLimitedNetwork)(item.data.network) && !policy.proxiedProviders?.includes(source.provider)) {
242
- // the init containers that read it share the pod's NetworkPolicy, and reach the
243
- // provider only through the egress proxy
244
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
245
- `Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it: ` +
246
- `this server names no endpoints of ${source.provider} in backend.providers.${source.provider}.allow ` +
247
- 'for the egress proxy to let through, so the task keeps an open network');
248
- }
249
- }
250
- if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
251
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
244
+ }
245
+ for (const volume of (0, work_service_1.isContainerWorkService)(data) ? data.volumes : []) {
246
+ // docker takes anything else for a path of the host, such as the docker socket
247
+ if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
248
+ problems.push(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
249
+ }
250
+ }
251
+ for (const secret of secretsOf(item)) {
252
+ const source = secret.source;
253
+ if (policy.backend === 'kubernetes' && source.type === 'provider') {
254
+ // the server reads no secret of a provider on a cluster: the Job of a task does
255
+ if (!(0, work_task_1.isWorkTask)(item.data)) {
256
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider}, which ` +
257
+ 'this server does for tasks only, a service on its cluster gets no provider secret yet');
252
258
  }
253
- else if (source.type === 'file') {
254
- problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
259
+ else if (secret.cache) {
260
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
261
+ 'the value before the Job of the task reads it, which this server does not support yet');
255
262
  }
256
- else if (source.type === 'env' && !policy.envs.includes(source.name)) {
257
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in ${policy.envsFrom ?? 'runs.env.allow'}`);
263
+ else if ((0, work_network_1.isLimitedNetwork)(item.data.network) && !policy.proxiedProviders?.includes(source.provider)) {
264
+ // the init containers that read it share the pod's NetworkPolicy, and reach the
265
+ // provider only through the egress proxy
266
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
267
+ `Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it: ` +
268
+ `this server names no endpoints of ${source.provider} in backend.providers.${source.provider}.allow ` +
269
+ 'for the egress proxy to let through, so the task keeps an open network');
258
270
  }
259
271
  }
272
+ if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
273
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
274
+ }
275
+ else if (source.type === 'file') {
276
+ problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
277
+ }
278
+ else if (source.type === 'env' && !policy.envs.includes(source.name)) {
279
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in ${policy.envsFrom ?? 'runs.env.allow'}`);
280
+ }
260
281
  }
261
282
  return problems;
262
283
  }
284
+ // A mount of the home directory says so: `$PWD` in a mount is the home directory, not the
285
+ // project, which reads like a path of the checkout
286
+ function describeMountSource(mount) {
287
+ const home = (0, normalize_path_1.getHomeReference)(mount.mount.split(':')[0]);
288
+ return home ? `${mount.localPath} (${home} is the home directory)` : mount.localPath;
289
+ }
263
290
  function toPlan(root, item) {
264
291
  const data = item.data;
265
292
  const secrets = secretsOf(item).map(describeSecret);
@@ -272,7 +299,7 @@ function toPlan(root, item) {
272
299
  : [];
273
300
  const ports = 'ports' in data ? data.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`) : [];
274
301
  const flags = [];
275
- if (data.type === 'local-task') {
302
+ if (data.type === 'local-task' && !(0, work_task_1.isAggregateTask)(data)) {
276
303
  flags.push('local-task');
277
304
  }
278
305
  if ('ports' in data && data.ports.some((port) => port.hostPort !== null)) {
@@ -285,7 +312,7 @@ function toPlan(root, item) {
285
312
  return {
286
313
  kind: isTask ? 'task' : 'service',
287
314
  ...(data.description ? { description: data.description } : {}),
288
- runtime: data.type === 'local-task' ? 'local' : 'docker',
315
+ runtime: (0, work_task_1.isAggregateTask)(data) ? 'none' : data.type === 'local-task' ? 'local' : 'docker',
289
316
  ...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
290
317
  cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
291
318
  command: (0, task_arguments_1.getArgumentsTarget)(data)?.cmd ?? null,
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.27.0-next.17",
4
+ "version": "1.27.0-next.19",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",