hammerkit 1.24.0 → 1.26.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.
Files changed (43) hide show
  1. package/dist/executer/container-task-state.js +8 -6
  2. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +1 -1
  3. package/dist/optimizer/get-cache-directory.js +5 -0
  4. package/dist/planner/service-definition.js +3 -2
  5. package/dist/planner/utils/task-arguments.js +155 -0
  6. package/dist/planner/work-cache-id.js +15 -1
  7. package/dist/planner/work-runtime-docker.js +2 -0
  8. package/dist/planner/work-runtime-kubernetes.js +2 -1
  9. package/dist/planner/work-runtime-local.js +16 -8
  10. package/dist/planner/work-secret.js +6 -2
  11. package/dist/planner/work-service.js +7 -3
  12. package/dist/planner/work-state-record.js +30 -0
  13. package/dist/program.js +61 -7
  14. package/dist/remote/remote-commands.js +16 -12
  15. package/dist/remote/remote-work-commands.js +75 -3
  16. package/dist/schema/reference-parser.js +8 -2
  17. package/dist/schema/work-scope-parser.js +8 -0
  18. package/dist/secrets/provider-values.js +4 -0
  19. package/dist/server/accounts-api.js +42 -0
  20. package/dist/server/approvals-api.js +11 -6
  21. package/dist/server/definitions-api.js +65 -21
  22. package/dist/server/definitions.js +54 -142
  23. package/dist/server/executor.js +180 -181
  24. package/dist/server/git-checkout.js +55 -3
  25. package/dist/server/plan.js +296 -0
  26. package/dist/server/policy.js +98 -50
  27. package/dist/server/proposals-api.js +30 -8
  28. package/dist/server/proposals.js +50 -3
  29. package/dist/server/repo-access.js +127 -0
  30. package/dist/server/repo.js +94 -0
  31. package/dist/server/run-environment.js +20 -0
  32. package/dist/server/run-plan.js +44 -0
  33. package/dist/server/run-request.js +23 -5
  34. package/dist/server/run-store.js +61 -0
  35. package/dist/server/run-worker-command.js +17 -0
  36. package/dist/server/run-worker-main.js +7 -0
  37. package/dist/server/run-worker.js +179 -0
  38. package/dist/server/runs.js +189 -36
  39. package/dist/server/server-command.js +18 -0
  40. package/dist/server/server-config.js +203 -42
  41. package/dist/server/server.js +148 -52
  42. package/dist/server/sources-api.js +1 -1
  43. package/package.json +1 -1
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.removeContainerTaskState = exports.writeContainerTaskState = exports.readContainerTaskState = exports.getContainerTaskStateFile = void 0;
4
4
  const path_1 = require("path");
5
5
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
6
- const work_instance_id_1 = require("../planner/work-instance-id");
6
+ const work_state_record_1 = require("../planner/work-state-record");
7
7
  // The record says "the outputs of this container task belong to this state
8
- // key", like the state file of a local task. It lives in the hammerkit
9
- // directory, not the project, so writing it never touches a task's src.
8
+ // key", like the state file of a local task (see work-state-record.ts). It lives
9
+ // in the hammerkit directory, not the project, so writing it never touches a
10
+ // task's src; the record name folds in the project root, so two checkouts keep
11
+ // their own.
10
12
  function getContainerTaskStateFile(task) {
11
- return (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'state', (0, work_instance_id_1.getWorkInstanceId)(task));
13
+ return (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'state', (0, work_state_record_1.getStateRecordName)(task));
12
14
  }
13
15
  exports.getContainerTaskStateFile = getContainerTaskStateFile;
14
16
  async function readContainerTaskState(environment, task) {
@@ -16,12 +18,12 @@ async function readContainerTaskState(environment, task) {
16
18
  if (!(await environment.file.exists(stateFile))) {
17
19
  return null;
18
20
  }
19
- return (await environment.file.read(stateFile)).trim() || null;
21
+ return (0, work_state_record_1.parseStateRecord)(task, await environment.file.read(stateFile));
20
22
  }
21
23
  exports.readContainerTaskState = readContainerTaskState;
22
24
  async function writeContainerTaskState(environment, task, stateKey) {
23
25
  await environment.file.createDirectory((0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'state'));
24
- await environment.file.writeFile(getContainerTaskStateFile(task), stateKey);
26
+ await environment.file.writeFile(getContainerTaskStateFile(task), (0, work_state_record_1.formatStateRecord)(task, stateKey));
25
27
  }
26
28
  exports.writeContainerTaskState = writeContainerTaskState;
27
29
  async function removeContainerTaskState(environment, task) {
@@ -30,7 +30,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
30
30
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(service.data.envs);
31
31
  const name = (0, resources_1.getResourceName)(service);
32
32
  const healthcheckCommand = (0, work_service_1.getHealthcheckCommand)(service.data);
33
- const serviceCommand = (0, work_service_1.getServiceCommand)(service.data);
33
+ const serviceCommand = (0, work_service_1.getServiceCommand)(service.data, { kubernetes: true });
34
34
  const probe = healthcheckCommand
35
35
  ? {
36
36
  exec: {
@@ -14,7 +14,12 @@ function getCacheStatsFile(cacheId) {
14
14
  return (0, path_1.join)(getCacheDirectory(cacheId), 'stats.json');
15
15
  }
16
16
  exports.getCacheStatsFile = getCacheStatsFile;
17
+ // `HAMMERKIT_DIR` puts hammerkit's directory (the cache) elsewhere than below the home directory;
18
+ // a server uses it to keep the caches of runs apart.
17
19
  function getHammerkitDirectory() {
20
+ if (process.env.HAMMERKIT_DIR) {
21
+ return process.env.HAMMERKIT_DIR;
22
+ }
18
23
  if (process.platform === 'win32') {
19
24
  return (0, path_1.join)(process.env.APPDATA, 'hammerkit');
20
25
  }
@@ -4,6 +4,7 @@ exports.getServiceDefinitionHash = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const work_secret_1 = require("./work-secret");
6
6
  const work_service_1 = require("./work-service");
7
+ const work_cache_id_1 = require("./work-cache-id");
7
8
  // Everything a running container of the service was created from, plus the
8
9
  // definitions of the services it needs: a dependent links to the container it
9
10
  // started with, so a recreated need means a recreated dependent too. Stored as
@@ -30,8 +31,8 @@ function getServiceDefinitionHash(item) {
30
31
  ? `provider:${(0, work_secret_1.getSecretName)(s)}:${(0, work_secret_1.describeSecretSource)(s)}`
31
32
  : `${s.source.type}:${(0, work_secret_1.getSecretName)(s)}`),
32
33
  // the init task's id covers its image, commands, envs and mounts: a changed
33
- // init recreates the service, so it runs again
34
- init: service.init ? service.init.task.id() : null,
34
+ // init recreates the service, so it runs again (arguments after -- don't)
35
+ init: service.init ? (0, work_cache_id_1.getDeclaredWorkTaskId)(service.init.task) : null,
35
36
  needs: item.needs
36
37
  .filter((need) => (0, work_service_1.isContainerWorkService)(need.service.data))
37
38
  .map((need) => `${need.name}=${getServiceDefinitionHash(need.service)}`),
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isRefusedByWindowsPowerShell = exports.applyArguments = exports.getArgumentsTarget = exports.getKubernetesCommandArgs = exports.getKubernetesCommandText = exports.escapeKubernetesText = exports.appendArguments = exports.quoteArgument = exports.getShellQuoting = void 0;
4
+ const os_1 = require("os");
5
+ const POSIX_SHELLS = ['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'mksh'];
6
+ const POWERSHELLS = ['powershell', 'pwsh'];
7
+ // The quoting of a shell, by the name of its program; null for a shell whose
8
+ // quoting isn't known (fish, cmd.exe, …). Guessing would let shell syntax in an
9
+ // argument run as a second command, so such a shell takes no arguments.
10
+ function getShellQuoting(shell) {
11
+ const program = shell.trim().split(/\s+/)[0] ?? '';
12
+ const name = (program.split(/[\\/]/).pop() ?? '').toLowerCase().replace(/\.exe$/, '');
13
+ if (POSIX_SHELLS.includes(name)) {
14
+ return 'posix';
15
+ }
16
+ if (POWERSHELLS.includes(name)) {
17
+ return 'powershell';
18
+ }
19
+ return null;
20
+ }
21
+ exports.getShellQuoting = getShellQuoting;
22
+ // Single quotes take everything literally in both shells. In sh a quote ends the
23
+ // string, so it is closed, escaped and reopened; PowerShell doubles it, and reads
24
+ // the typographic single quotes as quotes too.
25
+ function quoteArgument(arg, quoting) {
26
+ if (quoting === 'powershell') {
27
+ return `'${arg.replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
28
+ }
29
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
30
+ }
31
+ exports.quoteArgument = quoteArgument;
32
+ function appendArguments(command, args, quoting) {
33
+ // an exec-form command is shown as sh would read it; it runs from `parsed`
34
+ const display = quoting === 'exec' ? 'posix' : quoting;
35
+ return {
36
+ cwd: command.cwd,
37
+ cmd: [command.cmd, ...args.map((arg) => quoteArgument(arg, display))].join(' '),
38
+ parsed: {
39
+ command: command.parsed.command,
40
+ args: [...command.parsed.args, ...args],
41
+ },
42
+ arguments: { cmd: command.cmd.length, args: command.parsed.args.length },
43
+ };
44
+ }
45
+ exports.appendArguments = appendArguments;
46
+ // Kubernetes expands `$(NAME)` with the container's env and turns `$$` into `$`
47
+ // in a container's command and args, before any shell sees them, so a quoted
48
+ // argument isn't text there unless its `$` is doubled. The part of the command
49
+ // the build file declares keeps its meaning.
50
+ function escapeKubernetesText(text) {
51
+ return text.replace(/\$/g, '$$$$');
52
+ }
53
+ exports.escapeKubernetesText = escapeKubernetesText;
54
+ function getKubernetesCommandText(command) {
55
+ if (!command.arguments) {
56
+ return command.cmd;
57
+ }
58
+ const from = command.arguments.cmd;
59
+ return command.cmd.slice(0, from) + escapeKubernetesText(command.cmd.slice(from));
60
+ }
61
+ exports.getKubernetesCommandText = getKubernetesCommandText;
62
+ function getKubernetesCommandArgs(command) {
63
+ const from = command.arguments?.args ?? command.parsed.args.length;
64
+ return command.parsed.args.map((arg, index) => (index >= from ? escapeKubernetesText(arg) : arg));
65
+ }
66
+ exports.getKubernetesCommandArgs = getKubernetesCommandArgs;
67
+ // The command arguments after `--` land in: the last command of a task, the
68
+ // `cmd` of a container service. Null when there is none.
69
+ function getArgumentsTarget(item) {
70
+ if (item.type === 'local-task' || item.type === 'container-task') {
71
+ return item.cmds[item.cmds.length - 1] ?? null;
72
+ }
73
+ if (item.type === 'container-service') {
74
+ return item.cmd;
75
+ }
76
+ return null;
77
+ }
78
+ exports.getArgumentsTarget = getArgumentsTarget;
79
+ // Appends the arguments to the one task or service a run selects, before
80
+ // anything asks for its id: the last command is part of the task id, so the
81
+ // arguments are part of its cache key. A run with arguments is a one-off (one
82
+ // test, one flag), so the task skips the cache unless `keepCache`; even then the
83
+ // key keeps runs with other arguments apart. Dependencies and needed services
84
+ // are planned items of their own and keep their commands and caching. No
85
+ // arguments change nothing, so a task run without them keeps its cache entries.
86
+ function applyArguments(selection, args, keepCache = false) {
87
+ if (args.length === 0) {
88
+ return;
89
+ }
90
+ const selected = [...selection.tasks, ...selection.services];
91
+ if (selected.length === 0) {
92
+ return;
93
+ }
94
+ if (selected.length > 1) {
95
+ throw new Error(`arguments after -- go to one task or service, but this run selects ${selected.map((s) => s.name).join(', ')}`);
96
+ }
97
+ if (args.some((arg) => arg.includes('\0'))) {
98
+ throw new Error('an argument after -- contains a NUL character');
99
+ }
100
+ const item = selected[0];
101
+ if (item.type === 'kubernetes-service') {
102
+ throw new Error(`service ${item.name} is a kubernetes service and has no cmd for the arguments after --`);
103
+ }
104
+ const target = getArgumentsTarget(item);
105
+ if (!target) {
106
+ throw new Error(item.type === 'container-service'
107
+ ? `service ${item.name} has no cmd for the arguments after --`
108
+ : `task ${item.name} has no command for the arguments after --`);
109
+ }
110
+ const quoting = getItemQuoting(item);
111
+ if (item.type === 'local-task' && quoting === 'powershell') {
112
+ const refused = args.find(isRefusedByWindowsPowerShell);
113
+ if (refused !== undefined) {
114
+ throw new Error(`task ${item.name} runs in Windows PowerShell, which would split or run the argument ${JSON.stringify(refused)}, so it is refused: an empty argument, a ", the characters & | < > ^ % !, line breaks, and a space with a trailing \\`);
115
+ }
116
+ }
117
+ if (item.type === 'container-service') {
118
+ item.cmd = appendArguments(target, args, quoting);
119
+ }
120
+ else {
121
+ item.cmds = [...item.cmds.slice(0, -1), appendArguments(target, args, quoting)];
122
+ if (!keepCache) {
123
+ // not implicit, so the run's --cache default doesn't bring the method back
124
+ item.caching = { ...item.caching, method: 'none', implicit: false };
125
+ }
126
+ }
127
+ }
128
+ exports.applyArguments = applyArguments;
129
+ // Windows PowerShell 5.1 builds a new command line for a native program, and
130
+ // starts a .cmd or .bat shim (yarn.cmd, most of node_modules/.bin) through
131
+ // cmd.exe. There an empty argument is dropped, a " splits or joins arguments,
132
+ // an argument with a space that ends in \ is quoted as "a b\" and so escapes
133
+ // its own closing quote, and cmd reads & | < > ^ % ! and a line break as
134
+ // syntax, so such an argument can't be passed as text.
135
+ function isRefusedByWindowsPowerShell(arg) {
136
+ return arg === '' || /["&|<>^%!\r\n]/.test(arg) || (/\s/.test(arg) && arg.endsWith('\\'));
137
+ }
138
+ exports.isRefusedByWindowsPowerShell = isRefusedByWindowsPowerShell;
139
+ function getItemQuoting(item) {
140
+ if (item.type === 'local-task') {
141
+ // what executeCommand runs a local command in
142
+ return (0, os_1.platform)() === 'win32' ? 'powershell' : 'posix';
143
+ }
144
+ const shell = item.type === 'kubernetes-service' ? null : item.shell;
145
+ if (shell === null) {
146
+ return 'exec';
147
+ }
148
+ const quoting = getShellQuoting(shell);
149
+ if (!quoting) {
150
+ throw new Error(`${item.type === 'container-task' ? 'task' : 'service'} ${item.name} runs in ${shell}, whose quoting hammerkit ` +
151
+ `doesn't know, so it takes no arguments after --; use sh, bash, zsh, dash, ash, ksh, mksh or PowerShell`);
152
+ }
153
+ return quoting;
154
+ }
155
+ //# sourceMappingURL=task-arguments.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getWorkTaskId = void 0;
3
+ exports.getDeclaredWorkTaskId = exports.getWorkTaskId = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const work_task_cache_description_1 = require("../optimizer/work-task-cache-description");
6
6
  function getWorkTaskId(item) {
@@ -9,4 +9,18 @@ function getWorkTaskId(item) {
9
9
  return (0, crypto_1.createHash)('sha1').update(jsonData).digest('hex');
10
10
  }
11
11
  exports.getWorkTaskId = getWorkTaskId;
12
+ // The id of the task as the build file declares it: without the arguments
13
+ // after -- a run appended to its last command. A service's definition takes its
14
+ // init task by this id, so `run <init> -- ...` leaves a running service as it is.
15
+ function getDeclaredWorkTaskId(item) {
16
+ const description = (0, work_task_cache_description_1.getWorkTaskCacheDescription)(item);
17
+ const cmds = description.cmds?.map((cmd, index) => {
18
+ const declared = item.data.cmds[index];
19
+ return declared.arguments ? { ...cmd, cmd: declared.cmd.slice(0, declared.arguments.cmd) } : cmd;
20
+ });
21
+ return (0, crypto_1.createHash)('sha1')
22
+ .update(JSON.stringify({ ...description, cmds }))
23
+ .digest('hex');
24
+ }
25
+ exports.getDeclaredWorkTaskId = getDeclaredWorkTaskId;
12
26
  //# sourceMappingURL=work-cache-id.js.map
@@ -39,6 +39,8 @@ function dockerTaskRuntime(task, workEnvironment) {
39
39
  });
40
40
  },
41
41
  async restore(environment, path) {
42
+ // the restored outputs aren't the ones the record names any more
43
+ await (0, container_task_state_1.removeContainerTaskState)(environment, task);
42
44
  await restoreContainer(docker, environment, task, path);
43
45
  },
44
46
  async archive(environment, path) {
@@ -22,6 +22,7 @@ const ensure_http_route_1 = require("../kubernetes/ensure-http-route");
22
22
  const find_process_1 = __importDefault(require("find-process"));
23
23
  const log_1 = require("../log");
24
24
  const version_1 = require("../version");
25
+ const task_arguments_1 = require("./utils/task-arguments");
25
26
  const restore_kubernetes_data_1 = require("../kubernetes/restore-kubernetes-data");
26
27
  const store_kubernetes_data_1 = require("../kubernetes/store-kubernetes-data");
27
28
  const remove_persistent_data_1 = require("../kubernetes/remove-persistent-data");
@@ -110,7 +111,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
110
111
  image: task.data.image,
111
112
  // through the task's shell, as on Docker
112
113
  command: [task.data.shell, '-c'],
113
- args: [cmd.cmd],
114
+ args: [(0, task_arguments_1.getKubernetesCommandText)(cmd)],
114
115
  workingDir: cmd.cwd,
115
116
  env: [
116
117
  ...Object.entries(envs).map(([key, value]) => ({ name: key, value })),
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getLocalWorkRuntime = void 0;
6
+ exports.getLocalWorkRuntime = exports.getStateFilename = void 0;
7
7
  const tar_1 = require("tar");
8
8
  const path_1 = require("path");
9
9
  const local_task_1 = require("../executer/local-task");
@@ -11,9 +11,11 @@ const event_cache_1 = require("../executer/event-cache");
11
11
  const get_outputs_to_reset_1 = require("./utils/get-outputs-to-reset");
12
12
  const find_process_1 = __importDefault(require("find-process"));
13
13
  const stream_1 = require("stream");
14
+ const work_state_record_1 = require("./work-state-record");
14
15
  function getStateFilename(task) {
15
- return (0, path_1.join)(task.data.cwd, '.hammerkit', `${task.id()}`);
16
+ return (0, path_1.join)(task.data.cwd, '.hammerkit', 'state', (0, work_state_record_1.getStateRecordName)(task));
16
17
  }
18
+ exports.getStateFilename = getStateFilename;
17
19
  function getPidFilename(task) {
18
20
  return (0, path_1.join)(task.data.cwd, '.hammerkit', `${task.id()}.pid`);
19
21
  }
@@ -39,6 +41,8 @@ function getLocalWorkRuntime(task) {
39
41
  // pidfile check happens in execute (initialize has no Environment in scope)
40
42
  },
41
43
  async restore(environment, path) {
44
+ // the restored outputs aren't the ones the record names any more
45
+ await removeStateFile(environment, task);
42
46
  await restoreLocal(environment, task.data, path);
43
47
  },
44
48
  async archive(environment, path) {
@@ -54,7 +58,7 @@ function getLocalWorkRuntime(task) {
54
58
  });
55
59
  return;
56
60
  }
57
- await environment.file.createDirectory((0, path_1.join)(task.data.cwd, '.hammerkit'));
61
+ await environment.file.createDirectory((0, path_1.join)(task.data.cwd, '.hammerkit', 'state'));
58
62
  const pidFile = getPidFilename(task);
59
63
  await environment.file.writeFile(pidFile, `${process.pid}`);
60
64
  try {
@@ -62,16 +66,14 @@ function getLocalWorkRuntime(task) {
62
66
  // fails overwrites outputs, so the previous record goes first, and only
63
67
  // a run that succeeded writes a new one.
64
68
  const stateFile = getStateFilename(task);
65
- if (await environment.file.exists(stateFile)) {
66
- await environment.file.remove(stateFile);
67
- }
69
+ await removeStateFile(environment, task);
68
70
  for (const generate of (0, get_outputs_to_reset_1.getOutputsToReset)(task.data)) {
69
71
  await environment.file.remove(generate.path);
70
72
  }
71
73
  await (0, local_task_1.localTask)(task, environment, options);
72
74
  const failed = ['crash', 'error', 'canceled'].includes(options.state.current.type);
73
75
  if (!failed) {
74
- await environment.file.writeFile(stateFile, options.stateKey);
76
+ await environment.file.writeFile(stateFile, (0, work_state_record_1.formatStateRecord)(task, options.stateKey));
75
77
  }
76
78
  }
77
79
  finally {
@@ -95,7 +97,7 @@ function getLocalWorkRuntime(task) {
95
97
  return null;
96
98
  }
97
99
  }
98
- return await environment.file.read(stateFileName);
100
+ return (0, work_state_record_1.parseStateRecord)(task, await environment.file.read(stateFileName));
99
101
  },
100
102
  async remove(environment) {
101
103
  for (const generate of task.data.generates) {
@@ -109,6 +111,12 @@ function getLocalWorkRuntime(task) {
109
111
  };
110
112
  }
111
113
  exports.getLocalWorkRuntime = getLocalWorkRuntime;
114
+ async function removeStateFile(environment, task) {
115
+ const stateFile = getStateFilename(task);
116
+ if (await environment.file.exists(stateFile)) {
117
+ await environment.file.remove(stateFile);
118
+ }
119
+ }
112
120
  async function archiveLocal(environment, task, path) {
113
121
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(task.data, path)) {
114
122
  await environment.file.writeStream(generatedArchive.filename,
@@ -66,9 +66,13 @@ function parseSecretSource(secret, ref, cwd, envs, catalog, taskAccount, label)
66
66
  if (providerRef.length === 0 || providerRef.startsWith('-')) {
67
67
  throw new Error(`${label}: a reference for ${kind} must not be empty or start with -`);
68
68
  }
69
- const accountName = secret.account ?? taskAccount ?? Object.values(catalog.accounts).find((a) => a.default)?.name;
69
+ const accountName = catalog.explicitAccounts
70
+ ? secret.account
71
+ : secret.account ?? taskAccount ?? Object.values(catalog.accounts).find((a) => a.default)?.name;
70
72
  if (!accountName) {
71
- throw new Error(`${label}: ${kind}:${providerRef} needs an account, and none is named or marked default: true`);
73
+ throw new Error(catalog.explicitAccounts
74
+ ? `${label}: ${kind}:${providerRef} needs account: on the secret, a server uses neither a task's account nor a default`
75
+ : `${label}: ${kind}:${providerRef} needs an account, and none is named or marked default: true`);
72
76
  }
73
77
  const account = catalog.accounts[accountName];
74
78
  if (!account) {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getHealthcheckCommand = exports.getServiceCommand = exports.getServiceWorkingDir = exports.isContainerWorkService = void 0;
4
+ const task_arguments_1 = require("./utils/task-arguments");
4
5
  const isContainerWorkService = (svc) => 'image' in svc && svc.type === 'container-service';
5
6
  exports.isContainerWorkService = isContainerWorkService;
6
7
  // The container's working directory: the declared `workdir`, else the build
@@ -10,14 +11,17 @@ exports.getServiceWorkingDir = getServiceWorkingDir;
10
11
  // The container's entrypoint and command for a service: with a `shell`, its
11
12
  // `cmd` runs as `<shell> -c "<cmd>"` in place of the image entrypoint; without
12
13
  // one the tokenized `cmd` becomes the command and the image entrypoint stays.
13
- function getServiceCommand(service) {
14
+ // For Kubernetes, the arguments after -- are escaped so they stay text there.
15
+ function getServiceCommand(service, options = {}) {
14
16
  if (!service.cmd) {
15
17
  return { entrypoint: null, cmd: null };
16
18
  }
17
19
  if (service.shell) {
18
- return { entrypoint: [service.shell, '-c'], cmd: [service.cmd.cmd] };
20
+ const cmd = options.kubernetes ? (0, task_arguments_1.getKubernetesCommandText)(service.cmd) : service.cmd.cmd;
21
+ return { entrypoint: [service.shell, '-c'], cmd: [cmd] };
19
22
  }
20
- return { entrypoint: null, cmd: [service.cmd.parsed.command, ...service.cmd.parsed.args] };
23
+ const args = options.kubernetes ? (0, task_arguments_1.getKubernetesCommandArgs)(service.cmd) : service.cmd.parsed.args;
24
+ return { entrypoint: null, cmd: [service.cmd.parsed.command, ...args] };
21
25
  }
22
26
  exports.getServiceCommand = getServiceCommand;
23
27
  // The healthcheck as an exec command, through the service's `shell` if it has one.
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStateRecord = exports.formatStateRecord = exports.getStateRecordName = void 0;
4
+ const crypto_1 = require("crypto");
5
+ // The state record says "the outputs at this task's paths were made by this
6
+ // variant of the task (its id), from this state key". It is kept per task
7
+ // *name*, because every variant of a task (other arguments after --, another
8
+ // --set value) writes the same output paths: a record per id would let a run of
9
+ // one variant overwrite the outputs while the record of another one still
10
+ // claims them. The id stored with the key makes the next run of that other
11
+ // variant a miss.
12
+ function getStateRecordName(task) {
13
+ return (0, crypto_1.createHash)('sha1').update(`${task.name}\0${task.data.projectRoot}`).digest('hex');
14
+ }
15
+ exports.getStateRecordName = getStateRecordName;
16
+ function formatStateRecord(task, stateKey) {
17
+ return `${task.id()}:${stateKey}`;
18
+ }
19
+ exports.formatStateRecord = formatStateRecord;
20
+ // The state key of the record when this variant made the outputs, else null.
21
+ function parseStateRecord(task, content) {
22
+ const record = content.trim();
23
+ const separator = record.indexOf(':');
24
+ if (separator < 0 || record.slice(0, separator) !== task.id()) {
25
+ return null;
26
+ }
27
+ return record.slice(separator + 1) || null;
28
+ }
29
+ exports.parseStateRecord = parseStateRecord;
30
+ //# sourceMappingURL=work-state-record.js.map
package/dist/program.js CHANGED
@@ -27,6 +27,8 @@ const units_1 = require("./utils/units");
27
27
  const cache_inventory_1 = require("./cache/cache-inventory");
28
28
  const remote_commands_1 = require("./remote/remote-commands");
29
29
  const server_command_1 = require("./server/server-command");
30
+ const run_worker_command_1 = require("./server/run-worker-command");
31
+ const task_arguments_1 = require("./planner/utils/task-arguments");
30
32
  async function createCli(fileName, environment, workScope) {
31
33
  const { ctx, scope } = await (0, schema_parser_1.createParseContext)(fileName, environment);
32
34
  const referencedScope = await (0, reference_parser_1.parseReferences)(ctx, scope, environment);
@@ -79,6 +81,31 @@ function printTaskDefinition(environment, definition) {
79
81
  (0, log_1.printProperty)(environment, `env ${reference.name}`, `from $${reference.from} ${colors_1.default.grey(reference.available ? '(set)' : '(not set)')}`);
80
82
  }
81
83
  }
84
+ // `ls --json`: what a caller (an agent picking a task) needs to know, with the
85
+ // command that arguments after `--` are appended to (null: it takes none).
86
+ function getListJson(tasks, services) {
87
+ return {
88
+ tasks: tasks.map(({ item }) => ({
89
+ name: item.name,
90
+ description: item.data.description,
91
+ type: item.data.type,
92
+ ...(item.data.type === 'container-task' ? { image: item.data.image } : {}),
93
+ deps: item.deps.map((d) => d.name),
94
+ needs: item.needs.map((n) => n.name),
95
+ labels: item.data.labels,
96
+ command: (0, task_arguments_1.getArgumentsTarget)(item.data)?.cmd ?? null,
97
+ })),
98
+ services: services.map(({ item }) => ({
99
+ name: item.name,
100
+ description: item.data.description,
101
+ type: item.data.type,
102
+ ...(item.data.type === 'container-service' ? { image: item.data.image } : {}),
103
+ ports: item.data.ports.map((p) => ({ host: p.hostPort, container: p.containerPort })),
104
+ labels: item.data.labels,
105
+ command: (0, task_arguments_1.getArgumentsTarget)(item.data)?.cmd ?? null,
106
+ })),
107
+ };
108
+ }
82
109
  function parseWorkLabelScope(options) {
83
110
  const scope = {
84
111
  environmentName: null,
@@ -102,10 +129,20 @@ function parseWorkLabelScope(options) {
102
129
  }
103
130
  return scope;
104
131
  }
132
+ // A task given arguments after -- skips the cache, unless the caller asks for a
133
+ // cache method on the command line.
134
+ function keepsCache(command) {
135
+ return command.getOptionValueSource('cache') === 'cli';
136
+ }
105
137
  async function getProgram(environment, argv) {
106
138
  const program = new commander_1.Command();
107
139
  (0, server_command_1.registerServerCommand)(program, environment);
108
- const parsed = (0, parse_set_arguments_1.parseSetArguments)(argv);
140
+ (0, run_worker_command_1.registerRunWorkerCommand)(program, environment);
141
+ // What follows `--` belongs to the one task or service of a run or up, not to
142
+ // hammerkit: a `--set` or `--file` there is an argument like any other.
143
+ const separator = argv.indexOf('--');
144
+ const passthrough = separator >= 0 ? argv.slice(separator + 1) : [];
145
+ const parsed = (0, parse_set_arguments_1.parseSetArguments)(separator >= 0 ? argv.slice(0, separator) : argv);
109
146
  const args = parsed.args;
110
147
  environment.processEnvs = { ...environment.processEnvs, ...parsed.values };
111
148
  const fileIndex = args.indexOf('--file');
@@ -114,17 +151,28 @@ async function getProgram(environment, argv) {
114
151
  args.splice(fileIndex, 2);
115
152
  }
116
153
  (0, remote_commands_1.registerRemoteCommands)(program, environment, fileName);
154
+ program.hook('preAction', (_, actionCommand) => {
155
+ const takesArguments = actionCommand.parent === program && ['run', 'up'].includes(actionCommand.name());
156
+ if (passthrough.length > 0 && !takesArguments) {
157
+ program.error(`${actionCommand.name()} takes no arguments after --, only run and up do`, { exitCode: 1 });
158
+ }
159
+ });
117
160
  if (await environment.file.exists(fileName)) {
118
161
  program
119
162
  .command('ls')
120
163
  .description('list all tasks')
121
164
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
122
165
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
166
+ .addOption(new commander_1.Option('--json', 'emit the tasks and services as JSON').default(false))
123
167
  .action(async (options) => {
124
168
  const cli = await createCli(fileName, environment, parseWorkLabelScope(options));
125
169
  const items = cli.ls();
126
170
  const tasks = items.filter(cli_1.isCliTask);
127
171
  const services = items.filter(cli_1.isCliService);
172
+ if (options.json) {
173
+ environment.stdout.write(`${JSON.stringify(getListJson(tasks, services), null, 2)}\n`);
174
+ return;
175
+ }
128
176
  if (services.length > 0) {
129
177
  (0, log_1.printTitle)(environment, 'Services');
130
178
  for (const service of services) {
@@ -525,7 +573,8 @@ async function getProgram(environment, argv) {
525
573
  });
526
574
  program
527
575
  .command('up')
528
- .description('start services(s)')
576
+ .description("start services(s), with the arguments after -- appended to the selected service's cmd")
577
+ .usage('[options] [-- args...]')
529
578
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
530
579
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
531
580
  .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))
@@ -544,9 +593,12 @@ async function getProgram(environment, argv) {
544
593
  .choices(['checksum', 'modify-date', 'none']))
545
594
  .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))
546
595
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
547
- .action(async (options) => {
596
+ .action(async (options, command) => {
548
597
  try {
549
- const scope = parseWorkLabelScope(options);
598
+ const scope = {
599
+ ...parseWorkLabelScope(options),
600
+ args: { values: passthrough, services: true, cache: keepsCache(command) },
601
+ };
550
602
  const cli = await createCli(fileName, environment, scope);
551
603
  const result = await cli.runUp({
552
604
  cacheDefault: options.cache,
@@ -599,7 +651,8 @@ async function getProgram(environment, argv) {
599
651
  });
600
652
  program
601
653
  .command('run [task]', { isDefault: true })
602
- .description('execute task')
654
+ .description('execute task, with the arguments after -- appended to its last command')
655
+ .usage('[options] [task] [-- args...]')
603
656
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
604
657
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
605
658
  .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))
@@ -619,14 +672,15 @@ async function getProgram(environment, argv) {
619
672
  .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))
620
673
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
621
674
  .addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when nothing needing them has to run'))
622
- .action(async (task, options) => {
675
+ .action(async (task, options, command) => {
623
676
  try {
624
677
  // For machine-readable JSON, suppress the human progress logger (which
625
678
  // also writes to stdout) so the only thing on stdout is the JSON.
626
679
  const runEnvironment = options.summaryJson
627
680
  ? { ...environment, stdout: (0, empty_writable_1.emptyWritable)(), console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()) }
628
681
  : environment;
629
- const cli = await createCli(fileName, runEnvironment, task ? { taskName: task, environmentName: options.env } : parseWorkLabelScope(options));
682
+ const args = { values: passthrough, services: false, cache: keepsCache(command) };
683
+ const cli = await createCli(fileName, runEnvironment, task ? { taskName: task, environmentName: options.env, args } : { ...parseWorkLabelScope(options), args });
630
684
  if (cli.tasks().length === 0) {
631
685
  program.error('No tasks found', { exitCode: 127 });
632
686
  return;
@@ -108,7 +108,7 @@ function registerRemoteCommands(program, environment, buildFile) {
108
108
  const me = await (0, server_client_1.getMe)(server, result.accessToken);
109
109
  environment.stdout.write(`signed in to ${name} as ${me.sub}\n`);
110
110
  environment.stdout.write(me.accounts.length > 0
111
- ? `service accounts: ${me.accounts.join(', ')}\n`
111
+ ? `service accounts: ${me.accounts.map((a) => `${(0, server_client_1.printable)(a.name)} (${a.level})`).join(', ')}\n`
112
112
  : 'you have no service accounts on this server\n');
113
113
  }
114
114
  catch (e) {
@@ -131,21 +131,25 @@ function registerRemoteCommands(program, environment, buildFile) {
131
131
  const chosen = await getServer(name);
132
132
  const token = await (0, credentials_1.getAccessToken)(environment, chosen.name, chosen.server);
133
133
  const me = await (0, server_client_1.getMe)(chosen.server, token);
134
- environment.stdout.write(`${chosen.name}: ${me.sub} (${me.iss})\n`);
135
- if (me.accounts.length === 0 && !(me.request && me.request.length > 0)) {
134
+ const { body } = await (0, server_client_1.callServer)(chosen.server, token, 'GET', '/v1/accounts');
135
+ environment.stdout.write(`${chosen.name}: ${(0, server_client_1.printable)(me.sub)} (${(0, server_client_1.printable)(me.iss)})\n`);
136
+ if (body.accounts.length === 0) {
136
137
  environment.stdout.write('no service accounts\n');
137
138
  }
138
- for (const account of me.accounts) {
139
- environment.stdout.write(`${account}${account === me.default ? colors_1.default.green(' (default)') : ''}\n`);
139
+ for (const account of body.accounts) {
140
+ const level = account.level === 'use' ? 'use' : 'request, somebody who uses it approves';
141
+ environment.stdout.write(`${colors_1.default.bold((0, server_client_1.printable)(account.name))} ${level}${account.description ? ` ${(0, server_client_1.printable)(account.description)}` : ''} from ${account.sources.map(server_client_1.printable).join(', ')}\n`);
140
142
  }
141
- if (me.publish && me.publish.length > 0) {
142
- environment.stdout.write(`may publish build files: ${me.publish.join(', ')}\n`);
143
+ if (me.builds) {
144
+ const may = {
145
+ run: 'run published build files',
146
+ propose: 'propose build files, which somebody who publishes approves',
147
+ publish: 'publish build files',
148
+ }[me.builds];
149
+ environment.stdout.write(`you may ${may}\n`);
143
150
  }
144
- if (me.propose && me.propose.length > 0) {
145
- environment.stdout.write(`may propose build files, which need an approval: ${me.propose.join(', ')}\n`);
146
- }
147
- if (me.request && me.request.length > 0) {
148
- environment.stdout.write(`may request runs, which an admin approves: ${me.request.join(', ')}\n`);
151
+ if (me.repos.length > 0) {
152
+ environment.stdout.write(`repositories: ${me.repos.map(server_client_1.printable).join(', ')}\n`);
149
153
  }
150
154
  }));
151
155
  (0, remote_work_commands_1.registerWorkCommands)(remote, { environment, buildFile, getServer, guarded });