hammerkit 1.27.0-next.2 → 1.27.0-next.21

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 (103) hide show
  1. package/build.schema.json +27 -0
  2. package/dist/cache/backends/registry-cache-backend.js +2 -2
  3. package/dist/cache/explain.js +17 -0
  4. package/dist/cli.js +1 -0
  5. package/dist/docker/item-network.js +13 -5
  6. package/dist/executer/deadline.js +17 -3
  7. package/dist/executer/docker-service.js +30 -8
  8. package/dist/executer/event-cache.js +20 -2
  9. package/dist/executer/execute-work-task.js +5 -3
  10. package/dist/executer/execution-steps.js +25 -4
  11. package/dist/executer/reset-work-tree.js +4 -0
  12. package/dist/executer/scheduler/enqueue-next.js +7 -0
  13. package/dist/executer/scheduler/state-key.js +42 -5
  14. package/dist/harness/connect.js +20 -6
  15. package/dist/harness/mcp-command.js +22 -6
  16. package/dist/harness/mcp.js +63 -114
  17. package/dist/harness/opencode.js +158 -50
  18. package/dist/harness/pi.js +29 -0
  19. package/dist/harness/sandbox-client.js +207 -65
  20. package/dist/harness/service-tools.js +141 -0
  21. package/dist/harness/task-tools.js +221 -0
  22. package/dist/kubernetes/apply.js +27 -16
  23. package/dist/kubernetes/claim-layout.js +71 -0
  24. package/dist/kubernetes/ensure-http-route.js +1 -1
  25. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  26. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  27. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  28. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  29. package/dist/kubernetes/get-service-ip.js +2 -2
  30. package/dist/kubernetes/job-secrets.js +33 -19
  31. package/dist/kubernetes/kept-claims.js +22 -10
  32. package/dist/kubernetes/network-policy.js +85 -27
  33. package/dist/kubernetes/remove-persistent-data.js +3 -1
  34. package/dist/kubernetes/resources.js +25 -3
  35. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  36. package/dist/kubernetes/sandbox-runner.js +626 -0
  37. package/dist/kubernetes/secrets.js +7 -11
  38. package/dist/kubernetes/volumes.js +6 -3
  39. package/dist/network/egress-proxy.js +45 -6
  40. package/dist/optimizer/calculate-checksum.js +3 -2
  41. package/dist/optimizer/check-key.js +94 -0
  42. package/dist/optimizer/check-keys.js +64 -0
  43. package/dist/optimizer/get-work-cache-stats.js +17 -3
  44. package/dist/optimizer/work-task-cache-description.js +51 -1
  45. package/dist/planner/utils/append-work-dependencies.js +10 -5
  46. package/dist/planner/utils/append-work-service.js +1 -0
  47. package/dist/planner/utils/append-work-task.js +15 -2
  48. package/dist/planner/utils/get-work-runtime.js +6 -1
  49. package/dist/planner/utils/normalize-path.js +19 -1
  50. package/dist/planner/utils/parse-work-mount.js +7 -1
  51. package/dist/planner/utils/parse-work-port.js +4 -1
  52. package/dist/planner/utils/task-arguments.js +3 -0
  53. package/dist/planner/validate.js +22 -0
  54. package/dist/planner/work-network.js +3 -1
  55. package/dist/planner/work-runtime-aggregate.js +32 -0
  56. package/dist/planner/work-runtime-docker.js +2 -1
  57. package/dist/planner/work-runtime-kubernetes.js +97 -48
  58. package/dist/planner/work-runtime-local.js +2 -1
  59. package/dist/planner/work-task.js +6 -1
  60. package/dist/program.js +35 -13
  61. package/dist/remote/annotations.js +13 -1
  62. package/dist/remote/device-login.js +7 -2
  63. package/dist/remote/host-config.js +64 -3
  64. package/dist/remote/remote-commands.js +41 -9
  65. package/dist/remote/remote-work-commands.js +246 -1
  66. package/dist/remote/server-client.js +15 -2
  67. package/dist/remote/token-source.js +64 -26
  68. package/dist/run-program.js +9 -0
  69. package/dist/schema/build-file-base-task-schema.js +5 -1
  70. package/dist/schema/build-file-container-service-schema.js +2 -0
  71. package/dist/schema/build-file-include-schema.js +10 -4
  72. package/dist/schema/reference-parser.js +9 -1
  73. package/dist/schema/schema-parser.js +36 -4
  74. package/dist/schema/work-scope-parser.js +33 -10
  75. package/dist/secrets/github-app-token-command.js +3 -1
  76. package/dist/server/accounts-api.js +3 -1
  77. package/dist/server/approvals-api.js +1 -1
  78. package/dist/server/checks-api.js +83 -0
  79. package/dist/server/checks.js +116 -0
  80. package/dist/server/definitions-api.js +4 -2
  81. package/dist/server/endpoints.js +612 -0
  82. package/dist/server/executor.js +56 -5
  83. package/dist/server/kubernetes-backend.js +303 -64
  84. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  85. package/dist/server/oauth.js +12 -1
  86. package/dist/server/plan.js +126 -62
  87. package/dist/server/policy.js +32 -2
  88. package/dist/server/repo-access.js +5 -0
  89. package/dist/server/run-plan.js +50 -19
  90. package/dist/server/run-request.js +64 -3
  91. package/dist/server/run-worker.js +146 -10
  92. package/dist/server/runs.js +347 -27
  93. package/dist/server/sandbox-pod.js +28 -0
  94. package/dist/server/sandbox-storage.js +23 -0
  95. package/dist/server/sandboxes-api.js +10 -4
  96. package/dist/server/sandboxes.js +8 -0
  97. package/dist/server/server-command.js +39 -20
  98. package/dist/server/server-config.js +213 -14
  99. package/dist/server/server.js +268 -15
  100. package/dist/server/service-runs.js +82 -0
  101. package/dist/utils/abort-on-signals.js +46 -6
  102. package/dist/utils/tar.js +36 -0
  103. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pinLogin = exports.setDefaultLocation = exports.removeServer = exports.addServer = exports.normalizeServerUrl = exports.validateServerName = exports.writeHostConfig = exports.readHostConfig = exports.getHostConfigPath = exports.hasServer = exports.hostConfigSchema = exports.hostServerSchema = exports.RESERVED_SERVER_NAMES = void 0;
3
+ exports.pinLogin = exports.setDefaultLocation = exports.removeServer = exports.addServer = exports.normalizeServerUrl = exports.validateServerName = exports.writeHostConfig = exports.readHostConfig = exports.getHostConfigPath = exports.getRegisteredServer = exports.getTokenCommand = exports.hasServer = exports.hostConfigSchema = exports.hostServerSchema = exports.RESERVED_SERVER_NAMES = void 0;
4
4
  const path_1 = require("path");
5
5
  const zod_1 = require("zod");
6
6
  const yaml_1 = require("yaml");
@@ -28,11 +28,20 @@ exports.hostServerSchema = zod_1.z
28
28
  scope: zod_1.z.string().min(1).optional(),
29
29
  })
30
30
  .strict();
31
+ // A command that prints the token for a server instead of a login, see token-source.ts.
32
+ // It is bound to the url and issuer it was registered for, like a sign-in, and lives
33
+ // beside `servers` rather than in it: hammerkit before 1.27 refuses unknown keys of a
34
+ // server, but keeps unknown top-level keys. It is only ever read from the host config,
35
+ // a build file can't name one.
36
+ const tokenCommandSchema = zod_1.z
37
+ .object({ url: zod_1.z.string(), issuer: zod_1.z.string(), command: zod_1.z.string().trim().min(1) })
38
+ .passthrough();
31
39
  // Unknown top-level keys are kept when the file is written back, so a newer
32
40
  // hammerkit's settings survive an older one editing the file.
33
41
  exports.hostConfigSchema = zod_1.z
34
42
  .object({
35
43
  servers: zod_1.z.record(exports.hostServerSchema).default({}),
44
+ tokenCommands: zod_1.z.record(tokenCommandSchema).optional(),
36
45
  run: zod_1.z.object({ on: zod_1.z.string().optional() }).passthrough().default({}),
37
46
  })
38
47
  .passthrough();
@@ -41,6 +50,35 @@ function hasServer(servers, name) {
41
50
  return Object.prototype.hasOwnProperty.call(servers, name);
42
51
  }
43
52
  exports.hasServer = hasServer;
53
+ // The token command of a server, unless it was registered for another url or issuer (an
54
+ // older hammerkit that pointed the name elsewhere leaves it behind).
55
+ function getTokenCommand(config, name) {
56
+ const commands = config.tokenCommands ?? {};
57
+ const entry = Object.prototype.hasOwnProperty.call(commands, name) ? commands[name] : undefined;
58
+ const server = hasServer(config.servers, name) ? config.servers[name] : undefined;
59
+ return entry && server && entry.url === server.url && entry.issuer === server.issuer ? entry.command : undefined;
60
+ }
61
+ exports.getTokenCommand = getTokenCommand;
62
+ function getRegisteredServer(config, name) {
63
+ const tokenCommand = getTokenCommand(config, name);
64
+ return { ...config.servers[name], ...(tokenCommand ? { tokenCommand } : {}) };
65
+ }
66
+ exports.getRegisteredServer = getRegisteredServer;
67
+ function setTokenCommand(config, name, entry) {
68
+ const commands = { ...(config.tokenCommands ?? {}) };
69
+ if (entry) {
70
+ commands[name] = entry;
71
+ }
72
+ else {
73
+ delete commands[name];
74
+ }
75
+ if (Object.keys(commands).length > 0) {
76
+ config.tokenCommands = commands;
77
+ }
78
+ else {
79
+ delete config.tokenCommands;
80
+ }
81
+ }
44
82
  function getHostConfigPath(environment) {
45
83
  return environment.processEnvs.HAMMERKIT_CONFIG ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'config.yaml');
46
84
  }
@@ -121,16 +159,38 @@ async function addServer(environment, name, url, options) {
121
159
  server[key] = options[key];
122
160
  }
123
161
  }
162
+ if (typeof options.tokenCommand === 'string' && !options.tokenCommand.trim()) {
163
+ throw new Error('the token command must not be empty, use --no-token-command to remove it');
164
+ }
124
165
  const config = await readHostConfig(environment);
125
166
  const existing = hasServer(config.servers, name) ? config.servers[name] : undefined;
126
- const changed = (a, b) => a.url !== b.url || a.issuer !== b.issuer || PINNED.some((key) => a[key] !== b[key]);
167
+ const sameServer = (a, b) => a.url === b.url && a.issuer === b.issuer;
168
+ // Adding the same server again keeps what was pinned (by a login, too) unless it is given,
169
+ // so a later `remote add ... --token-command` needs no --force.
170
+ if (existing && sameServer(existing, server)) {
171
+ for (const key of PINNED) {
172
+ if (!server[key] && existing[key]) {
173
+ server[key] = existing[key];
174
+ }
175
+ }
176
+ }
177
+ const changed = (a, b) => !sameServer(a, b) || PINNED.some((key) => a[key] !== b[key]);
127
178
  if (existing && changed(existing, server) && !options.force) {
128
179
  throw new Error(`server "${name}" is already registered as ${existing.url} (issuer ${existing.issuer}); ` +
129
180
  `use --force to point it somewhere else`);
130
181
  }
182
+ // The command decides which identity's token goes to the server: changing it does not
183
+ // redirect anything, but a name pointed at another server does not keep the old one.
184
+ const kept = existing && sameServer(existing, server) ? getTokenCommand(config, name) : undefined;
185
+ const tokenCommand = typeof options.tokenCommand === 'string'
186
+ ? options.tokenCommand.trim()
187
+ : options.tokenCommand === undefined
188
+ ? kept
189
+ : undefined;
131
190
  config.servers[name] = server;
191
+ setTokenCommand(config, name, tokenCommand ? { url: server.url, issuer: server.issuer, command: tokenCommand } : undefined);
132
192
  await writeHostConfig(environment, config);
133
- return server;
193
+ return { ...server, ...(tokenCommand ? { tokenCommand } : {}) };
134
194
  }
135
195
  exports.addServer = addServer;
136
196
  async function removeServer(environment, name) {
@@ -139,6 +199,7 @@ async function removeServer(environment, name) {
139
199
  throw new Error(`server "${name}" is not registered`);
140
200
  }
141
201
  delete config.servers[name];
202
+ setTokenCommand(config, name, undefined);
142
203
  const resetDefault = config.run.on === name;
143
204
  if (resetDefault) {
144
205
  delete config.run.on;
@@ -16,7 +16,9 @@ const remote_work_commands_1 = require("./remote-work-commands");
16
16
  const mcp_command_1 = require("../harness/mcp-command");
17
17
  // Host-level commands: they only touch the machine's config file, so they work
18
18
  // with or without a build file in the working directory.
19
- function registerRemoteCommands(program, environment, buildFile) {
19
+ function registerRemoteCommands(program, environment, buildFile,
20
+ // what follows `--`, the arguments of `remote run`
21
+ passthrough = []) {
20
22
  const remote = program.command('remote').description('register the servers runs can be sent to');
21
23
  const guarded = (failure, fn) => {
22
24
  return async (...args) => {
@@ -38,6 +40,8 @@ function registerRemoteCommands(program, environment, buildFile) {
38
40
  .addOption(new commander_1.Option('--client-id <id>', 'OAuth client to sign in with (pinned; default: what the server offers)'))
39
41
  .addOption(new commander_1.Option('--audience <audience>', 'audience of the token (pinned; default: what the server offers)'))
40
42
  .addOption(new commander_1.Option('--scope <scope>', 'scopes to request (pinned; default: what the server offers)'))
43
+ .addOption(new commander_1.Option('--token-command <command>', 'a command that prints the token for this server, instead of remote login (e.g. of a service account)'))
44
+ .addOption(new commander_1.Option('--no-token-command', 'remove the token command, sign in with remote login again'))
41
45
  .addOption(new commander_1.Option('--force', 'point an already registered name at a different server').default(false))
42
46
  .action(guarded('Remote add was not successful', async (name, url, options) => {
43
47
  const server = await (0, host_config_1.addServer)(environment, name, url, {
@@ -45,9 +49,13 @@ function registerRemoteCommands(program, environment, buildFile) {
45
49
  clientId: options.clientId,
46
50
  audience: options.audience,
47
51
  scope: options.scope,
52
+ tokenCommand: options.tokenCommand,
48
53
  force: options.force,
49
54
  });
50
55
  environment.stdout.write(`added ${name}: ${server.url} (issuer ${server.issuer})\n`);
56
+ if (server.tokenCommand) {
57
+ environment.stdout.write(`tokens for ${name} come from: ${server.tokenCommand}\n`);
58
+ }
51
59
  }));
52
60
  remote
53
61
  .command('list')
@@ -60,9 +68,10 @@ function registerRemoteCommands(program, environment, buildFile) {
60
68
  return;
61
69
  }
62
70
  for (const name of names) {
63
- const server = config.servers[name];
71
+ const server = (0, host_config_1.getRegisteredServer)(config, name);
64
72
  const marker = config.run.on === name ? colors_1.default.green(' (default)') : '';
65
- environment.stdout.write(`${colors_1.default.bold(name)}${marker} ${server.url} issuer ${server.issuer}\n`);
73
+ const token = server.tokenCommand ? ` token from: ${server.tokenCommand}` : '';
74
+ environment.stdout.write(`${colors_1.default.bold(name)}${marker} ${server.url} issuer ${server.issuer}${token}\n`);
66
75
  }
67
76
  if (config.run.on === 'auto' || config.run.on === 'local') {
68
77
  environment.stdout.write(`default location: ${config.run.on}\n`);
@@ -93,17 +102,33 @@ function registerRemoteCommands(program, environment, buildFile) {
93
102
  ? `server ${name} is not registered, add it with: hammerkit remote add ${name} <url> --issuer <url>`
94
103
  : 'name a server, or set one as the default with: hammerkit remote use <name>');
95
104
  }
96
- return { name: chosen, server: config.servers[chosen] };
105
+ return { name: chosen, server: (0, host_config_1.getRegisteredServer)(config, chosen) };
97
106
  };
98
107
  remote
99
108
  .command('login <name>')
100
109
  .description('sign in to a registered server with your identity provider (device code, works headless)')
101
110
  .action(guarded('Remote login was not successful', async (name) => {
102
111
  const { server } = await getServer(name);
103
- const { pinned, ...result } = await (0, device_login_1.deviceLogin)(server, {
104
- prompt: (message) => environment.stdout.write(`${message}\n`),
105
- signal: environment.abortCtrl.signal,
106
- });
112
+ if (server.tokenCommand) {
113
+ // a sign-in would never be used: the command's token goes first
114
+ throw new Error(`${name} takes its tokens from its token command (${server.tokenCommand}), it needs no sign-in; ` +
115
+ `to sign in instead, remove it with: hammerkit remote add ${name} ${server.url} --issuer ${server.issuer} --no-token-command`);
116
+ }
117
+ let login;
118
+ try {
119
+ login = await (0, device_login_1.deviceLogin)(server, {
120
+ prompt: (message) => environment.stdout.write(`${message}\n`),
121
+ signal: environment.abortCtrl.signal,
122
+ });
123
+ }
124
+ catch (e) {
125
+ if (e instanceof device_login_1.NoSignInError) {
126
+ throw new Error(`${e.message}: register a command that prints one, such as the token of a service account, with: ` +
127
+ `hammerkit remote add ${name} ${server.url} --issuer ${server.issuer} --token-command '<command>'`);
128
+ }
129
+ throw e;
130
+ }
131
+ const { pinned, ...result } = login;
107
132
  await (0, host_config_1.pinLogin)(environment, name, pinned);
108
133
  await (0, credentials_1.saveToken)(environment, name, { url: server.url, issuer: server.issuer, ...result });
109
134
  try {
@@ -154,7 +179,14 @@ function registerRemoteCommands(program, environment, buildFile) {
154
179
  environment.stdout.write(`repositories: ${me.repos.map(server_client_1.printable).join(', ')}\n`);
155
180
  }
156
181
  }));
157
- (0, remote_work_commands_1.registerWorkCommands)(remote, { environment, buildFile, getServer, guarded });
182
+ (0, remote_work_commands_1.registerWorkCommands)(remote, {
183
+ environment,
184
+ buildFile,
185
+ getServer,
186
+ guarded,
187
+ passthrough,
188
+ fail: (message, exitCode) => program.error(message, { exitCode }),
189
+ });
158
190
  (0, mcp_command_1.registerMcpCommand)(remote, environment);
159
191
  }
160
192
  exports.registerRemoteCommands = registerRemoteCommands;
@@ -12,7 +12,23 @@ const bundle_1 = require("./bundle");
12
12
  const annotations_1 = require("./annotations");
13
13
  const server_client_1 = require("./server-client");
14
14
  const bundle_text_1 = require("./bundle-text");
15
+ const sandbox_client_1 = require("../harness/sandbox-client");
16
+ const units_1 = require("../utils/units");
17
+ const service_tools_1 = require("../harness/service-tools");
18
+ const path_1 = require("path");
19
+ const check_keys_1 = require("../optimizer/check-keys");
15
20
  const RUN_ID = /^run_[A-Za-z0-9_-]{16}$/;
21
+ // how a check passed, one line
22
+ function describePassed(passed) {
23
+ const from = passed.source.type === 'ref'
24
+ ? `${passed.source.ref} at ${passed.source.commit.slice(0, 12)}`
25
+ : `a check of sandbox ${passed.source.sandbox}, fetched at ${passed.source.commit.slice(0, 12)}`;
26
+ return `passed ${passed.finishedAt}${passed.status === 'cached'
27
+ ? ' from the cache'
28
+ : passed.durationMs !== undefined
29
+ ? ` in ${(0, units_1.formatDuration)(passed.durationMs)}`
30
+ : ''}, ${passed.run} by ${passed.by.sub}, from ${from}`;
31
+ }
16
32
  // what the publisher said about where a version comes from, one line
17
33
  // What a publisher said about where a version comes from: the server takes it as said,
18
34
  // so anybody else is told it is not checked.
@@ -29,6 +45,11 @@ function yankLines(version) {
29
45
  ]
30
46
  : [];
31
47
  }
48
+ // A line a task printed, shown as it is but for control characters, so it can't rewrite
49
+ // the terminal; tabs stay.
50
+ function printableLine(text) {
51
+ return text.split('\t').map(server_client_1.printableWhole).join('\t');
52
+ }
32
53
  const collectOption = (value, previous = []) => [...previous, value];
33
54
  // What a person who approves is shown of a run: everything the decision rests on.
34
55
  function describeApproval(run) {
@@ -89,7 +110,22 @@ function describePlan(plan) {
89
110
  }
90
111
  exports.describePlan = describePlan;
91
112
  function registerWorkCommands(remote, context) {
92
- const { environment, buildFile, getServer, guarded } = context;
113
+ const { environment, buildFile, getServer, guarded, passthrough, fail } = context;
114
+ // the ref a run starts from: what the options name, else the checkout here
115
+ const refSource = async (options) => {
116
+ const checkout = options.repo && options.ref ? {} : await (0, annotations_1.readCheckout)((0, path_1.dirname)(buildFile));
117
+ const repo = options.repo ?? checkout.repo;
118
+ const ref = options.ref ?? checkout.branch;
119
+ // the commit checked out here is the code meant, when the ref is the branch it is on
120
+ const commit = options.commit ?? (options.ref ? undefined : checkout.commit);
121
+ if (!repo) {
122
+ throw new Error('name the repository with --repo, the checkout here has no origin it can be told by');
123
+ }
124
+ if (!ref) {
125
+ throw new Error('name the branch or tag with --ref, the checkout here is on no branch');
126
+ }
127
+ return { type: 'ref', repo, ref, ...(commit ? { commit } : {}) };
128
+ };
93
129
  const connect = async (name) => {
94
130
  const chosen = await getServer(name);
95
131
  // a CI job or an agent brings its own token, a person signs in
@@ -122,6 +158,215 @@ function registerWorkCommands(remote, context) {
122
158
  environment.stdout.write(`it can not run before somebody who may publish approves it, until ${(0, server_client_1.printable)(version.expiresAt)}\n`);
123
159
  }
124
160
  }));
161
+ remote
162
+ .command('run <tasks...>')
163
+ .description('run tasks of a published build file on a server from a git ref, print their output and exit as they did')
164
+ .addOption(new commander_1.Option('--server <name>', 'the server to run on; the default of remote use when left out'))
165
+ .addOption(new commander_1.Option('--build <hash>', 'the published version to run (sha256:...), or HAMMERKIT_BUILD; the hash of the build file here when left out'))
166
+ .addOption(new commander_1.Option('--repo <host/path>', 'the repository, such as github.com/corp/payments; origin of the checkout here when left out'))
167
+ .addOption(new commander_1.Option('--ref <ref>', 'the branch or tag to run; the branch checked out here when left out'))
168
+ .addOption(new commander_1.Option('--commit <sha>', 'the commit the ref has to be at, else the run is refused; the commit checked out here when --ref is left out'))
169
+ .addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
170
+ .addOption(new commander_1.Option('--cache <method>', 'as --cache of run').choices(['checksum', 'modify-date', 'none']))
171
+ .addOption(new commander_1.Option('--timeout <duration>', 'as --timeout of run: for tasks without their own, such as 30m'))
172
+ .addOption(new commander_1.Option('--max-timeout <duration>', 'as --max-timeout of run: for every task, also one with its own timeout'))
173
+ .addOption(new commander_1.Option('--detach', 'submit the run and exit, without following it: for a check that CI verifies later'))
174
+ .action(guarded('Remote run was not successful', async (tasks, options) => {
175
+ const named = options.build ?? environment.processEnvs.HAMMERKIT_BUILD;
176
+ const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
177
+ if (!bundle_1.HASH_PATTERN.test(definition)) {
178
+ throw new Error(`${(0, server_client_1.printable)(definition)} is not the hash of a published version (sha256:...)`);
179
+ }
180
+ const source = await refSource(options);
181
+ const ref = source.ref;
182
+ const { name: serverName, server } = await getServer(options.server);
183
+ const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, serverName, server) };
184
+ const runOptions = {};
185
+ for (const key of ['env', 'cache', 'timeout', 'maxTimeout']) {
186
+ if (options[key] !== undefined) {
187
+ runOptions[key] = options[key];
188
+ }
189
+ }
190
+ if (passthrough.length > 0) {
191
+ runOptions.args = passthrough;
192
+ }
193
+ let submitted;
194
+ try {
195
+ submitted = await (0, sandbox_client_1.submitRun)(connection, {
196
+ definition,
197
+ tasks,
198
+ source,
199
+ options: runOptions,
200
+ });
201
+ }
202
+ catch (e) {
203
+ if (e.status === 400 && !named && /no published build file/.test(String(e))) {
204
+ throw new Error(`the build file here is not published on ${serverName} (${definition}), publish it first`);
205
+ }
206
+ throw e;
207
+ }
208
+ const at = submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : '';
209
+ environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${tasks.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(ref)}${at}\n`);
210
+ if (options.detach) {
211
+ environment.stdout.write(`follow it with: hammerkit remote show ${(0, server_client_1.printable)(submitted.id)}\n`);
212
+ return;
213
+ }
214
+ const prefixed = tasks.length > 1;
215
+ const followed = await (0, sandbox_client_1.followRun)(connection, submitted, {
216
+ signal: environment.abortCtrl.signal,
217
+ onLine: (line) => {
218
+ environment.stdout.write(`${prefixed || line.task !== tasks[0] ? `[${(0, server_client_1.printable)(line.task)}] ` : ''}${printableLine(line.text)}\n`);
219
+ },
220
+ onWaiting: (run) => {
221
+ for (const approval of run.approvals ?? []) {
222
+ environment.stdout.write(`waiting until somebody who uses ${(0, server_client_1.printable)(approval.account)} approves it, before ${(0, server_client_1.printable)(approval.expiresAt)}: hammerkit remote approve ${(0, server_client_1.printable)(run.id)}\n`);
223
+ }
224
+ },
225
+ });
226
+ const run = followed.run;
227
+ if (run.state === 'succeeded') {
228
+ environment.stdout.write(`${(0, server_client_1.printable)(run.id)} succeeded\n`);
229
+ return;
230
+ }
231
+ // the exit code of the first task that failed, as `hammerkit run` would end
232
+ const failed = tasks
233
+ .map((task) => run.results?.[task])
234
+ .concat(Object.values(run.results ?? {}))
235
+ .find((result) => result?.status === 'failed');
236
+ const exitCode = failed?.exitCode && failed.exitCode > 0 ? failed.exitCode : 1;
237
+ fail(`${(0, server_client_1.printable)(run.id)} ${run.state}${run.error ? `: ${(0, server_client_1.printable)(run.error)}` : ''}`, exitCode);
238
+ }));
239
+ remote
240
+ .command('up <services...>')
241
+ .description('start services of a published build file on a server from a git ref and keep them up until their ttl ends, ' +
242
+ 'with their endpoints at addresses of their own behind the sign-in of the server')
243
+ .addOption(new commander_1.Option('--server <name>', 'the server to run on; the default of remote use when left out'))
244
+ .addOption(new commander_1.Option('--endpoint <service:port>', 'publish this port of a service, such as web:8080; may be given more than once').argParser(collectOption))
245
+ .addOption(new commander_1.Option('--ttl <duration>', 'how long they stay once ready, such as 2h, then they are removed; the server default when left out'))
246
+ .addOption(new commander_1.Option('--sleep-ttl <duration>', 'how long they stay up without a request, such as 30m, then they sleep until the next request wakes them; ' +
247
+ 'the server default when left out'))
248
+ .addOption(new commander_1.Option('--name <name>', 'a later run of yours with the same name replaces this one and keeps its addresses; the branch when left out'))
249
+ .addOption(new commander_1.Option('--build <hash>', 'the published version to run (sha256:...), or HAMMERKIT_BUILD; the hash of the build file here when left out'))
250
+ .addOption(new commander_1.Option('--repo <host/path>', 'the repository; origin of the checkout here when left out'))
251
+ .addOption(new commander_1.Option('--ref <ref>', 'the branch or tag to start; the branch checked out here when left out'))
252
+ .addOption(new commander_1.Option('--commit <sha>', 'the commit the ref has to be at; the commit checked out here when --ref is left out'))
253
+ .addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
254
+ .action(guarded('Remote up was not successful', async (services, options) => {
255
+ const named = options.build ?? environment.processEnvs.HAMMERKIT_BUILD;
256
+ const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
257
+ if (!bundle_1.HASH_PATTERN.test(definition)) {
258
+ throw new Error(`${(0, server_client_1.printable)(definition)} is not the hash of a published version (sha256:...)`);
259
+ }
260
+ const endpoints = (options.endpoint ?? []).map(service_tools_1.parseEndpoint);
261
+ const source = await refSource(options);
262
+ const { name: serverName, server } = await getServer(options.server);
263
+ const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, serverName, server) };
264
+ const submitted = await (0, sandbox_client_1.submitRun)(connection, {
265
+ definition,
266
+ services,
267
+ endpoints,
268
+ ...(options.ttl ? { ttl: options.ttl } : {}),
269
+ ...(options.sleepTtl ? { sleepTtl: options.sleepTtl } : {}),
270
+ ...(options.name ? { name: options.name } : {}),
271
+ source,
272
+ options: options.env ? { env: options.env } : {},
273
+ });
274
+ environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${services.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(source.ref)}${submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : ''}\n`);
275
+ const followed = await (0, sandbox_client_1.followRun)(connection, submitted, {
276
+ signal: environment.abortCtrl.signal,
277
+ untilUp: true,
278
+ onLine: (line) => environment.stdout.write(`[${(0, server_client_1.printable)(line.task)}] ${printableLine(line.text)}\n`),
279
+ onWaiting: (run) => {
280
+ for (const approval of run.approvals ?? []) {
281
+ environment.stdout.write(`waiting until somebody who uses ${(0, server_client_1.printable)(approval.account)} approves it, before ${(0, server_client_1.printable)(approval.expiresAt)}: hammerkit remote approve ${(0, server_client_1.printable)(run.id)}\n`);
282
+ }
283
+ },
284
+ });
285
+ const run = followed.run;
286
+ if (run.state !== 'up') {
287
+ fail(`${(0, server_client_1.printable)(run.id)} ${run.state}${run.error ? `: ${(0, server_client_1.printable)(run.error)}` : ''}`, 1);
288
+ }
289
+ environment.stdout.write(`${(0, server_client_1.printable)(run.id)} is up until ${(0, server_client_1.printable)(run.expiresAt ?? '-')}${run.up?.sleepTtl ? `, asleep after ${(0, units_1.formatDuration)(run.up.sleepTtl)} without a request` : ''}\n`);
290
+ for (const endpoint of run.up?.endpoints ?? []) {
291
+ environment.stdout.write(` ${(0, server_client_1.printable)(endpoint.service)}:${endpoint.port} ${(0, server_client_1.printable)(endpoint.url)}\n`);
292
+ }
293
+ environment.stdout.write(`extend it with: hammerkit remote extend ${(0, server_client_1.printable)(run.id)} 8h, stop it with: hammerkit remote stop ${(0, server_client_1.printable)(run.id)}\n`);
294
+ }));
295
+ remote
296
+ .command('extend <run> <ttl> [server]')
297
+ .description('keep a service run that is up for this long from now, such as 8h, at most the maxTtl of the server')
298
+ .action(guarded('Remote extend was not successful', async (id, ttl, name) => {
299
+ if (!RUN_ID.test(id)) {
300
+ throw new Error(`${(0, server_client_1.printable)(id)} is not a run id (run_...)`);
301
+ }
302
+ const { name: serverName, server, token } = await connect(name);
303
+ const run = (await (0, server_client_1.callServer)(server, token, 'POST', `/v1/runs/${id}/extend`, { ttl })).body;
304
+ environment.stdout.write(`${(0, server_client_1.printable)(run.id)} on ${serverName} stays up until ${(0, server_client_1.printable)(run.expiresAt ?? '-')}\n`);
305
+ }));
306
+ remote
307
+ .command('stop <run> [server]')
308
+ .description('stop a run: a service run takes its services and endpoints down')
309
+ .action(guarded('Remote stop was not successful', async (id, name) => {
310
+ if (!RUN_ID.test(id)) {
311
+ throw new Error(`${(0, server_client_1.printable)(id)} is not a run id (run_...)`);
312
+ }
313
+ const { name: serverName, server, token } = await connect(name);
314
+ const run = (await (0, server_client_1.callServer)(server, token, 'POST', `/v1/runs/${id}/cancel`)).body;
315
+ environment.stdout.write(`${(0, server_client_1.printable)(run.id)} on ${serverName}: ${run.state === 'up' || run.state === 'running' ? 'stopping' : (0, server_client_1.printable)(run.state)}\n`);
316
+ }));
317
+ remote
318
+ .command('verify <tasks...>')
319
+ .description('check that the server already passed tasks for the files checked out here, without running them; ' +
320
+ 'exits 1 at once for any it did not')
321
+ .addOption(new commander_1.Option('--server <name>', 'the server to ask; the default of remote use when left out'))
322
+ .addOption(new commander_1.Option('--repo <host/path>', 'the repository, such as github.com/corp/payments; origin of the checkout here when left out'))
323
+ .addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
324
+ .action(guarded('Remote verify was not successful', async (tasks, options) => {
325
+ const repo = options.repo ?? (await (0, annotations_1.readCheckout)((0, path_1.dirname)(buildFile))).repo;
326
+ if (!repo) {
327
+ throw new Error('name the repository with --repo, the checkout here has no origin it can be told by');
328
+ }
329
+ const keys = [];
330
+ const unkeyed = [];
331
+ for (const task of tasks) {
332
+ const found = (await (0, check_keys_1.getCheckKeys)(buildFile, environment, { taskName: task, environmentName: options.env ?? null }))[task];
333
+ if (!found) {
334
+ throw new Error(`the build file has no task ${(0, server_client_1.printable)(task)}`);
335
+ }
336
+ if (found.key === null) {
337
+ unkeyed.push({ task, reason: found.reason });
338
+ }
339
+ else {
340
+ keys.push({ task, key: found.key });
341
+ }
342
+ }
343
+ const { name: serverName, server, token } = await connect(options.server);
344
+ const answer = keys.length > 0
345
+ ? await (0, server_client_1.callServer)(server, token, 'POST', '/v1/checks/lookup', { repo, keys })
346
+ : { body: { checks: [] } };
347
+ const missing = [];
348
+ // by what was asked, never by what was answered: a task the answer leaves out, or
349
+ // answers for another key, is not verified
350
+ for (const { task, key } of keys) {
351
+ const check = answer.body.checks.find((answered) => answered.task === task && answered.key === key);
352
+ if (check?.passed) {
353
+ environment.stdout.write(`${(0, server_client_1.printable)(task)}: ${(0, server_client_1.printable)(describePassed(check.passed))}\n`);
354
+ }
355
+ else {
356
+ missing.push(task);
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
+ }
359
+ }
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
+ }
363
+ if (missing.length > 0 || unkeyed.length > 0) {
364
+ const run = missing.length > 0 ? `; run on the server: hammerkit remote run ${missing.map(server_client_1.printable).join(' ')}` : '';
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);
368
+ }
369
+ }));
125
370
  remote
126
371
  .command('versions [server]')
127
372
  .description('list the published versions of build files on a server, newest first, with where they come from')
@@ -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 };
@@ -9,6 +9,54 @@ function isToken(value) {
9
9
  return /^[\x21-\x7e]+$/.test(value);
10
10
  }
11
11
  exports.isToken = isToken;
12
+ // What a failed token command said on stderr, for the user's own terminal: never its stdout,
13
+ // and never in an error message, which a harness hands on to an agent.
14
+ const MAX_STDERR_LINES = 20;
15
+ const MAX_STDERR_LINE = 300;
16
+ function writeStderr(environment, what, stderr) {
17
+ const lines = stderr
18
+ .split(/\r?\n/)
19
+ .map((line) => line.replace(/[^\x20-\x7e\t]/g, '?').slice(0, MAX_STDERR_LINE))
20
+ .filter((line) => line.trim())
21
+ .slice(-MAX_STDERR_LINES);
22
+ for (const line of lines) {
23
+ environment.stderr.write(`${what}: ${line}\n`);
24
+ }
25
+ }
26
+ function commandSource(environment, command, what) {
27
+ let current;
28
+ const run = () => new Promise((resolve, reject) => {
29
+ (0, child_process_1.exec)(command, { env: environment.processEnvs, timeout: 60000 }, (error, stdout, stderr) => {
30
+ const token = String(stdout).trim();
31
+ if (error || !token) {
32
+ writeStderr(environment, what, String(stderr));
33
+ const status = error?.killed
34
+ ? ', it did not end within a minute'
35
+ : typeof error?.code === 'number'
36
+ ? `, it exited with ${error.code}`
37
+ : '';
38
+ reject(new Error(`${what} printed no token${status}`));
39
+ }
40
+ else if (!isToken(token)) {
41
+ // never the output itself: it can hold more secrets than the token
42
+ reject(new Error(`${what} printed more than a token, it has to print the token alone`));
43
+ }
44
+ else {
45
+ resolve(token);
46
+ }
47
+ });
48
+ });
49
+ return {
50
+ token: () => {
51
+ current = current ?? run();
52
+ current.catch(() => (current = undefined));
53
+ return current;
54
+ },
55
+ renew: async () => {
56
+ current = undefined;
57
+ },
58
+ };
59
+ }
12
60
  /**
13
61
  * The token a client of a server sends: a harness, and the `remote` commands. In this order:
14
62
  *
@@ -16,42 +64,29 @@ exports.isToken = isToken;
16
64
  * identity, such as a workload identity or a service account of the identity provider. It
17
65
  * runs again when the server no longer accepts the token.
18
66
  * - `HAMMERKIT_TOKEN`: such a token, as the agent's platform hands it over.
67
+ * - the token command of the server in the host config (`remote add --token-command`): the
68
+ * same as `HAMMERKIT_TOKEN_COMMAND`, for one server, so each server can have the service
69
+ * account and audience it accepts. It only ever comes from the host config, never from a
70
+ * build file: whoever writes it decides what runs on this machine.
19
71
  * - the sign-in of `hammerkit remote login <server>`.
20
72
  *
21
73
  * The token only goes to the URL the server is registered with in the host config.
22
74
  */
23
75
  function getTokenSource(environment, name, server) {
76
+ // a variable holds for every server: say so when it takes the place of the one registered
77
+ const overrides = (variable) => {
78
+ if (server.tokenCommand) {
79
+ environment.stderr.write(`${variable} is set, so the token command of ${name} is not used\n`);
80
+ }
81
+ };
24
82
  const command = environment.processEnvs.HAMMERKIT_TOKEN_COMMAND;
25
83
  if (command) {
26
- let current;
27
- const run = () => new Promise((resolve, reject) => {
28
- (0, child_process_1.exec)(command, { env: environment.processEnvs, timeout: 60000 }, (error, stdout) => {
29
- const token = String(stdout).trim();
30
- if (error || !token) {
31
- reject(new Error('HAMMERKIT_TOKEN_COMMAND printed no token'));
32
- }
33
- else if (!isToken(token)) {
34
- // never the output itself: it can hold more secrets than the token
35
- reject(new Error('HAMMERKIT_TOKEN_COMMAND printed more than a token, it has to print the token alone'));
36
- }
37
- else {
38
- resolve(token);
39
- }
40
- });
41
- });
42
- return {
43
- token: () => {
44
- current = current ?? run();
45
- current.catch(() => (current = undefined));
46
- return current;
47
- },
48
- renew: async () => {
49
- current = undefined;
50
- },
51
- };
84
+ overrides('HAMMERKIT_TOKEN_COMMAND');
85
+ return commandSource(environment, command, 'HAMMERKIT_TOKEN_COMMAND');
52
86
  }
53
87
  const fixed = environment.processEnvs.HAMMERKIT_TOKEN?.trim();
54
88
  if (fixed) {
89
+ overrides('HAMMERKIT_TOKEN');
55
90
  return {
56
91
  token: async () => {
57
92
  if (!isToken(fixed)) {
@@ -61,6 +96,9 @@ function getTokenSource(environment, name, server) {
61
96
  },
62
97
  };
63
98
  }
99
+ if (server.tokenCommand) {
100
+ return commandSource(environment, server.tokenCommand, `the token command of ${name}`);
101
+ }
64
102
  return { token: () => (0, credentials_1.getAccessToken)(environment, name, server) };
65
103
  }
66
104
  exports.getTokenSource = getTokenSource;