hammerkit 1.27.0-next.9 → 2.0.0-next.2

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 (122) hide show
  1. package/build.schema.json +176 -6
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/cache-sync.js +1 -1
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +6 -3
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +49 -1
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +133 -9
  36. package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
  37. package/dist/kubernetes/ensure-persistent-data.js +211 -6
  38. package/dist/kubernetes/follow-pod-logs.js +182 -0
  39. package/dist/kubernetes/job-placement.js +36 -12
  40. package/dist/kubernetes/kubernetes-instance.js +1 -0
  41. package/dist/kubernetes/network-policy.js +2 -1
  42. package/dist/kubernetes/remove-stale-items.js +46 -0
  43. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  44. package/dist/kubernetes/sandbox-runner.js +628 -0
  45. package/dist/kubernetes/service-rollouts.js +74 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  47. package/dist/kubernetes/volumes.js +94 -9
  48. package/dist/main.js +2 -0
  49. package/dist/optimizer/calculate-checksum.js +3 -2
  50. package/dist/optimizer/check-key.js +94 -0
  51. package/dist/optimizer/check-keys.js +64 -0
  52. package/dist/optimizer/get-work-cache-stats.js +17 -3
  53. package/dist/optimizer/work-task-cache-description.js +49 -1
  54. package/dist/planner/service-definition.js +9 -4
  55. package/dist/planner/utils/append-work-dependencies.js +5 -3
  56. package/dist/planner/utils/append-work-service.js +18 -1
  57. package/dist/planner/utils/append-work-task.js +13 -3
  58. package/dist/planner/utils/get-work-runtime.js +6 -1
  59. package/dist/planner/utils/normalize-path.js +19 -1
  60. package/dist/planner/utils/parse-work-mount.js +7 -1
  61. package/dist/planner/utils/parse-work-port.js +4 -1
  62. package/dist/planner/validate.js +22 -0
  63. package/dist/planner/work-network.js +3 -1
  64. package/dist/planner/work-runtime-aggregate.js +32 -0
  65. package/dist/planner/work-runtime-kubernetes.js +136 -13
  66. package/dist/planner/work-task.js +6 -1
  67. package/dist/program.js +27 -9
  68. package/dist/remote/device-login.js +7 -2
  69. package/dist/remote/host-config.js +69 -3
  70. package/dist/remote/remote-commands.js +33 -7
  71. package/dist/remote/remote-status.js +112 -0
  72. package/dist/remote/remote-work-commands.js +562 -45
  73. package/dist/remote/server-client.js +15 -2
  74. package/dist/remote/token-source.js +64 -26
  75. package/dist/run-program.js +9 -0
  76. package/dist/schema/build-file-base-task-schema.js +3 -3
  77. package/dist/schema/build-file-container-service-schema.js +10 -0
  78. package/dist/schema/build-file-container-task-schema.js +16 -1
  79. package/dist/schema/build-file-include-schema.js +18 -4
  80. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  81. package/dist/schema/build-file-task-schema.js +5 -1
  82. package/dist/schema/capabilities-schema.js +56 -0
  83. package/dist/schema/reference-parser.js +119 -11
  84. package/dist/schema/removed-properties.js +20 -0
  85. package/dist/schema/schema-parser.js +94 -41
  86. package/dist/schema/work-scope-parser.js +33 -10
  87. package/dist/secrets/command-secret-provider.js +3 -1
  88. package/dist/secrets/github-app-token-command.js +3 -1
  89. package/dist/secrets/secret-catalog.js +43 -19
  90. package/dist/server/accounts-api.js +4 -1
  91. package/dist/server/approvals-api.js +1 -1
  92. package/dist/server/capacity-format.js +36 -0
  93. package/dist/server/capacity.js +300 -0
  94. package/dist/server/checks-api.js +83 -0
  95. package/dist/server/checks.js +116 -0
  96. package/dist/server/dashboard.js +196 -0
  97. package/dist/server/definitions-api.js +66 -3
  98. package/dist/server/endpoints.js +794 -0
  99. package/dist/server/executor.js +164 -9
  100. package/dist/server/kubernetes-backend.js +415 -48
  101. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  102. package/dist/server/overview.js +271 -0
  103. package/dist/server/plan.js +170 -121
  104. package/dist/server/policy.js +34 -2
  105. package/dist/server/proposals-api.js +17 -8
  106. package/dist/server/repo-access.js +5 -0
  107. package/dist/server/run-logs.js +4 -0
  108. package/dist/server/run-plan.js +38 -4
  109. package/dist/server/run-request.js +58 -3
  110. package/dist/server/run-worker.js +262 -13
  111. package/dist/server/runs.js +962 -36
  112. package/dist/server/sandbox-pod.js +28 -0
  113. package/dist/server/sandbox-storage.js +23 -0
  114. package/dist/server/sandboxes.js +8 -0
  115. package/dist/server/server-config.js +246 -10
  116. package/dist/server/server.js +429 -32
  117. package/dist/server/service-runs.js +82 -0
  118. package/dist/server/timings.js +132 -0
  119. package/dist/server/wait-reason.js +21 -0
  120. package/dist/utils/tar.js +28 -1
  121. package/dist/utils/terminal-prompt.js +53 -0
  122. package/package.json +8 -2
@@ -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");
@@ -39,50 +40,33 @@ const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
39
40
  // A hash alone can only name what is inside the bundle, so everything a build
40
41
  // file pulls in from elsewhere has to be pinned: a git include needs a commit
41
42
  // and a remote that is not a path on the server.
42
- function findLooseIncludes(bundle, noRemote = false) {
43
+ function checkGitSource(path, where, source, noRemote) {
44
+ if (noRemote === 'proposal') {
45
+ throw new DefinitionError(`${path}: ${where}: a proposal may not include from git, the server would fetch it before anybody approved it`);
46
+ }
47
+ if (noRemote === 'check') {
48
+ throw new DefinitionError(`${path}: ${where}: checking a build file that includes from git needs builds: publish, the server would fetch it`);
49
+ }
43
50
  const problems = [];
44
- for (const [path, content] of Object.entries(bundle.files)) {
45
- if (!/\.ya?ml$/i.test(path)) {
46
- continue;
47
- }
48
- let document;
49
- try {
50
- document = (0, yaml_1.parse)(content);
51
- }
52
- catch {
53
- continue; // a file that isn't valid yaml is reported when the bundle is parsed
54
- }
55
- for (const block of ['includes', 'references']) {
56
- for (const [name, include] of Object.entries(document?.[block] ?? {})) {
57
- const source = include;
58
- if (!source || typeof source !== 'object' || source.git === undefined) {
59
- continue;
60
- }
61
- if (noRemote) {
62
- problems.push(`${path}: ${block}.${name}: a proposal may not include from git, the server would fetch it before anybody approved it`);
63
- continue;
64
- }
65
- if (typeof source.git !== 'string' || !/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
66
- problems.push(`${path}: ${block}.${name}: git has to be an https:// or ssh:// address`);
67
- }
68
- if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
69
- problems.push(`${path}: ${block}.${name}: pin ref to a commit, a branch or tag can move`);
70
- }
71
- }
72
- }
51
+ if (!/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
52
+ problems.push(`${path}: ${where}: git has to be an https:// or ssh:// address`);
53
+ }
54
+ if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
55
+ problems.push(`${path}: ${where}: pin ref to a commit, a branch or tag can move`);
56
+ }
57
+ if (problems.length > 0) {
58
+ throw new DefinitionError(problems.join('\n'));
73
59
  }
74
- return problems;
75
60
  }
76
61
  // What a build file may not name on a server: the places it would run or store things
77
62
  // are the operator's. The docker host, a Kubernetes cluster and a remote cache are
78
63
  // credentials of the machine, not of the build file, and a project file that could carry
79
64
  // them could send runs, or the cache, anywhere.
80
- function findHostProblems(bundle) {
65
+ // A file a server leaves out (`publish: false`) is not checked, and neither is a service
66
+ // with `publish: false`: what is left out is never run there.
67
+ function findHostProblems(files) {
81
68
  const problems = [];
82
- for (const [path, content] of Object.entries(bundle.files)) {
83
- if (!/\.ya?ml$/i.test(path)) {
84
- continue;
85
- }
69
+ for (const [path, content] of Object.entries(files)) {
86
70
  let document;
87
71
  try {
88
72
  document = (0, yaml_1.parse)(content);
@@ -104,6 +88,7 @@ function findHostProblems(bundle) {
104
88
  for (const [name, service] of Object.entries(document.services ?? {})) {
105
89
  if (service &&
106
90
  typeof service === 'object' &&
91
+ service.publish !== false &&
107
92
  ('selector' in service || 'kubeconfig' in service || 'context' in service)) {
108
93
  problems.push(`${path}: services.${name}: Kubernetes services are not supported on a server`);
109
94
  }
@@ -111,6 +96,17 @@ function findHostProblems(bundle) {
111
96
  }
112
97
  return problems;
113
98
  }
99
+ // The files of the bundle a server publishes, see getPublishedFiles
100
+ function publishedBundleFiles(bundle, directory, entry) {
101
+ const files = {};
102
+ for (const scope of (0, reference_parser_1.getPublishedFiles)(entry)) {
103
+ const path = (0, path_1.relative)(directory, scope.fileName).split(path_1.sep).join('/');
104
+ if (!scope.remote && path in bundle.files) {
105
+ files[path] = bundle.files[path];
106
+ }
107
+ }
108
+ return files;
109
+ }
114
110
  // What a build file may read while it is inspected: its own files. The rest of
115
111
  // the server is not its business (a missing file is "not there", not an error).
116
112
  function confined(files, root) {
@@ -165,101 +161,139 @@ function describeSecret(secret) {
165
161
  // server has no account, so nothing would stand between the build file and the
166
162
  // operator's own credentials.
167
163
  function findPolicyProblems(items, policy, root) {
168
- const problems = [];
164
+ // the problems of each item, said once for the items of a directory that share one, such
165
+ // as the tasks of an included file that all mount the docker socket
166
+ const grouped = new Map();
169
167
  for (const item of items) {
170
- const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
171
- if (policy.runtimes && !policy.runtimes.includes(runtime)) {
172
- problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
173
- }
174
- const data = item.data;
175
- // what a task reads and writes is the bundle's: `src: ../..` would have the server
176
- // copy its own files (its credentials, other callers' caches) into the task
177
- for (const source of 'src' in data ? data.src : []) {
178
- if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
179
- problems.push(`${item.name}: src ${source.source} is outside of the bundle`);
168
+ const prefix = `${item.name}: `;
169
+ for (const problem of findItemPolicyProblems(item, policy, root)) {
170
+ const said = problem.startsWith(prefix) ? problem.slice(prefix.length) : problem;
171
+ const key = `${item.data.cwd}\0${said}`;
172
+ const group = grouped.get(key);
173
+ if (group) {
174
+ group.names.push(item.name);
180
175
  }
181
- }
182
- for (const generate of (0, work_task_1.isWorkTask)(data) ? data.generates : []) {
183
- if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
184
- problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
176
+ else {
177
+ grouped.set(key, { names: [item.name], problem: said });
185
178
  }
186
179
  }
187
- // what holds a credential gets at most `runs.secretNetwork`, so a tool it runs can't
188
- // send the credential anywhere else
189
- const reads = getCredentialReads({
190
- secrets: secretsOf(item).map(describeSecret),
191
- serverEnvs: 'envs' in data ? data.envs.processNames ?? [] : [],
192
- });
193
- const ceilings = [];
194
- if (policy.network && (0, work_network_1.isLimitedNetwork)(policy.network)) {
195
- ceilings.push({ ceiling: policy.network, setting: 'runs.network', reason: '' });
180
+ }
181
+ return [...grouped.values()].map(({ names: [name, ...others], problem }) => others.length > 0 ? `${name} (also ${others.join(', ')}): ${problem}` : `${name}: ${problem}`);
182
+ }
183
+ function findItemPolicyProblems(item, policy, root) {
184
+ const problems = [];
185
+ const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
186
+ // an aggregate runs nothing, on no runtime
187
+ if (policy.runtimes && !(0, work_task_1.isAggregateTask)(item.data) && !policy.runtimes.includes(runtime)) {
188
+ problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
189
+ }
190
+ const data = item.data;
191
+ // a capability holds in the sandboxed runtime the operator named for it, so only those
192
+ // the operator allows are added
193
+ const refused = policy.capabilities
194
+ ? ('capAdd' in data ? data.capAdd : []).filter((capability) => !policy.capabilities?.includes(capability))
195
+ : [];
196
+ if (refused.length > 0) {
197
+ const from = policy.capabilitiesFrom ?? 'backend.capabilities.allow';
198
+ problems.push(`${item.name}: adds the ${refused.length > 1 ? 'capabilities' : 'capability'} ${refused.join(', ')}, ` +
199
+ (policy.capabilities && policy.capabilities.length > 0
200
+ ? `which this server does not allow (${from}: ${policy.capabilities.join(', ')})`
201
+ : `and this server allows none (${from})`));
202
+ }
203
+ // what a task reads and writes is the bundle's: `src: ../..` would have the server
204
+ // copy its own files (its credentials, other callers' caches) into the task
205
+ for (const source of 'src' in data ? data.src : []) {
206
+ if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
207
+ problems.push(`${item.name}: src ${source.source} is outside of the bundle`);
196
208
  }
197
- if (reads.length > 0 && policy.secretNetwork && (0, work_network_1.isLimitedNetwork)(policy.secretNetwork)) {
198
- ceilings.push({
199
- ceiling: policy.secretNetwork,
200
- setting: 'runs.secretNetwork',
201
- reason: ` to what reads ${reads.join(', ')}`,
202
- });
209
+ }
210
+ for (const generate of (0, work_task_1.isWorkTask)(data) ? data.generates : []) {
211
+ if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
212
+ problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
203
213
  }
204
- for (const { ceiling, setting, reason } of ceilings) {
205
- // on a cluster a local task is a pod of a pool's tool image, limited like any other
206
- if (data.type === 'local-task' && policy.backend !== 'kubernetes') {
207
- problems.push(`${item.name}: is a local task, which can't be limited to the network this server allows${reason} ` +
208
- `(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
209
- }
210
- else if ('network' in data && (0, work_network_1.exceedsNetwork)(data.network, ceiling)) {
211
- problems.push(`${item.name}: asks for network ${(0, work_network_1.formatNetwork)(data.network)}, more than this server allows${reason} ` +
212
- `(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
213
- }
214
+ }
215
+ // what holds a credential gets at most `runs.secretNetwork`, so a tool it runs can't
216
+ // send the credential anywhere else
217
+ const reads = getCredentialReads({
218
+ secrets: secretsOf(item).map(describeSecret),
219
+ serverEnvs: 'envs' in data ? data.envs.processNames ?? [] : [],
220
+ });
221
+ const ceilings = [];
222
+ if (policy.network && (0, work_network_1.isLimitedNetwork)(policy.network)) {
223
+ ceilings.push({ ceiling: policy.network, setting: 'runs.network', reason: '' });
224
+ }
225
+ if (reads.length > 0 && policy.secretNetwork && (0, work_network_1.isLimitedNetwork)(policy.secretNetwork)) {
226
+ ceilings.push({
227
+ ceiling: policy.secretNetwork,
228
+ setting: 'runs.secretNetwork',
229
+ reason: ` to what reads ${reads.join(', ')}`,
230
+ });
231
+ }
232
+ // an aggregate reaches nothing: it runs no command
233
+ for (const { ceiling, setting, reason } of (0, work_task_1.isAggregateTask)(data) ? [] : ceilings) {
234
+ // on a cluster a local task is a pod of a pool's tool image, limited like any other
235
+ if (data.type === 'local-task' && policy.backend !== 'kubernetes') {
236
+ problems.push(`${item.name}: is a local task, which can't be limited to the network this server allows${reason} ` +
237
+ `(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
214
238
  }
215
- if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
216
- for (const mount of data.mounts) {
217
- // a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
218
- if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
219
- problems.push(`${item.name}: mounts ${mount.localPath}, which is outside of the bundle`);
220
- }
221
- }
239
+ else if ('network' in data && (0, work_network_1.exceedsNetwork)(data.network, ceiling)) {
240
+ problems.push(`${item.name}: asks for network ${(0, work_network_1.formatNetwork)(data.network)}, more than this server allows${reason} ` +
241
+ `(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
222
242
  }
223
- for (const volume of (0, work_service_1.isContainerWorkService)(data) ? data.volumes : []) {
224
- // docker takes anything else for a path of the host, such as the docker socket
225
- if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
226
- problems.push(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
243
+ }
244
+ if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
245
+ for (const mount of data.mounts) {
246
+ // a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
247
+ if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
248
+ problems.push(`${item.name}: mounts ${describeMountSource(mount)}, which is outside of the bundle`);
227
249
  }
228
250
  }
229
- for (const secret of secretsOf(item)) {
230
- const source = secret.source;
231
- if (policy.backend === 'kubernetes' && source.type === 'provider') {
232
- // the server reads no secret of a provider on a cluster: the Job of a task does
233
- if (!(0, work_task_1.isWorkTask)(item.data)) {
234
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider}, which ` +
235
- 'this server does for tasks only, a service on its cluster gets no provider secret yet');
236
- }
237
- else if (secret.cache) {
238
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
239
- 'the value before the Job of the task reads it, which this server does not support yet');
240
- }
241
- else if ((0, work_network_1.isLimitedNetwork)(item.data.network) && !policy.proxiedProviders?.includes(source.provider)) {
242
- // the init containers that read it share the pod's NetworkPolicy, and reach the
243
- // provider only through the egress proxy
244
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
245
- `Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it: ` +
246
- `this server names no endpoints of ${source.provider} in backend.providers.${source.provider}.allow ` +
247
- 'for the egress proxy to let through, so the task keeps an open network');
248
- }
249
- }
250
- if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
251
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
251
+ }
252
+ for (const volume of (0, work_service_1.isContainerWorkService)(data) ? data.volumes : []) {
253
+ // docker takes anything else for a path of the host, such as the docker socket
254
+ if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
255
+ problems.push(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
256
+ }
257
+ }
258
+ for (const secret of secretsOf(item)) {
259
+ const source = secret.source;
260
+ if (policy.backend === 'kubernetes' && source.type === 'provider') {
261
+ // the server reads no secret of a provider on a cluster: the Job of a task does
262
+ if (!(0, work_task_1.isWorkTask)(item.data)) {
263
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider}, which ` +
264
+ 'this server does for tasks only, a service on its cluster gets no provider secret yet');
252
265
  }
253
- else if (source.type === 'file') {
254
- problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
266
+ else if (secret.cache) {
267
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
268
+ 'the value before the Job of the task reads it, which this server does not support yet');
255
269
  }
256
- else if (source.type === 'env' && !policy.envs.includes(source.name)) {
257
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in ${policy.envsFrom ?? 'runs.env.allow'}`);
270
+ else if ((0, work_network_1.isLimitedNetwork)(item.data.network) && !policy.proxiedProviders?.includes(source.provider)) {
271
+ // the init containers that read it share the pod's NetworkPolicy, and reach the
272
+ // provider only through the egress proxy
273
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
274
+ `Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it: ` +
275
+ `this server names no endpoints of ${source.provider} in backend.providers.${source.provider}.allow ` +
276
+ 'for the egress proxy to let through, so the task keeps an open network');
258
277
  }
259
278
  }
279
+ if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
280
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
281
+ }
282
+ else if (source.type === 'file') {
283
+ problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
284
+ }
285
+ else if (source.type === 'env' && !policy.envs.includes(source.name)) {
286
+ 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'}`);
287
+ }
260
288
  }
261
289
  return problems;
262
290
  }
291
+ // A mount of the home directory says so: `$PWD` in a mount is the home directory, not the
292
+ // project, which reads like a path of the checkout
293
+ function describeMountSource(mount) {
294
+ const home = (0, normalize_path_1.getHomeReference)(mount.mount.split(':')[0]);
295
+ return home ? `${mount.localPath} (${home} is the home directory)` : mount.localPath;
296
+ }
263
297
  function toPlan(root, item) {
264
298
  const data = item.data;
265
299
  const secrets = secretsOf(item).map(describeSecret);
@@ -272,7 +306,7 @@ function toPlan(root, item) {
272
306
  : [];
273
307
  const ports = 'ports' in data ? data.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`) : [];
274
308
  const flags = [];
275
- if (data.type === 'local-task') {
309
+ if (data.type === 'local-task' && !(0, work_task_1.isAggregateTask)(data)) {
276
310
  flags.push('local-task');
277
311
  }
278
312
  if ('ports' in data && data.ports.some((port) => port.hostPort !== null)) {
@@ -281,10 +315,15 @@ function toPlan(root, item) {
281
315
  if (data.scope.remote) {
282
316
  flags.push('remote-include');
283
317
  }
318
+ const capAdd = (0, work_task_1.isContainerWorkTask)(data) ? data.capAdd : [];
319
+ if (capAdd.length > 0) {
320
+ flags.push('capabilities');
321
+ }
284
322
  const isTask = data.type === 'local-task' || data.type === 'container-task';
285
323
  return {
286
324
  kind: isTask ? 'task' : 'service',
287
- runtime: data.type === 'local-task' ? 'local' : 'docker',
325
+ ...(data.description ? { description: data.description } : {}),
326
+ runtime: (0, work_task_1.isAggregateTask)(data) ? 'none' : data.type === 'local-task' ? 'local' : 'docker',
288
327
  ...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
289
328
  cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
290
329
  command: (0, task_arguments_1.getArgumentsTarget)(data)?.cmd ?? null,
@@ -298,6 +337,7 @@ function toPlan(root, item) {
298
337
  mounts,
299
338
  ports,
300
339
  generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
340
+ ...(capAdd.length > 0 ? { capAdd: [...capAdd] } : {}),
301
341
  flags,
302
342
  };
303
343
  }
@@ -314,10 +354,6 @@ function describeFailure(e) {
314
354
  // when somebody wants to run it. The same function answers what a run will do,
315
355
  // with the scope of the run: which tasks, services and accounts it involves.
316
356
  async function planBundle(bundle, environment, options = {}) {
317
- const problems = [...findLooseIncludes(bundle, options.noRemote), ...(options.policy ? findHostProblems(bundle) : [])];
318
- if (problems.length > 0) {
319
- throw new DefinitionError(problems.join('\n'));
320
- }
321
357
  const directory = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'hammerkit-definition-'));
322
358
  try {
323
359
  for (const [path, content] of Object.entries(bundle.files)) {
@@ -330,7 +366,20 @@ async function planBundle(bundle, environment, options = {}) {
330
366
  file: confined((0, get_file_context_1.getFileContext)(directory), directory),
331
367
  };
332
368
  try {
333
- const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected);
369
+ const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected, {
370
+ // a hash alone can only name what is inside the bundle, see checkGitSource
371
+ checkGitSource: (source, from, block, name) => checkGitSource(
372
+ // a file of a git include is named by its repository
373
+ from.remote
374
+ ? `${from.remote.git} ${(0, path_1.relative)(from.remote.root, from.fileName).split(path_1.sep).join('/')}`
375
+ : (0, path_1.relative)(directory, from.fileName), `${block}.${name}`, source, options.noRemote),
376
+ });
377
+ if (options.policy) {
378
+ const problems = findHostProblems(publishedBundleFiles(bundle, directory, scope));
379
+ if (problems.length > 0) {
380
+ throw new DefinitionError(problems.join('\n'));
381
+ }
382
+ }
334
383
  const context = await (0, reference_parser_1.parseReferences)(ctx, scope, inspected);
335
384
  const workTree = (0, work_scope_parser_1.getWorkContext)(context, options.scope ?? { environmentName: null }, inspected);
336
385
  const items = itemsOf(workTree);
@@ -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,17 @@ 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;
67
+ let observe = false;
59
68
  entitlements.forEach((entitlement, index) => {
60
69
  if (!matches(entitlement, principal)) {
61
70
  return;
62
71
  }
63
72
  matched.push(index);
73
+ checks = checks || entitlement.checks === 'read';
74
+ observe = observe || entitlement.observe === true;
64
75
  for (const [account, level] of Object.entries(entitlement.accounts)) {
65
76
  if (accounts[account] !== 'use') {
66
77
  accounts[account] = level;
@@ -85,10 +96,31 @@ function getGrant(entitlements, principal) {
85
96
  known.branches[pattern] = higherType(known.branches[pattern], type);
86
97
  }
87
98
  }
99
+ for (const endpoint of entitlement.endpoints ?? []) {
100
+ const known = endpoints.find((entry) => entry.repo === endpoint.repo);
101
+ if (!known) {
102
+ endpoints.push({ ...endpoint });
103
+ }
104
+ else if (endpoint.level === 'publish') {
105
+ known.level = 'publish';
106
+ }
107
+ }
88
108
  });
89
- return { accounts, repos, builds, sandboxes, matched };
109
+ return { accounts, repos, builds, sandboxes, endpoints, checks, observe, matched };
90
110
  }
91
111
  exports.getGrant = getGrant;
112
+ // What the caller may do with the endpoints of service runs of a repository: the higher
113
+ // level of the entries whose `repo` covers it, null for nothing.
114
+ function endpointLevel(grant, repo) {
115
+ let found = null;
116
+ for (const endpoint of grant.endpoints) {
117
+ if ((0, repo_1.matchesRepo)(endpoint.repo, repo) && found !== 'publish') {
118
+ found = endpoint.level;
119
+ }
120
+ }
121
+ return found;
122
+ }
123
+ exports.endpointLevel = endpointLevel;
92
124
  const BRANCH_TYPES = ['pull', 'push'];
93
125
  function higherType(a, b) {
94
126
  return a !== undefined && BRANCH_TYPES.indexOf(a) > BRANCH_TYPES.indexOf(b) ? a : b;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleProposals = exports.mayApprove = void 0;
3
+ exports.handleProposals = exports.decideProposal = exports.mayApprove = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const bundle_1 = require("../remote/bundle");
6
6
  const definitions_1 = require("./definitions");
@@ -23,6 +23,21 @@ function toView(proposal, principal, grant) {
23
23
  canDecide: proposal.state === 'waiting' && (0, exports.mayApprove)(grant, proposal) && !same(proposal.proposedBy, principal),
24
24
  };
25
25
  }
26
+ /**
27
+ * Approves (publishes) or rejects a proposal, for the API and the dashboard: who decides
28
+ * needs the permission to publish and the accounts its proposer lacked. Throws a
29
+ * DefinitionError when the files do not load.
30
+ */
31
+ async function decideProposal(store, proposals, grant, principal, hash, outcome, reason) {
32
+ return proposals.decide(principal, hash, outcome, (proposed) => (0, exports.mayApprove)(grant, proposed), async (bundle) => {
33
+ const proposed = proposals.get(hash);
34
+ await store.publish(bundle, proposed?.proposedBy ?? principal, {
35
+ approvedBy: principal,
36
+ annotations: proposed?.annotations,
37
+ });
38
+ }, reason);
39
+ }
40
+ exports.decideProposal = decideProposal;
26
41
  /**
27
42
  * New versions of build files that wait for an approval. Whoever may only
28
43
  * propose a version (`builds: propose`), or publishes one that names accounts they
@@ -94,13 +109,7 @@ async function handleProposals(config, store, proposals, log, principal, req, re
94
109
  }
95
110
  const outcome = action === 'approve' ? 'approved' : 'rejected';
96
111
  try {
97
- const result = await proposals.decide(principal, hash, outcome, (proposed) => (0, exports.mayApprove)(grant, proposed), async (bundle) => {
98
- const proposed = proposals.get(hash);
99
- await store.publish(bundle, proposed?.proposedBy ?? principal, {
100
- approvedBy: principal,
101
- annotations: proposed?.annotations,
102
- });
103
- }, body.data.reason);
112
+ const result = await decideProposal(store, proposals, grant, principal, hash, outcome, body.data.reason);
104
113
  log(`${action} proposal=${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
105
114
  switch (result.status) {
106
115
  case 'ok':
@@ -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);
@@ -106,6 +106,10 @@ class LogIndex {
106
106
  get size() {
107
107
  return this.lineStart.length;
108
108
  }
109
+ // the tasks, services and `hammerkit` that wrote to the log, in the order they first did
110
+ names() {
111
+ return [...this.taskLines.keys()];
112
+ }
109
113
  lines(task) {
110
114
  return this.taskLines.get(task) ?? [];
111
115
  }
@@ -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 {
@@ -87,4 +91,34 @@ function refuseArguments(run, plan, policy = {}) {
87
91
  return null;
88
92
  }
89
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;
90
124
  //# sourceMappingURL=run-plan.js.map