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
@@ -7,9 +7,11 @@ const path_1 = require("path");
7
7
  const stream_1 = require("stream");
8
8
  const promises_2 = require("stream/promises");
9
9
  const tar_1 = require("tar");
10
+ const tar_2 = require("../utils/tar");
10
11
  const apply_1 = require("../kubernetes/apply");
11
12
  const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
12
13
  const network_policy_1 = require("../kubernetes/network-policy");
14
+ const sandbox_runner_1 = require("../kubernetes/sandbox-runner");
13
15
  const volumes_1 = require("../kubernetes/volumes");
14
16
  const log_1 = require("../log");
15
17
  const sleep_1 = require("../utils/sleep");
@@ -259,6 +261,41 @@ class KubernetesSandboxStorage {
259
261
  await (0, promises_1.rm)(repository, { recursive: true, force: true });
260
262
  }
261
263
  }
264
+ // The pod writes the tree's files beside the sandbox on the claim and sends them as a tar,
265
+ // which is extracted here as the files of `attach` are: what a run plans with, up to the
266
+ // bytes a sandbox may take.
267
+ async exportTree(key, tree, into, signal) {
268
+ await (0, promises_1.mkdir)(into, { recursive: true });
269
+ let bytes = 0;
270
+ const extract = (0, tar_1.extract)({
271
+ cwd: into,
272
+ filter: (_path, entry) => 'type' in entry && ['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type),
273
+ });
274
+ const counted = new stream_1.PassThrough();
275
+ counted.on('data', (chunk) => {
276
+ bytes += chunk.length;
277
+ if (bytes > this.limits.bytes) {
278
+ counted.destroy(new Error(`the files of the sandbox ${key.id} take more than ${(0, units_1.formatSize)(this.limits.bytes)}`));
279
+ }
280
+ });
281
+ const extracted = new Promise((resolve, reject) => {
282
+ extract.on('finish', resolve);
283
+ extract.on('error', reject);
284
+ counted.on('error', reject);
285
+ });
286
+ extracted.catch(() => undefined);
287
+ counted.pipe(extract);
288
+ const answer = await this.exclusive(key.id, () => this.call(key, { op: 'archive', id: key.id, tree }, signal, true));
289
+ if ('stream' in answer) {
290
+ // a pipeline, so the answer is ended with the copy: an error of the pod after the copy
291
+ // stopped (over the size limit) lands nowhere else
292
+ await (0, promises_2.pipeline)(answer.stream, counted);
293
+ }
294
+ else {
295
+ counted.end(answer.body);
296
+ }
297
+ await extracted;
298
+ }
262
299
  async moveTo(key, commit) {
263
300
  await this.request(key, { op: 'move', id: key.id, commit }, AbortSignal.timeout(FETCH_TIMEOUT_MS));
264
301
  }
@@ -283,6 +320,8 @@ class KubernetesSandboxStorage {
283
320
  // runs on a sandbox go one at a time: a proxy the pod of the files does not own was
284
321
  // left by a run before (the server stopped, or its release could not remove it)
285
322
  await (0, network_policy_1.removeSandboxProxies)(this.instance, namespace, { sandbox: names.label }, (deployment) => isOwnedBy(deployment, pod.uid)).catch(() => undefined);
323
+ // and a runner, which the pod did not own when it made way for a run
324
+ await (0, sandbox_runner_1.removeSandboxRunners)(this.instance, namespace, names.label, (runner) => isOwnedBy(runner, pod.uid)).catch(() => undefined);
286
325
  await (0, promises_1.mkdir)(into, { recursive: true });
287
326
  await this.copyOut(key, ['tar', 'cf', '-', '-C', `${CLAIM_PATH}/${key.id}/files`, '.'], into,
288
327
  // what the run plans with: files, directories and links, nothing outside of it
@@ -387,7 +426,8 @@ class KubernetesSandboxStorage {
387
426
  async removeIn(namespace, id) {
388
427
  const names = getSandboxNames(id);
389
428
  await this.stopPod({ id, owner: { iss: '', sub: '' } }, namespace);
390
- // the cluster removes the proxies the pod owned; those it did not own go here
429
+ // the cluster removes the proxies and the runner the pod owned; those it did not own go here
430
+ await (0, sandbox_runner_1.removeSandboxRunners)(this.instance, namespace, names.label);
391
431
  await (0, network_policy_1.removeSandboxProxies)(this.instance, namespace, { sandbox: names.label });
392
432
  for (const name of [names.claim, names.git]) {
393
433
  try {
@@ -463,7 +503,7 @@ class KubernetesSandboxStorage {
463
503
  await (0, git_checkout_1.git)([`--git-dir=${repository}`, 'fetch', '--quiet', '--depth', '1', '--', fetch.url, fetch.ref], fetch.env, fetch.signal, what);
464
504
  await (0, git_checkout_1.git)([`--git-dir=${repository}`, 'update-ref', `refs/heads/${sandbox_pod_1.INCOMING_REF}`, 'FETCH_HEAD'], fetch.env, fetch.signal, what, op);
465
505
  const incoming = `${CLAIM_PATH}/${sandbox_pod_1.INCOMING}`;
466
- await this.withPod(key, (pod) => this.exec(pod, ['sh', '-c', 'rm -rf "$1" && mkdir -p "$1" && tar xf - -C "$1"', 'sh', incoming], stream_1.Readable.from((0, tar_1.create)({ cwd: repository, portable: true }, ['.'])), null, fetch.signal));
506
+ await this.withPod(key, (pod) => this.exec(pod, ['sh', '-c', 'rm -rf "$1" && mkdir -p "$1" && tar xf - -C "$1"', 'sh', incoming], stream_1.Readable.from((0, tar_2.packArchive)({ cwd: repository, portable: true }, ['.'])), null, fetch.signal));
467
507
  return (await this.request(key, { op, id: key.id }, fetch.signal)).result;
468
508
  }
469
509
  finally {
@@ -501,7 +541,7 @@ class KubernetesSandboxStorage {
501
541
  const reader = new AnswerReader(body);
502
542
  // the process ends when the server stops waiting for it: closing the connection does
503
543
  // not end a command in a pod
504
- const seconds = Math.ceil((['create', 'fetch', 'move'].includes(request.op) ? FETCH_TIMEOUT_MS : REQUEST_TIMEOUT_MS) / 1000);
544
+ const seconds = Math.ceil((['create', 'fetch', 'move', 'archive'].includes(request.op) ? FETCH_TIMEOUT_MS : REQUEST_TIMEOUT_MS) / 1000);
505
545
  const done = this.withPod(key, (pod) => this.exec(pod, ['timeout', String(seconds), 'hammerkit', 'server-sandbox', CLAIM_PATH], stream_1.Readable.from([Buffer.from(JSON.stringify(encoded))]), reader, signal));
506
546
  done.then(() => reader.complete(), (e) => reader.fail(e));
507
547
  const answer = await reader.answer;
@@ -884,6 +924,11 @@ class AnswerReader extends stream_1.Writable {
884
924
  }
885
925
  _write(chunk, _encoding, callback) {
886
926
  if (this.stream) {
927
+ if (this.stream.destroyed) {
928
+ // its reader stopped (over a size limit): the rest of the answer is nobody's
929
+ callback();
930
+ return;
931
+ }
887
932
  this.stream.write(chunk, callback);
888
933
  return;
889
934
  }
@@ -106,7 +106,7 @@ class TokenVerifier {
106
106
  ? [groups]
107
107
  : [],
108
108
  claims,
109
- ...(typeof payload.email_verified === 'boolean' ? { emailVerified: payload.email_verified } : {}),
109
+ ...emailVerified(payload.email_verified),
110
110
  };
111
111
  }
112
112
  async findKey(issuer, algorithm, kid) {
@@ -224,4 +224,15 @@ function isSecureUrl(value) {
224
224
  return false;
225
225
  }
226
226
  }
227
+ // Some issuers send `email_verified` as the string "true" or "false": either says it as
228
+ // much as the boolean does, anything else says nothing
229
+ function emailVerified(value) {
230
+ if (value === true || value === 'true') {
231
+ return { emailVerified: true };
232
+ }
233
+ if (value === false || value === 'false') {
234
+ return { emailVerified: false };
235
+ }
236
+ return {};
237
+ }
227
238
  //# sourceMappingURL=oauth.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.inspectBundle = exports.planBundle = exports.DefinitionError = void 0;
3
+ exports.inspectBundle = exports.planBundle = exports.getCredentialReads = exports.PLAIN_SERVER_ENVS = exports.DefinitionError = void 0;
4
4
  const promises_1 = require("fs/promises");
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
@@ -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");
@@ -20,6 +21,20 @@ const work_network_1 = require("../planner/work-network");
20
21
  class DefinitionError extends Error {
21
22
  }
22
23
  exports.DefinitionError = DefinitionError;
24
+ // Variables of a server that hold no credential (FR-008): a task may read these and
25
+ // still take arguments, or reach any network.
26
+ exports.PLAIN_SERVER_ENVS = ['PATH', 'HOME', 'CI'];
27
+ // What an item holds that its command must not hand out: its secrets, and the variables
28
+ // of the server its `envs` read, but the plain ones.
29
+ function getCredentialReads(item) {
30
+ return [
31
+ ...item.secrets.map((secret) => `the secret ${secret.target}`),
32
+ ...(item.serverEnvs ?? [])
33
+ .filter((env) => !exports.PLAIN_SERVER_ENVS.includes(env.toUpperCase()))
34
+ .map((env) => `$${env} of the server`),
35
+ ];
36
+ }
37
+ exports.getCredentialReads = getCredentialReads;
23
38
  // 40 (sha-1) or 64 (sha-256) hex digits
24
39
  const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
25
40
  // A hash alone can only name what is inside the bundle, so everything a build
@@ -151,80 +166,127 @@ function describeSecret(secret) {
151
166
  // server has no account, so nothing would stand between the build file and the
152
167
  // operator's own credentials.
153
168
  function findPolicyProblems(items, policy, root) {
154
- 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();
155
172
  for (const item of items) {
156
- const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
157
- if (policy.runtimes && !policy.runtimes.includes(runtime)) {
158
- problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
159
- }
160
- const data = item.data;
161
- // what a task reads and writes is the bundle's: `src: ../..` would have the server
162
- // copy its own files (its credentials, other callers' caches) into the task
163
- for (const source of 'src' in data ? data.src : []) {
164
- if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
165
- 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);
166
180
  }
167
- }
168
- for (const generate of (0, work_task_1.isWorkTask)(data) ? data.generates : []) {
169
- if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
170
- problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
181
+ else {
182
+ grouped.set(key, { names: [item.name], problem: said });
171
183
  }
172
184
  }
173
- const ceiling = policy.network;
174
- if (ceiling && (0, work_network_1.isLimitedNetwork)(ceiling)) {
175
- if (data.type === 'local-task') {
176
- problems.push(`${item.name}: is a local task, which can't be limited to the network this server allows (runs.network: ${(0, work_network_1.formatNetwork)(ceiling)})`);
177
- }
178
- else if ('network' in data && (0, work_network_1.exceedsNetwork)(data.network, ceiling)) {
179
- problems.push(`${item.name}: asks for network ${(0, work_network_1.formatNetwork)(data.network)}, more than this server allows (runs.network: ${(0, work_network_1.formatNetwork)(ceiling)})`);
180
- }
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`);
181
201
  }
182
- if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
183
- for (const mount of data.mounts) {
184
- // a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
185
- if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
186
- problems.push(`${item.name}: mounts ${mount.localPath}, which is outside of the bundle`);
187
- }
188
- }
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`);
189
206
  }
190
- for (const volume of (0, work_service_1.isContainerWorkService)(data) ? data.volumes : []) {
191
- // docker takes anything else for a path of the host, such as the docker socket
192
- if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
193
- problems.push(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
194
- }
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)})`);
195
231
  }
196
- for (const secret of secretsOf(item)) {
197
- const source = secret.source;
198
- if (policy.backend === 'kubernetes' && source.type === 'provider') {
199
- // the server reads no secret of a provider on a cluster: the Job of a task does
200
- if (!(0, work_task_1.isWorkTask)(item.data)) {
201
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider}, which ` +
202
- 'this server does for tasks only, a service on its cluster gets no provider secret yet');
203
- }
204
- else if (secret.cache) {
205
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
206
- 'the value before the Job of the task reads it, which this server does not support yet');
207
- }
208
- else if (item.data.network.type !== 'open') {
209
- // the init containers that read it share the pod's NetworkPolicy
210
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
211
- `Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it, ` +
212
- 'a task that reads secrets of a provider keeps an open network on this server');
213
- }
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)})`);
235
+ }
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`);
214
242
  }
215
- if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
216
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
243
+ }
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');
217
258
  }
218
- else if (source.type === 'file') {
219
- 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');
220
262
  }
221
- else if (source.type === 'env' && !policy.envs.includes(source.name)) {
222
- 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');
223
270
  }
224
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
+ }
225
281
  }
226
282
  return problems;
227
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
+ }
228
290
  function toPlan(root, item) {
229
291
  const data = item.data;
230
292
  const secrets = secretsOf(item).map(describeSecret);
@@ -237,7 +299,7 @@ function toPlan(root, item) {
237
299
  : [];
238
300
  const ports = 'ports' in data ? data.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`) : [];
239
301
  const flags = [];
240
- if (data.type === 'local-task') {
302
+ if (data.type === 'local-task' && !(0, work_task_1.isAggregateTask)(data)) {
241
303
  flags.push('local-task');
242
304
  }
243
305
  if ('ports' in data && data.ports.some((port) => port.hostPort !== null)) {
@@ -249,11 +311,13 @@ function toPlan(root, item) {
249
311
  const isTask = data.type === 'local-task' || data.type === 'container-task';
250
312
  return {
251
313
  kind: isTask ? 'task' : 'service',
252
- runtime: data.type === 'local-task' ? 'local' : 'docker',
314
+ ...(data.description ? { description: data.description } : {}),
315
+ runtime: (0, work_task_1.isAggregateTask)(data) ? 'none' : data.type === 'local-task' ? 'local' : 'docker',
253
316
  ...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
254
317
  cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
255
318
  command: (0, task_arguments_1.getArgumentsTarget)(data)?.cmd ?? null,
256
319
  network: (0, work_network_1.toNetworkSchema)('network' in data ? data.network : { type: 'open' }),
320
+ ...('args' in data && data.args !== null ? { args: data.args } : {}),
257
321
  deps: item.deps.map((dep) => dep.name).sort(),
258
322
  needs: item.needs.map((need) => need.name).sort(),
259
323
  accounts,
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
3
+ exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.endpointLevel = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayReadChecks = exports.mayRun = exports.matches = void 0;
4
4
  const repo_1 = require("./repo");
5
5
  const BUILDS_ORDER = ['run', 'propose', 'publish'];
6
6
  function matches(entitlement, principal) {
@@ -22,6 +22,12 @@ function atLeast(grant, level) {
22
22
  }
23
23
  const mayRun = (grant) => atLeast(grant, 'run');
24
24
  exports.mayRun = mayRun;
25
+ // Who may run from a repository's refs may see what passed for its files, and so may who
26
+ // is entitled to just that (a CI job that only verifies).
27
+ function mayReadChecks(grant, repo) {
28
+ return (grant.checks || grant.builds !== null) && (0, repo_1.matchesAnyRepo)(grant.repos, repo);
29
+ }
30
+ exports.mayReadChecks = mayReadChecks;
25
31
  const mayPropose = (grant) => atLeast(grant, 'propose');
26
32
  exports.mayPropose = mayPropose;
27
33
  const mayPublish = (grant) => atLeast(grant, 'publish');
@@ -55,12 +61,15 @@ function getGrant(entitlements, principal) {
55
61
  const accounts = {};
56
62
  const repos = [];
57
63
  const sandboxes = [];
64
+ const endpoints = [];
58
65
  let builds = null;
66
+ let checks = false;
59
67
  entitlements.forEach((entitlement, index) => {
60
68
  if (!matches(entitlement, principal)) {
61
69
  return;
62
70
  }
63
71
  matched.push(index);
72
+ checks = checks || entitlement.checks === 'read';
64
73
  for (const [account, level] of Object.entries(entitlement.accounts)) {
65
74
  if (accounts[account] !== 'use') {
66
75
  accounts[account] = level;
@@ -85,10 +94,31 @@ function getGrant(entitlements, principal) {
85
94
  known.branches[pattern] = higherType(known.branches[pattern], type);
86
95
  }
87
96
  }
97
+ for (const endpoint of entitlement.endpoints ?? []) {
98
+ const known = endpoints.find((entry) => entry.repo === endpoint.repo);
99
+ if (!known) {
100
+ endpoints.push({ ...endpoint });
101
+ }
102
+ else if (endpoint.level === 'publish') {
103
+ known.level = 'publish';
104
+ }
105
+ }
88
106
  });
89
- return { accounts, repos, builds, sandboxes, matched };
107
+ return { accounts, repos, builds, sandboxes, endpoints, checks, matched };
90
108
  }
91
109
  exports.getGrant = getGrant;
110
+ // What the caller may do with the endpoints of service runs of a repository: the higher
111
+ // level of the entries whose `repo` covers it, null for nothing.
112
+ function endpointLevel(grant, repo) {
113
+ let found = null;
114
+ for (const endpoint of grant.endpoints) {
115
+ if ((0, repo_1.matchesRepo)(endpoint.repo, repo) && found !== 'publish') {
116
+ found = endpoint.level;
117
+ }
118
+ }
119
+ return found;
120
+ }
121
+ exports.endpointLevel = endpointLevel;
92
122
  const BRANCH_TYPES = ['pull', 'push'];
93
123
  function higherType(a, b) {
94
124
  return a !== undefined && BRANCH_TYPES.indexOf(a) > BRANCH_TYPES.indexOf(b) ? a : b;
@@ -105,6 +105,11 @@ class RepoAccess {
105
105
  env.GIT_CONFIG_GLOBAL = '/dev/null';
106
106
  return { env, values: credentials.values };
107
107
  }
108
+ // A secret only the server reads, as an account no caller may use: the client secret of
109
+ // the sign-in at endpoints, read like the credential of a source.
110
+ serverSecret(secret) {
111
+ return this.token(secret);
112
+ }
108
113
  async token(secret) {
109
114
  const key = `${secret.account}\0${secret.from}`;
110
115
  const known = this.tokens.get(key);
@@ -1,15 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.refuseArguments = exports.planRun = exports.getRunScopes = void 0;
3
+ exports.describeArguments = exports.refuseArguments = exports.planRun = exports.getRunScopes = void 0;
4
4
  const parse_label_arguments_1 = require("../parser/parse-label-arguments");
5
5
  const plan_1 = require("./plan");
6
6
  function labels(values) {
7
7
  return values && values.length > 0 ? (0, parse_label_arguments_1.parseLabelArguments)(values) : undefined;
8
8
  }
9
- // The tasks in the order they were named, one scope each; by labels when none is.
9
+ // The tasks in the order they were named, one scope each; by labels when none is. A
10
+ // service run is one scope of its services.
10
11
  // The arguments, if any, go to the one task named (the request allows no more). That
11
12
  // task runs uncached unless the request names a `cache` method, as `run --cache` does.
12
- function getRunScopes(tasks, options) {
13
+ function getRunScopes(tasks, options, services = []) {
14
+ if (services.length > 0) {
15
+ return [{ name: services.join(', '), scope: { serviceNames: services, environmentName: options.env ?? null } }];
16
+ }
13
17
  const args = options.args && options.args.length > 0
14
18
  ? { values: options.args, services: false, cache: options.cache !== undefined }
15
19
  : undefined;
@@ -37,7 +41,7 @@ exports.getRunScopes = getRunScopes;
37
41
  */
38
42
  async function planRun(bundle, run, environment, policy) {
39
43
  const plan = {};
40
- for (const { scope } of getRunScopes(run.tasks, run.options)) {
44
+ for (const { scope } of getRunScopes(run.tasks, run.options, run.up?.services ?? run.services)) {
41
45
  Object.assign(plan, (await (0, plan_1.planBundle)(bundle, environment, { scope, policy })).plan);
42
46
  }
43
47
  return {
@@ -46,26 +50,16 @@ async function planRun(bundle, run, environment, policy) {
46
50
  };
47
51
  }
48
52
  exports.planRun = planRun;
49
- // Variables of a server that hold no credential (FR-008): a task may read these and
50
- // still take arguments.
51
- const PLAIN_SERVER_ENVS = ['PATH', 'HOME', 'CI'];
52
- function readsOf(entry) {
53
- return [
54
- ...entry.secrets.map((secret) => `the secret ${secret.target}`),
55
- ...(entry.serverEnvs ?? [])
56
- .filter((env) => !PLAIN_SERVER_ENVS.includes(env.toUpperCase()))
57
- .map((env) => `$${env} of the server`),
58
- ];
59
- }
60
53
  /**
61
54
  * Why a run may not pass its arguments, or null when it may. They go to a task, never a
62
55
  * service, and never to a run in which an item reads a secret or takes a variable of the
63
56
  * server into its `envs`: an argument can make the command print what the task holds,
64
57
  * and what its dependencies write into their outputs or its services serve, past the
65
58
  * masking of secrets. So a shell on a server runs with no account and no credential of
66
- * the server.
59
+ * the server. Under `runs.args: declared` they go only to a task that says `args: true`,
60
+ * so a task nobody meant to take them never does.
67
61
  */
68
- function refuseArguments(run, plan) {
62
+ function refuseArguments(run, plan, policy = {}) {
69
63
  if (!run.options.args || run.options.args.length === 0) {
70
64
  return null;
71
65
  }
@@ -75,14 +69,21 @@ function refuseArguments(run, plan) {
75
69
  if (!entry || entry.kind !== 'task') {
76
70
  return `${name} is not a task of this version`;
77
71
  }
78
- const reads = readsOf(entry);
72
+ // planning the run refuses it before, this keeps the check with the others
73
+ if (entry.args === false) {
74
+ return `${name} takes no arguments (args: false)`;
75
+ }
76
+ if (policy.args === 'declared' && entry.args !== true) {
77
+ return `${name} takes no arguments on this server: it takes them only for tasks that say args: true (runs.args: declared)`;
78
+ }
79
+ const reads = (0, plan_1.getCredentialReads)(entry);
79
80
  if (reads.length > 0) {
80
81
  return `${name} reads ${reads.join(', ')}, it takes no arguments on a server: an argument could make it print them`;
81
82
  }
82
83
  // what a dependency or a service of the run holds reaches the task through its outputs or
83
84
  // its answers, where an argument can print it in a form no mask knows (`rev token.txt`)
84
85
  for (const [other, item] of Object.entries(plan).sort(([a], [b]) => a.localeCompare(b))) {
85
- const held = (other === name ? [] : readsOf(item)).join(', ');
86
+ const held = (other === name ? [] : (0, plan_1.getCredentialReads)(item)).join(', ');
86
87
  if (held.length > 0) {
87
88
  return `${name} takes no arguments on a server: ${other} in its run reads ${held}, and an argument could make ${name} print what ${other} hands on`;
88
89
  }
@@ -90,4 +91,34 @@ function refuseArguments(run, plan) {
90
91
  return null;
91
92
  }
92
93
  exports.refuseArguments = refuseArguments;
94
+ /**
95
+ * For each task of a version, whether a run of it alone may pass arguments: null when it may,
96
+ * else why not, as {@link refuseArguments} answers a run. The plan is the one of the whole
97
+ * version: a task's run is the task with its dependencies and services.
98
+ */
99
+ function describeArguments(plan, policy = {}) {
100
+ const described = {};
101
+ for (const [name, entry] of Object.entries(plan)) {
102
+ if (entry.kind !== 'task') {
103
+ continue;
104
+ }
105
+ const run = {};
106
+ const visit = (item) => {
107
+ if (run[item] || !plan[item]) {
108
+ return;
109
+ }
110
+ run[item] = plan[item];
111
+ for (const next of [...plan[item].deps, ...plan[item].needs]) {
112
+ visit(next);
113
+ }
114
+ };
115
+ visit(name);
116
+ described[name] =
117
+ entry.command === null
118
+ ? `${name} has no command to take arguments`
119
+ : refuseArguments({ tasks: [name], options: { args: ['-'] } }, run, policy);
120
+ }
121
+ return described;
122
+ }
123
+ exports.describeArguments = describeArguments;
93
124
  //# sourceMappingURL=run-plan.js.map