hammerkit 1.27.0-next.2 → 1.27.0-next.20
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.
- package/build.schema.json +9 -0
- package/dist/cache/backends/registry-cache-backend.js +2 -2
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +1 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/event-cache.js +20 -2
- package/dist/executer/execute-work-task.js +5 -3
- package/dist/executer/execution-steps.js +5 -2
- package/dist/executer/scheduler/enqueue-next.js +7 -0
- package/dist/executer/scheduler/state-key.js +42 -5
- package/dist/harness/connect.js +20 -6
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +63 -114
- package/dist/harness/opencode.js +158 -50
- package/dist/harness/pi.js +29 -0
- package/dist/harness/sandbox-client.js +207 -65
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +27 -16
- package/dist/kubernetes/claim-layout.js +71 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
- package/dist/kubernetes/ensure-persistent-data.js +30 -20
- package/dist/kubernetes/get-service-ip.js +2 -2
- package/dist/kubernetes/job-secrets.js +33 -19
- package/dist/kubernetes/kept-claims.js +22 -10
- package/dist/kubernetes/network-policy.js +85 -27
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/resources.js +25 -3
- package/dist/kubernetes/restore-kubernetes-data.js +9 -3
- package/dist/kubernetes/sandbox-runner.js +626 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/volumes.js +6 -3
- package/dist/network/egress-proxy.js +45 -6
- package/dist/optimizer/calculate-checksum.js +3 -2
- package/dist/optimizer/check-key.js +94 -0
- package/dist/optimizer/check-keys.js +64 -0
- package/dist/optimizer/get-work-cache-stats.js +17 -3
- package/dist/optimizer/work-task-cache-description.js +51 -1
- package/dist/planner/utils/append-work-dependencies.js +10 -5
- package/dist/planner/utils/append-work-service.js +1 -0
- package/dist/planner/utils/append-work-task.js +15 -2
- package/dist/planner/utils/get-work-runtime.js +6 -1
- package/dist/planner/utils/normalize-path.js +19 -1
- package/dist/planner/utils/parse-work-mount.js +7 -1
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/validate.js +22 -0
- package/dist/planner/work-network.js +3 -1
- package/dist/planner/work-runtime-aggregate.js +32 -0
- package/dist/planner/work-runtime-docker.js +2 -1
- package/dist/planner/work-runtime-kubernetes.js +97 -48
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +14 -4
- package/dist/remote/annotations.js +13 -1
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +64 -3
- package/dist/remote/remote-commands.js +41 -9
- package/dist/remote/remote-work-commands.js +246 -1
- package/dist/remote/server-client.js +15 -2
- package/dist/remote/token-source.js +64 -26
- package/dist/run-program.js +9 -0
- package/dist/schema/build-file-base-task-schema.js +5 -1
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/reference-parser.js +9 -1
- package/dist/schema/work-scope-parser.js +6 -0
- package/dist/secrets/github-app-token-command.js +3 -1
- package/dist/server/accounts-api.js +3 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/definitions-api.js +4 -2
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +56 -5
- package/dist/server/kubernetes-backend.js +303 -64
- package/dist/server/kubernetes-sandbox-storage.js +48 -3
- package/dist/server/oauth.js +12 -1
- package/dist/server/plan.js +126 -62
- package/dist/server/policy.js +32 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +50 -19
- package/dist/server/run-request.js +64 -3
- package/dist/server/run-worker.js +146 -10
- package/dist/server/runs.js +347 -27
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes-api.js +10 -4
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-command.js +39 -20
- package/dist/server/server-config.js +213 -14
- package/dist/server/server.js +268 -15
- package/dist/server/service-runs.js +82 -0
- package/dist/utils/abort-on-signals.js +46 -6
- package/dist/utils/tar.js +36 -0
- 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,
|
|
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
|
}
|
package/dist/server/oauth.js
CHANGED
|
@@ -106,7 +106,7 @@ class TokenVerifier {
|
|
|
106
106
|
? [groups]
|
|
107
107
|
: [],
|
|
108
108
|
claims,
|
|
109
|
-
...(
|
|
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
|
package/dist/server/plan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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 (
|
|
219
|
-
problems.push(`${item.name}:
|
|
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 (
|
|
222
|
-
|
|
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
|
-
|
|
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,
|
package/dist/server/policy.js
CHANGED
|
@@ -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);
|
package/dist/server/run-plan.js
CHANGED
|
@@ -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
|
-
|
|
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 ? [] :
|
|
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
|