hammerkit 1.27.0-next.4 → 1.27.0-next.6
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/executer/event-cache.js +20 -2
- package/dist/kubernetes/claim-layout.js +71 -0
- package/dist/kubernetes/ensure-persistent-data.js +21 -8
- package/dist/kubernetes/job-secrets.js +24 -13
- package/dist/kubernetes/network-policy.js +69 -19
- package/dist/kubernetes/restore-kubernetes-data.js +9 -3
- package/dist/kubernetes/volumes.js +4 -1
- package/dist/network/egress-proxy.js +45 -6
- package/dist/optimizer/work-task-cache-description.js +2 -0
- package/dist/planner/utils/append-work-dependencies.js +5 -2
- package/dist/planner/utils/append-work-service.js +1 -0
- package/dist/planner/utils/append-work-task.js +4 -1
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/work-runtime-kubernetes.js +55 -33
- package/dist/program.js +4 -1
- 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 +1 -0
- package/dist/server/executor.js +1 -1
- package/dist/server/plan.js +46 -10
- package/dist/server/run-plan.js +12 -15
- package/dist/server/server-config.js +35 -4
- package/dist/server/server.js +10 -2
- package/package.json +1 -1
package/build.schema.json
CHANGED
|
@@ -289,6 +289,9 @@
|
|
|
289
289
|
],
|
|
290
290
|
"description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
|
|
291
291
|
},
|
|
292
|
+
"args": {
|
|
293
|
+
"type": "boolean"
|
|
294
|
+
},
|
|
292
295
|
"runner": {
|
|
293
296
|
"type": "string",
|
|
294
297
|
"minLength": 1
|
|
@@ -370,6 +373,9 @@
|
|
|
370
373
|
"network": {
|
|
371
374
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network"
|
|
372
375
|
},
|
|
376
|
+
"args": {
|
|
377
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/args"
|
|
378
|
+
},
|
|
373
379
|
"runner": {
|
|
374
380
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
|
|
375
381
|
},
|
|
@@ -511,6 +517,9 @@
|
|
|
511
517
|
"network": {
|
|
512
518
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network",
|
|
513
519
|
"description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
|
|
520
|
+
},
|
|
521
|
+
"args": {
|
|
522
|
+
"type": "boolean"
|
|
514
523
|
}
|
|
515
524
|
},
|
|
516
525
|
"required": [
|
|
@@ -8,6 +8,8 @@ const execute_docker_1 = require("./execute-docker");
|
|
|
8
8
|
const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
|
|
9
9
|
const get_cache_directory_1 = require("../optimizer/get-cache-directory");
|
|
10
10
|
const path_1 = require("path");
|
|
11
|
+
const crypto_1 = require("crypto");
|
|
12
|
+
const claim_layout_1 = require("../kubernetes/claim-layout");
|
|
11
13
|
const move_files_1 = require("../file/move-files");
|
|
12
14
|
const work_instance_id_1 = require("../planner/work-instance-id");
|
|
13
15
|
async function restoreCache(environment, path, workTree) {
|
|
@@ -43,8 +45,24 @@ function* getArchivePaths(task, path) {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
else {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
// `a-b` and `a/b` flatten alike: of those, the least deep (then the first in sorted
|
|
49
|
+
// order) keeps the name, the others' names carry a hash of their relative path, so a
|
|
50
|
+
// store and a restore in another directory or order find the same archive
|
|
51
|
+
const generates = task.generates
|
|
52
|
+
.filter((g) => !g.inherited)
|
|
53
|
+
.map((generate) => ({ generate, rel: (0, path_1.relative)(task.cwd, generate.path).split(path_1.sep).join(path_1.posix.sep) }));
|
|
54
|
+
const holders = new Map();
|
|
55
|
+
for (const { rel } of [...generates].sort((a, b) => (0, claim_layout_1.byDepth)(a.rel, b.rel))) {
|
|
56
|
+
const id = rel.split(path_1.posix.sep).join('-');
|
|
57
|
+
if (!holders.has(id)) {
|
|
58
|
+
holders.set(id, rel);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const { generate, rel } of generates) {
|
|
62
|
+
let generateId = rel.split(path_1.posix.sep).join('-');
|
|
63
|
+
if (holders.get(generateId) !== rel) {
|
|
64
|
+
generateId += '-' + (0, crypto_1.createHash)('sha256').update(rel).digest('hex').slice(0, 12);
|
|
65
|
+
}
|
|
48
66
|
yield {
|
|
49
67
|
filename: (0, path_1.join)(path, generateId + '-generates.tgz'),
|
|
50
68
|
path: generate.path,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hasOutputsFlatteningAlike = exports.hasPathsEndingAlike = exports.byDepth = exports.getClaimSubPath = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const portable_path_1 = require("../planner/utils/portable-path");
|
|
7
|
+
// The paths a work item keeps in its claim. Its consumers mount its outputs by the same
|
|
8
|
+
// paths, so they find the same folders.
|
|
9
|
+
function getClaimPaths(work) {
|
|
10
|
+
const data = work.data;
|
|
11
|
+
const paths = [];
|
|
12
|
+
if ('mounts' in data) {
|
|
13
|
+
paths.push(...data.mounts.map((mount) => mount.containerPath));
|
|
14
|
+
}
|
|
15
|
+
if ('src' in data) {
|
|
16
|
+
paths.push(...data.src.filter((src) => !src.inherited).map((src) => src.absolutePath));
|
|
17
|
+
}
|
|
18
|
+
if ('generates' in data) {
|
|
19
|
+
paths.push(...data.generates.filter((generate) => !generate.inherited).map((generate) => generate.path));
|
|
20
|
+
}
|
|
21
|
+
if ('volumes' in data) {
|
|
22
|
+
paths.push(...data.volumes.filter((volume) => !volume.inherited).map((volume) => volume.containerPath));
|
|
23
|
+
}
|
|
24
|
+
return paths;
|
|
25
|
+
}
|
|
26
|
+
// Where `path` is in the claim of `work`, relative to its root. A path is named after its
|
|
27
|
+
// last segment, so claims without two such paths are laid out as they always were (a
|
|
28
|
+
// service's data among them). Of the paths with the same last segment, the least deep one
|
|
29
|
+
// (then the first in sorted order) keeps the name, and the others also carry a hash of
|
|
30
|
+
// their path. Both go by the path
|
|
31
|
+
// relative to the project, as the task's id does, so a run in another checkout directory
|
|
32
|
+
// (each run of a server has its own) or with its `generates` in another order finds the
|
|
33
|
+
// same folders in a kept claim.
|
|
34
|
+
function getClaimSubPath(work, path) {
|
|
35
|
+
const name = (0, path_1.basename)(path) || 'root';
|
|
36
|
+
const alike = getClaimPaths(work).filter((other) => other !== path && (0, path_1.basename)(other) === (0, path_1.basename)(path));
|
|
37
|
+
if (alike.length === 0) {
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
const own = (0, portable_path_1.portablePath)(work.data.projectRoot, path);
|
|
41
|
+
const first = [own, ...alike.map((other) => (0, portable_path_1.portablePath)(work.data.projectRoot, other))].sort(byDepth)[0];
|
|
42
|
+
if (first === own) {
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
return `${name}-${(0, crypto_1.createHash)('sha256').update(own).digest('hex').slice(0, 12)}`;
|
|
46
|
+
}
|
|
47
|
+
exports.getClaimSubPath = getClaimSubPath;
|
|
48
|
+
function byDepth(a, b) {
|
|
49
|
+
return a.split('/').length - b.split('/').length || (a < b ? -1 : a > b ? 1 : 0);
|
|
50
|
+
}
|
|
51
|
+
exports.byDepth = byDepth;
|
|
52
|
+
// Whether two different paths of the claim of `work` end alike. Before getClaimSubPath
|
|
53
|
+
// named them apart, they shared one folder, and outputs made then are mixed.
|
|
54
|
+
function hasPathsEndingAlike(work) {
|
|
55
|
+
const paths = new Set(getClaimPaths(work));
|
|
56
|
+
return new Set([...paths].map((path) => (0, path_1.basename)(path))).size < paths.size;
|
|
57
|
+
}
|
|
58
|
+
exports.hasPathsEndingAlike = hasPathsEndingAlike;
|
|
59
|
+
// Whether two outputs of `work` flatten to the same name of a cache archive (`a-b` and
|
|
60
|
+
// `a/b`, see getArchivePaths). Before they were named apart, one archive held both.
|
|
61
|
+
function hasOutputsFlatteningAlike(work) {
|
|
62
|
+
const data = work.data;
|
|
63
|
+
if (!('generates' in data) || !('cwd' in data)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const paths = new Set(data.generates.filter((generate) => !generate.inherited).map((generate) => generate.path));
|
|
67
|
+
const names = new Set([...paths].map((path) => (0, path_1.relative)(data.cwd, path).split(path_1.sep).join('-')));
|
|
68
|
+
return names.size < paths.size;
|
|
69
|
+
}
|
|
70
|
+
exports.hasOutputsFlatteningAlike = hasOutputsFlatteningAlike;
|
|
71
|
+
//# sourceMappingURL=claim-layout.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ensurePersistentData = exports.clearClaim = exports.getPodForPersistence = void 0;
|
|
3
|
+
exports.ensurePersistentData = exports.packSource = exports.clearClaim = exports.getPodForPersistence = void 0;
|
|
4
4
|
const apply_1 = require("./apply");
|
|
5
5
|
const path_1 = require("path");
|
|
6
6
|
const tar_1 = require("tar");
|
|
@@ -137,6 +137,25 @@ async function clearClaim(instance, env, work, pod) {
|
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
139
|
exports.clearClaim = clearClaim;
|
|
140
|
+
// The archive that uploads a source into the claim: its file or directory, at its place in
|
|
141
|
+
// the claim (see getClaimSubPath) instead of under its own name
|
|
142
|
+
function packSource(source) {
|
|
143
|
+
const fileItem = (0, path_1.basename)(source.localPath);
|
|
144
|
+
return stream_1.Readable.from((0, tar_1.create)({
|
|
145
|
+
cwd: (0, path_1.dirname)(source.localPath),
|
|
146
|
+
filter(path) {
|
|
147
|
+
return source.matcher(path, source.localPath);
|
|
148
|
+
},
|
|
149
|
+
onWriteEntry(entry) {
|
|
150
|
+
entry.path = source.subPath + entry.path.slice(fileItem.length);
|
|
151
|
+
// the second of two hard-linked files links to the first by its path in the archive
|
|
152
|
+
if (entry.type === 'Link' && entry.linkpath?.startsWith(fileItem)) {
|
|
153
|
+
entry.linkpath = source.subPath + entry.linkpath.slice(fileItem.length);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
}, [fileItem]));
|
|
157
|
+
}
|
|
158
|
+
exports.packSource = packSource;
|
|
140
159
|
async function ensurePersistentData(instance, env, environment, service, persistence,
|
|
141
160
|
// the kept claim of the work holds what an earlier run left, which goes first
|
|
142
161
|
options = {}) {
|
|
@@ -163,13 +182,7 @@ options = {}) {
|
|
|
163
182
|
if (!(await environment.file.exists(source.localPath))) {
|
|
164
183
|
continue;
|
|
165
184
|
}
|
|
166
|
-
const
|
|
167
|
-
const data = stream_1.Readable.from((0, tar_1.create)({
|
|
168
|
-
cwd: (0, path_1.dirname)(source.localPath),
|
|
169
|
-
filter(path) {
|
|
170
|
-
return source.matcher(path, source.localPath);
|
|
171
|
-
},
|
|
172
|
-
}, [fileItem]));
|
|
185
|
+
const data = packSource(source);
|
|
173
186
|
service.status.console('stdout', `upload ${source.localPath}`);
|
|
174
187
|
const targetPath = `/dev/hammerkit/${service.id()}`;
|
|
175
188
|
const res = await instance.exec.exec(env.namespace, name, 'volume', ['tar', 'xf', '-', '-C', targetPath], process.stdout, process.stderr, data, false, (status) => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getJobSecretSpec = exports.removeJobCredentials = exports.assertJobAccounts = exports.ensureJobCredentials = exports.getCredentialsName = exports.
|
|
3
|
+
exports.getJobSecretSpec = exports.removeJobCredentials = exports.assertJobAccounts = exports.ensureJobCredentials = exports.getCredentialsName = exports.getSecretHosts = exports.splitJobSecrets = exports.ACCOUNT_PATH = exports.SECRET_VALUES_PATH = void 0;
|
|
4
4
|
const work_secret_1 = require("../planner/work-secret");
|
|
5
5
|
const command_secret_provider_1 = require("../secrets/command-secret-provider");
|
|
6
|
+
const task_arguments_1 = require("../planner/utils/task-arguments");
|
|
6
7
|
const version_1 = require("../version");
|
|
7
8
|
const apply_1 = require("./apply");
|
|
8
9
|
const resources_1 = require("./resources");
|
|
@@ -42,19 +43,24 @@ function splitJobSecrets(secrets, server) {
|
|
|
42
43
|
exports.splitJobSecrets = splitJobSecrets;
|
|
43
44
|
/**
|
|
44
45
|
* The init containers that read secrets share the pod, and so the NetworkPolicy, of
|
|
45
|
-
* the task: under a limited network they
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* the task: under a limited network they reach the secret manager only through the
|
|
47
|
+
* task's egress proxy, which lets them reach the endpoints the server names for the
|
|
48
|
+
* provider (`backend.providers.<name>.allow`). A task that reads a secret of a provider
|
|
49
|
+
* without them keeps an open network. Answers the endpoints the proxy lets through.
|
|
48
50
|
*/
|
|
49
|
-
function
|
|
50
|
-
if (secrets.length === 0 || network.type === 'open') {
|
|
51
|
-
return;
|
|
51
|
+
function getSecretHosts(task, secrets, server, network = (0, work_network_1.getEffectiveNetwork)(task.data)) {
|
|
52
|
+
if (!server || secrets.length === 0 || network.type === 'open') {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const unreachable = secrets.find((secret) => !server.providers[secret.source.provider]?.allow?.length);
|
|
56
|
+
if (unreachable) {
|
|
57
|
+
throw new Error(`task ${task.name}: secret ${(0, work_secret_1.getSecretName)(unreachable.secret)} is read from ${(0, work_secret_1.describeSecretSource)(unreachable.secret)} in the Job of the task, which network ${(0, work_network_1.formatNetwork)(network)} would cut off from it; ` +
|
|
58
|
+
`on this cluster a task that reads secrets of ${unreachable.source.provider} keeps an open network, ` +
|
|
59
|
+
`unless the server names its endpoints in backend.providers.${unreachable.source.provider}.allow`);
|
|
52
60
|
}
|
|
53
|
-
|
|
54
|
-
throw new Error(`task ${task.name}: secret ${(0, work_secret_1.getSecretName)(secret.secret)} is read from ${(0, work_secret_1.describeSecretSource)(secret.secret)} in the Job of the task, which network ${(0, work_network_1.formatNetwork)(network)} would cut off from it; ` +
|
|
55
|
-
'on this cluster a task that reads secrets of a provider keeps an open network');
|
|
61
|
+
return [...new Set(secrets.flatMap((secret) => server.providers[secret.source.provider].allow ?? []))].sort();
|
|
56
62
|
}
|
|
57
|
-
exports.
|
|
63
|
+
exports.getSecretHosts = getSecretHosts;
|
|
58
64
|
// The accounts of the secrets, in the order they are first read
|
|
59
65
|
function accountsOf(secrets) {
|
|
60
66
|
return [...new Set(secrets.map((s) => s.source.account))];
|
|
@@ -186,7 +192,9 @@ function quote(value) {
|
|
|
186
192
|
* from it, and the task's output is masked before it leaves the pod, since the
|
|
187
193
|
* server, which masks everything else, never sees these values.
|
|
188
194
|
*/
|
|
189
|
-
function getJobSecretSpec(item, secrets, server, credentials, environment
|
|
195
|
+
function getJobSecretSpec(item, secrets, server, credentials, environment,
|
|
196
|
+
// under a limited network, what sends their requests through the egress proxy
|
|
197
|
+
proxyEnv = []) {
|
|
190
198
|
if (!server || secrets.length === 0) {
|
|
191
199
|
return {
|
|
192
200
|
initContainers: [],
|
|
@@ -229,8 +237,11 @@ function getJobSecretSpec(item, secrets, server, credentials, environment) {
|
|
|
229
237
|
'-c',
|
|
230
238
|
'umask 022 && "$@" > "$HAMMERKIT_VALUE.part" && mv "$HAMMERKIT_VALUE.part" "$HAMMERKIT_VALUE"',
|
|
231
239
|
],
|
|
232
|
-
|
|
240
|
+
// Kubernetes would expand a `$(NAME)` of the container's env in them, the proxy's token
|
|
241
|
+
// among them: the reference is text, as on any other backend
|
|
242
|
+
args: ['sh', command, ...args].map(task_arguments_1.escapeKubernetesText),
|
|
233
243
|
env: [
|
|
244
|
+
...proxyEnv,
|
|
234
245
|
...Object.entries(env).map(([name, value]) => ({ name, value })),
|
|
235
246
|
{ name: 'HAMMERKIT_VALUE', value: `${exports.SECRET_VALUES_PATH}/secret-${index}` },
|
|
236
247
|
],
|
|
@@ -23,6 +23,9 @@ const NETWORK_OF_LABEL = 'hammerkit.dev/network-of';
|
|
|
23
23
|
// on the proxy of a task on a sandbox: the sandbox, whose pod of files it stays with
|
|
24
24
|
const PROXY_OF_SANDBOX_LABEL = 'hammerkit.dev/proxy-of-sandbox';
|
|
25
25
|
const PROXY_READY_TIMEOUT_MS = 60000;
|
|
26
|
+
// the token of the endpoints of secret providers, in a Secret named like the proxy
|
|
27
|
+
const SECRET_TOKEN_KEY = 'token';
|
|
28
|
+
const SECRET_TOKEN_VARIABLE = 'HAMMERKIT_PROXY_TOKEN';
|
|
26
29
|
// how long the pods of an item may take to end before their policy goes
|
|
27
30
|
const POD_END_TIMEOUT_MS = 120000;
|
|
28
31
|
const POD_END_POLL_MS = 500;
|
|
@@ -109,10 +112,10 @@ function getIngress(env, item) {
|
|
|
109
112
|
return [{ _from: [{ podSelector: from }] }];
|
|
110
113
|
}
|
|
111
114
|
// The NetworkPolicy of a limited item: egress to the pods of the services it
|
|
112
|
-
// needs and, under `allow
|
|
113
|
-
// and the proxy are host aliases). Ingress
|
|
114
|
-
// item has none.
|
|
115
|
-
function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item, scope)) {
|
|
115
|
+
// needs and, under `allow` or with secrets read through it, to its proxy;
|
|
116
|
+
// nothing else, DNS included (needs and the proxy are host aliases). Ingress
|
|
117
|
+
// only from what needs it. An open item has none.
|
|
118
|
+
function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item, scope), proxied = network.type === 'allow') {
|
|
116
119
|
const needs = item.needs.filter((need) => (0, work_item_1.isContainerWorkServiceItem)(need.service));
|
|
117
120
|
return {
|
|
118
121
|
apiVersion: 'networking.k8s.io/v1',
|
|
@@ -131,7 +134,7 @@ function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item,
|
|
|
131
134
|
...(needs.length > 0
|
|
132
135
|
? [{ to: needs.map((need) => ({ podSelector: { matchLabels: { [ID_LABEL]: need.service.id() } } })) }]
|
|
133
136
|
: []),
|
|
134
|
-
...(
|
|
137
|
+
...(proxied
|
|
135
138
|
? [
|
|
136
139
|
{
|
|
137
140
|
to: [{ podSelector: { matchLabels: { [PROXY_LABEL]: proxy.key } } }],
|
|
@@ -179,6 +182,9 @@ exports.buildProxyPolicy = buildProxyPolicy;
|
|
|
179
182
|
// left goes.
|
|
180
183
|
// A task follows the proxy's log while its jobs run. A service outlives the
|
|
181
184
|
// run on a cluster, so its refused hosts are in the proxy's own log.
|
|
185
|
+
// `secretHosts` are the endpoints of the providers whose secrets the init
|
|
186
|
+
// containers of a task on a server read: a limited task gets a proxy for them
|
|
187
|
+
// (under `none` too), which passes them to those containers only.
|
|
182
188
|
async function ensureKubernetesItemNetwork(instance, env, item, options) {
|
|
183
189
|
const network = (0, work_network_1.getEffectiveNetwork)(item.data);
|
|
184
190
|
if (network.type !== 'open' && !env.networkPolicyEnforced) {
|
|
@@ -191,22 +197,30 @@ async function ensureKubernetesItemNetwork(instance, env, item, options) {
|
|
|
191
197
|
// without the rights to manage NetworkPolicies, hammerkit never made one
|
|
192
198
|
await removeKubernetesItemNetwork(instance, env, item, { run: null }, { ignoreForbidden: true });
|
|
193
199
|
}
|
|
194
|
-
return { env: [], hostAliases, initContainers: [], close() { } };
|
|
200
|
+
return { env: [], secretEnv: [], hostAliases, initContainers: [], close() { } };
|
|
201
|
+
}
|
|
202
|
+
const secretHosts = options.secretHosts ?? [];
|
|
203
|
+
if (secretHosts.length > 0 && options.sandbox) {
|
|
204
|
+
// a run on a sandbox uses no account, so it reads no secret of a provider
|
|
205
|
+
throw new Error(`${item.name}: a task on a sandbox reads no secret of a provider`);
|
|
195
206
|
}
|
|
196
207
|
const proxy = network.type === 'allow' && options.sandbox
|
|
197
208
|
? getSandboxProxy(env, item, network, hostAliases, options.sandbox)
|
|
198
209
|
: getRunProxy(item, options);
|
|
199
|
-
|
|
210
|
+
const proxied = network.type === 'allow' || secretHosts.length > 0;
|
|
211
|
+
await (0, apply_1.apply)(instance, buildNetworkPolicy(env, item, network, options, proxy, proxied));
|
|
200
212
|
const initContainers = [(0, network_gate_1.getNetworkGateContainer)()];
|
|
201
|
-
if (
|
|
213
|
+
if (!proxied) {
|
|
202
214
|
if (options.sandbox) {
|
|
203
215
|
await removeSandboxProxies(instance, env.namespace, { sandbox: options.sandbox.label, item });
|
|
204
216
|
}
|
|
205
217
|
else {
|
|
206
218
|
await removeProxy(instance, env.namespace, proxy.name, [404]);
|
|
207
219
|
}
|
|
208
|
-
return { env: [], hostAliases, initContainers, close() { } };
|
|
220
|
+
return { env: [], secretEnv: [], hostAliases, initContainers, close() { } };
|
|
209
221
|
}
|
|
222
|
+
// under `none`, a proxy for the secrets only: it allows the item no host
|
|
223
|
+
const proxyNetwork = network.type === 'allow' ? network : { type: 'allow', hosts: [] };
|
|
210
224
|
let started;
|
|
211
225
|
try {
|
|
212
226
|
if (proxy.sandbox) {
|
|
@@ -214,7 +228,7 @@ async function ensureKubernetesItemNetwork(instance, env, item, options) {
|
|
|
214
228
|
await removeSandboxProxies(instance, env.namespace, { sandbox: proxy.sandbox.label, item }, (deployment) => deployment.metadata?.labels?.[PROXY_LABEL] === proxy.key);
|
|
215
229
|
proxy.owner = await getFilesPodOwner(instance, env, proxy.sandbox);
|
|
216
230
|
}
|
|
217
|
-
started = await ensureProxy(instance, env, item,
|
|
231
|
+
started = await ensureProxy(instance, env, item, proxyNetwork, hostAliases, options, proxy, secretHosts);
|
|
218
232
|
}
|
|
219
233
|
catch (e) {
|
|
220
234
|
await removeKubernetesItemNetwork(instance, env, item, options).catch(() => undefined);
|
|
@@ -223,11 +237,17 @@ async function ensureKubernetesItemNetwork(instance, env, item, options) {
|
|
|
223
237
|
const follow = options.followLog
|
|
224
238
|
? await followProxyLog(instance, env, item, proxy, { tailLines: started.reused ? 0 : undefined })
|
|
225
239
|
: null;
|
|
240
|
+
const needs = item.needs.map((need) => need.name);
|
|
241
|
+
const toEnv = (variables) => Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
226
242
|
return {
|
|
227
|
-
env:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
243
|
+
env: network.type === 'allow' ? toEnv((0, egress_proxy_1.getProxyClientEnvironment)(needs)) : [],
|
|
244
|
+
// the token from the proxy's Secret, which the item's own container never mounts
|
|
245
|
+
secretEnv: secretHosts.length > 0
|
|
246
|
+
? [
|
|
247
|
+
{ name: SECRET_TOKEN_VARIABLE, valueFrom: { secretKeyRef: { name: proxy.name, key: SECRET_TOKEN_KEY } } },
|
|
248
|
+
...toEnv((0, egress_proxy_1.getProxyClientEnvironment)(needs, `$(${SECRET_TOKEN_VARIABLE})`)),
|
|
249
|
+
]
|
|
250
|
+
: [],
|
|
231
251
|
// the item reaches each TCP forward under its host's name, at the proxy
|
|
232
252
|
hostAliases: [
|
|
233
253
|
...hostAliases,
|
|
@@ -318,6 +338,7 @@ async function removeAllKubernetesItemNetworks(instance, env, item) {
|
|
|
318
338
|
if (name) {
|
|
319
339
|
await ignoreStatus(instance.appsApi.deleteNamespacedDeployment({ name, namespace: env.namespace }), [404]);
|
|
320
340
|
await ignoreStatus(instance.coreApi.deleteNamespacedService({ name, namespace: env.namespace }), [404]);
|
|
341
|
+
await ignoreStatus(instance.coreApi.deleteNamespacedSecret({ name, namespace: env.namespace }), [404]);
|
|
321
342
|
}
|
|
322
343
|
}
|
|
323
344
|
}
|
|
@@ -349,7 +370,7 @@ function getProxyReadinessScript(ports) {
|
|
|
349
370
|
`.on('error', () => process.exit(1))`);
|
|
350
371
|
}
|
|
351
372
|
exports.getProxyReadinessScript = getProxyReadinessScript;
|
|
352
|
-
async function ensureProxy(instance, env, item, network, hostAliases, scope, proxy) {
|
|
373
|
+
async function ensureProxy(instance, env, item, network, hostAliases, scope, proxy, secretHosts = []) {
|
|
353
374
|
const name = proxy.name;
|
|
354
375
|
const labels = { [PROXY_LABEL]: proxy.key };
|
|
355
376
|
const metadata = {
|
|
@@ -388,7 +409,17 @@ async function ensureProxy(instance, env, item, network, hostAliases, scope, pro
|
|
|
388
409
|
name: 'proxy',
|
|
389
410
|
image: egress_proxy_1.EGRESS_PROXY_IMAGE,
|
|
390
411
|
command: (0, egress_proxy_1.getEgressProxyCommand)(),
|
|
391
|
-
env:
|
|
412
|
+
env: [
|
|
413
|
+
...Object.entries({ ...(0, egress_proxy_1.getItemProxyEnvironment)(item), ...(0, egress_proxy_1.getSecretProxyEnvironment)(secretHosts) }).map(([key, value]) => ({ name: key, value })),
|
|
414
|
+
...(secretHosts.length > 0
|
|
415
|
+
? [
|
|
416
|
+
{
|
|
417
|
+
name: 'HAMMERKIT_SECRET_TOKEN',
|
|
418
|
+
valueFrom: { secretKeyRef: { name, key: SECRET_TOKEN_KEY } },
|
|
419
|
+
},
|
|
420
|
+
]
|
|
421
|
+
: []),
|
|
422
|
+
],
|
|
392
423
|
ports: (0, egress_proxy_1.getEgressProxyPorts)(network).map((port) => ({ containerPort: port })),
|
|
393
424
|
// in the pod, since its ingress policy may keep the kubelet out;
|
|
394
425
|
// ready once it listens on every port, its TCP forwards' too
|
|
@@ -422,6 +453,21 @@ async function ensureProxy(instance, env, item, network, hostAliases, scope, pro
|
|
|
422
453
|
? await instance.objectApi.read(deployment).then((current) => !current.metadata?.deletionTimestamp, (e) => ((0, apply_1.statusCodeOf)(e) === 404 ? false : Promise.reject(e)))
|
|
423
454
|
: false;
|
|
424
455
|
await (0, apply_1.apply)(instance, buildProxyPolicy(env, item, network, scope, proxy));
|
|
456
|
+
if (secretHosts.length > 0) {
|
|
457
|
+
// new with each run's proxy, and kept while it is there: its pod read it when it started.
|
|
458
|
+
// The init containers that read secrets get it, the task not.
|
|
459
|
+
const secret = {
|
|
460
|
+
apiVersion: 'v1',
|
|
461
|
+
kind: 'Secret',
|
|
462
|
+
metadata: { ...metadata, annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() } },
|
|
463
|
+
type: 'Opaque',
|
|
464
|
+
data: { [SECRET_TOKEN_KEY]: Buffer.from((0, crypto_1.randomBytes)(32).toString('hex')).toString('base64') },
|
|
465
|
+
};
|
|
466
|
+
const exists = await instance.objectApi.read(secret).then(() => true, (e) => ((0, apply_1.statusCodeOf)(e) === 404 ? false : Promise.reject(e)));
|
|
467
|
+
if (!exists) {
|
|
468
|
+
await instance.objectApi.create(secret);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
425
471
|
await (0, apply_1.apply)(instance, deployment);
|
|
426
472
|
const created = await (0, apply_1.apply)(instance, service);
|
|
427
473
|
await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, {
|
|
@@ -433,7 +479,7 @@ async function ensureProxy(instance, env, item, network, hostAliases, scope, pro
|
|
|
433
479
|
if (!ip) {
|
|
434
480
|
throw new Error(`the egress proxy of ${item.name} has no cluster ip`);
|
|
435
481
|
}
|
|
436
|
-
item.status.write('debug', `${reused ? 'egress proxy of the sandbox' : 'egress proxy'} allows ${network.hosts.join(', ') || 'no host'}`);
|
|
482
|
+
item.status.write('debug', `${reused ? 'egress proxy of the sandbox' : 'egress proxy'} allows ${network.hosts.join(', ') || 'no host'}${secretHosts.length > 0 ? `, and ${secretHosts.join(', ')} to the reads of secrets` : ''}`);
|
|
437
483
|
return { ip, reused };
|
|
438
484
|
}
|
|
439
485
|
// Refused hosts show up in the item's log, as on Docker; of a proxy from an
|
|
@@ -468,9 +514,13 @@ async function followProxyLog(instance, env, item, proxy, options) {
|
|
|
468
514
|
return null;
|
|
469
515
|
}
|
|
470
516
|
}
|
|
471
|
-
|
|
517
|
+
// The token of a proxy for secrets goes with it; a proxy of a sandbox has none.
|
|
518
|
+
async function removeProxy(instance, namespace, name, ignored, options = { token: true }) {
|
|
472
519
|
await ignoreStatus(instance.appsApi.deleteNamespacedDeployment({ name, namespace }), ignored);
|
|
473
520
|
await ignoreStatus(instance.coreApi.deleteNamespacedService({ name, namespace }), ignored);
|
|
521
|
+
if (options.token) {
|
|
522
|
+
await ignoreStatus(instance.coreApi.deleteNamespacedSecret({ name, namespace }), ignored);
|
|
523
|
+
}
|
|
474
524
|
await ignoreStatus(instance.objectApi.delete({
|
|
475
525
|
apiVersion: 'networking.k8s.io/v1',
|
|
476
526
|
kind: 'NetworkPolicy',
|
|
@@ -492,7 +542,7 @@ async function removeSandboxProxies(instance, namespace, of, keep = () => false)
|
|
|
492
542
|
for (const deployment of deployments.items) {
|
|
493
543
|
const name = deployment.metadata?.name;
|
|
494
544
|
if (name && !keep(deployment)) {
|
|
495
|
-
await removeProxy(instance, namespace, name, [404]);
|
|
545
|
+
await removeProxy(instance, namespace, name, [404], { token: false });
|
|
496
546
|
}
|
|
497
547
|
}
|
|
498
548
|
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.restoreKubernetesData = void 0;
|
|
3
|
+
exports.restoreKubernetesData = exports.getRestoreTarget = void 0;
|
|
4
4
|
const event_cache_1 = require("../executer/event-cache");
|
|
5
5
|
const log_1 = require("../log");
|
|
6
|
-
const path_1 = require("path");
|
|
7
6
|
const volumes_1 = require("./volumes");
|
|
8
7
|
const ensure_persistent_data_1 = require("./ensure-persistent-data");
|
|
8
|
+
const claim_layout_1 = require("./claim-layout");
|
|
9
|
+
// Where the upload pod (which mounts the work's claim at /dev/hammerkit/<id>) unpacks the
|
|
10
|
+
// archive of an output: the output's place in the claim, which its mount reads
|
|
11
|
+
function getRestoreTarget(work, path) {
|
|
12
|
+
return `/dev/hammerkit/${work.id()}/${(0, claim_layout_1.getClaimSubPath)(work, path)}`;
|
|
13
|
+
}
|
|
14
|
+
exports.getRestoreTarget = getRestoreTarget;
|
|
9
15
|
async function restoreKubernetesData(work, kubernetes, instance, environment, path,
|
|
10
16
|
// the kept claim of the work holds what an earlier run left, which goes first
|
|
11
17
|
options = {}) {
|
|
@@ -18,7 +24,7 @@ options = {}) {
|
|
|
18
24
|
if (!(await environment.file.exists(generatedArchive.filename))) {
|
|
19
25
|
continue;
|
|
20
26
|
}
|
|
21
|
-
const targetPath =
|
|
27
|
+
const targetPath = getRestoreTarget(work, generatedArchive.path);
|
|
22
28
|
// a claim made for this run (a server removes the claims of a run when it
|
|
23
29
|
// ends) does not have the directory of the output yet
|
|
24
30
|
let failure = null;
|
|
@@ -4,6 +4,7 @@ exports.getSandboxAffinity = exports.SANDBOX_LABEL = exports.getWorkspacePodSpec
|
|
|
4
4
|
const path_1 = require("path");
|
|
5
5
|
const fs_1 = require("fs");
|
|
6
6
|
const kept_claims_1 = require("./kept-claims");
|
|
7
|
+
const claim_layout_1 = require("./claim-layout");
|
|
7
8
|
function getVolumeName(service, kubernetes) {
|
|
8
9
|
return (0, kept_claims_1.getClaimName)(service, kubernetes);
|
|
9
10
|
}
|
|
@@ -83,6 +84,7 @@ async function getKubernetesPersistence(work, kubernetes) {
|
|
|
83
84
|
exports.getKubernetesPersistence = getKubernetesPersistence;
|
|
84
85
|
function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes) {
|
|
85
86
|
const volumeName = getVolumeName(sourceWork, kubernetes);
|
|
87
|
+
const subPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath);
|
|
86
88
|
const existVolume = persistence.volumes.some((v) => v.name === volumeName);
|
|
87
89
|
if (!existVolume) {
|
|
88
90
|
persistence.volumes.push({
|
|
@@ -101,7 +103,7 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
|
|
|
101
103
|
readOnly,
|
|
102
104
|
mountPath: containerPath,
|
|
103
105
|
name: volumeName,
|
|
104
|
-
subPath
|
|
106
|
+
subPath,
|
|
105
107
|
},
|
|
106
108
|
uploadPath: `/dev/hammerkit/${sourceWork.id()}`,
|
|
107
109
|
cwd: isFile ? (0, path_1.dirname)(containerPath) : containerPath,
|
|
@@ -111,6 +113,7 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
|
|
|
111
113
|
localPath: localPath,
|
|
112
114
|
matcher: () => true, // TODO
|
|
113
115
|
containerPath,
|
|
116
|
+
subPath,
|
|
114
117
|
stateKey: '', // TODO
|
|
115
118
|
});
|
|
116
119
|
}
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
// `CONNECT` tunnels to an allowed host pass, everything else gets a 403 and a
|
|
6
6
|
// `refused` event on stdout. For each `host:port` entry it also listens on that
|
|
7
7
|
// port and forwards raw TCP to that host, which the item reaches under the
|
|
8
|
-
// host's name.
|
|
8
|
+
// host's name. On a server's cluster it also lets the init containers that read
|
|
9
|
+
// the task's secrets reach the endpoints of their providers: a tunnel to port 443
|
|
10
|
+
// that carries the proxy's token, which those containers get and the task not. The script is passed to `node -e`, so the proxy
|
|
9
11
|
// needs no image of its own, only a stock Node image.
|
|
10
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.formatEgressProxyEvent = exports.formatRefusedHost = exports.parseEgressProxyEvent = exports.getProxylessEnvironment = exports.getProxyClientEnvironment = exports.getEgressProxyPorts = exports.getItemProxyEnvironment = exports.getEgressProxyEnvironment = exports.getEgressProxyCommand = exports.EGRESS_PROXY_SCRIPT = exports.EGRESS_PROXY_HOST = exports.EGRESS_PROXY_PORT = exports.EGRESS_PROXY_IMAGE = void 0;
|
|
13
|
+
exports.formatEgressProxyEvent = exports.formatRefusedHost = exports.parseEgressProxyEvent = exports.getProxylessEnvironment = exports.getProxyClientEnvironment = exports.getEgressProxyPorts = exports.getItemProxyEnvironment = exports.getSecretProxyEnvironment = exports.getEgressProxyEnvironment = exports.getEgressProxyCommand = exports.EGRESS_PROXY_SCRIPT = exports.EGRESS_PROXY_HOST = exports.EGRESS_PROXY_PORT = exports.EGRESS_PROXY_IMAGE = void 0;
|
|
12
14
|
const work_network_1 = require("../planner/work-network");
|
|
13
15
|
const network_schema_1 = require("../schema/network-schema");
|
|
14
16
|
// Pinned, so the code behind the boundary is the same on every host. To bump
|
|
@@ -25,6 +27,7 @@ exports.EGRESS_PROXY_SCRIPT = `
|
|
|
25
27
|
const http = require('http')
|
|
26
28
|
const net = require('net')
|
|
27
29
|
const dns = require('dns')
|
|
30
|
+
const crypto = require('crypto')
|
|
28
31
|
const entries = JSON.parse(process.env.HAMMERKIT_ALLOW || '[]').map((h) => h.toLowerCase())
|
|
29
32
|
// names and patterns for HTTP(S)
|
|
30
33
|
const allow = entries.filter((e) => !e.includes(':'))
|
|
@@ -38,6 +41,9 @@ const publicOnly = JSON.parse(process.env.HAMMERKIT_PUBLIC_ONLY || '[]').map((h)
|
|
|
38
41
|
// the names of the needed services, for tools that ignore NO_PROXY: exact
|
|
39
42
|
// names only, never patterns
|
|
40
43
|
const needs = JSON.parse(process.env.HAMMERKIT_NEEDS || '[]').map((h) => h.toLowerCase())
|
|
44
|
+
// the endpoints of secret providers, for a request with the token only
|
|
45
|
+
const secretAllow = JSON.parse(process.env.HAMMERKIT_SECRET_ALLOW || '[]').map((h) => h.toLowerCase())
|
|
46
|
+
const secretToken = process.env.HAMMERKIT_SECRET_TOKEN || ''
|
|
41
47
|
const port = parseInt(process.env.HAMMERKIT_PROXY_PORT || '${exports.EGRESS_PROXY_PORT}', 10)
|
|
42
48
|
// the name whose address the proxy listens on, every address when unset
|
|
43
49
|
const bind = process.env.HAMMERKIT_PROXY_BIND
|
|
@@ -64,6 +70,31 @@ function allowedBy(host, port) {
|
|
|
64
70
|
}
|
|
65
71
|
return allow.some((rule) => rule.startsWith('*.') && name.endsWith(rule.substring(1))) ? 'wildcard' : null
|
|
66
72
|
}
|
|
73
|
+
// Whether the request carries the token as the password of Proxy-Authorization (Basic).
|
|
74
|
+
function hasSecretToken(req) {
|
|
75
|
+
if (secretToken.length === 0) {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
const match = /^basic\\s+(\\S+)$/i.exec(String(req.headers['proxy-authorization'] || ''))
|
|
79
|
+
if (!match) {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
const credentials = Buffer.from(match[1], 'base64').toString('utf8')
|
|
83
|
+
const colon = credentials.indexOf(':')
|
|
84
|
+
if (colon < 0) {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
const given = Buffer.from(credentials.substring(colon + 1), 'utf8')
|
|
88
|
+
const expected = Buffer.from(secretToken, 'utf8')
|
|
89
|
+
return given.length === expected.length && crypto.timingSafeEqual(given, expected)
|
|
90
|
+
}
|
|
91
|
+
// the rule of a tunnel to an endpoint of a secret provider: like a wildcard, it reaches
|
|
92
|
+
// no private address
|
|
93
|
+
function allowedForSecrets(req, host, port) {
|
|
94
|
+
const name = host.toLowerCase().replace(/\\.$/, '')
|
|
95
|
+
const listed = secretAllow.some((rule) => (rule.startsWith('*.') ? name.endsWith(rule.substring(1)) : name === rule))
|
|
96
|
+
return listed && port === 443 && hasSecretToken(req) ? 'wildcard' : null
|
|
97
|
+
}
|
|
67
98
|
function ipv4(address) {
|
|
68
99
|
const mapped = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/i.exec(address)
|
|
69
100
|
const value = mapped ? mapped[1] : address
|
|
@@ -197,7 +228,7 @@ const server = http.createServer((req, res) => {
|
|
|
197
228
|
server.on('connect', (req, socket, head) => {
|
|
198
229
|
const target = parseAuthority(req.url || '')
|
|
199
230
|
socket.on('error', () => socket.destroy())
|
|
200
|
-
const rule = target ? allowedBy(target.host, target.port) : null
|
|
231
|
+
const rule = target ? allowedBy(target.host, target.port) || allowedForSecrets(req, target.host, target.port) : null
|
|
201
232
|
if (!rule) {
|
|
202
233
|
refuse(target ? target.host : String(req.url))
|
|
203
234
|
socket.end('HTTP/1.1 403 Forbidden\\r\\ncontent-type: text/plain\\r\\n\\r\\nhammerkit: network access is not allowed\\n')
|
|
@@ -290,7 +321,8 @@ exports.getEgressProxyCommand = getEgressProxyCommand;
|
|
|
290
321
|
// `needs` are the names of the needed services, allowed as exactly those
|
|
291
322
|
// names. `bind` is the name the proxy listens on alone, so that nothing on its
|
|
292
323
|
// other networks reaches it. `publicOnly` are the entries of `hosts` that reach
|
|
293
|
-
// no private address (see WorkNetwork).
|
|
324
|
+
// no private address (see WorkNetwork). The endpoints of secret providers and
|
|
325
|
+
// the token they need are set by the caller (getSecretProxyEnvironment).
|
|
294
326
|
function getEgressProxyEnvironment(hosts, needs, bind, publicOnly = []) {
|
|
295
327
|
return {
|
|
296
328
|
HAMMERKIT_ALLOW: JSON.stringify(hosts),
|
|
@@ -301,6 +333,12 @@ function getEgressProxyEnvironment(hosts, needs, bind, publicOnly = []) {
|
|
|
301
333
|
};
|
|
302
334
|
}
|
|
303
335
|
exports.getEgressProxyEnvironment = getEgressProxyEnvironment;
|
|
336
|
+
// The endpoints of secret providers the proxy lets a tunnel with its token reach; the
|
|
337
|
+
// token itself is set where the proxy runs, see HAMMERKIT_SECRET_TOKEN.
|
|
338
|
+
function getSecretProxyEnvironment(hosts) {
|
|
339
|
+
return hosts.length > 0 ? { HAMMERKIT_SECRET_ALLOW: JSON.stringify(hosts) } : {};
|
|
340
|
+
}
|
|
341
|
+
exports.getSecretProxyEnvironment = getSecretProxyEnvironment;
|
|
304
342
|
// The proxy of a task or service: the hosts of its network in this run (under
|
|
305
343
|
// the run's ceiling), and the names of the services it needs, on every runtime.
|
|
306
344
|
function getItemProxyEnvironment(item, bind) {
|
|
@@ -315,8 +353,9 @@ function getEgressProxyPorts(network) {
|
|
|
315
353
|
return [exports.EGRESS_PROXY_PORT, ...(0, work_network_1.getTcpForwards)(network).map((forward) => forward.port)];
|
|
316
354
|
}
|
|
317
355
|
exports.getEgressProxyPorts = getEgressProxyPorts;
|
|
318
|
-
|
|
319
|
-
|
|
356
|
+
// `token` is sent as the password, for the endpoints of secret providers.
|
|
357
|
+
function getProxyClientEnvironment(directHosts, token) {
|
|
358
|
+
const proxy = `http://${token !== undefined ? `hammerkit:${token}@` : ''}${exports.EGRESS_PROXY_HOST}:${exports.EGRESS_PROXY_PORT}`;
|
|
320
359
|
const noProxy = ['localhost', '127.0.0.1', ...directHosts].join(',');
|
|
321
360
|
return {
|
|
322
361
|
HTTP_PROXY: proxy,
|
|
@@ -6,6 +6,7 @@ const crypto_1 = require("crypto");
|
|
|
6
6
|
const replace_env_variables_1 = require("../environment/replace-env-variables");
|
|
7
7
|
const portable_path_1 = require("../planner/utils/portable-path");
|
|
8
8
|
const work_secret_1 = require("../planner/work-secret");
|
|
9
|
+
const claim_layout_1 = require("../kubernetes/claim-layout");
|
|
9
10
|
// Every path is made project-relative (portablePath) so the description, and the
|
|
10
11
|
// task id hashed from it, is identical wherever the project is checked out.
|
|
11
12
|
//
|
|
@@ -40,6 +41,7 @@ function getWorkTaskCacheDescription(item) {
|
|
|
40
41
|
cwd: portable(task.cwd),
|
|
41
42
|
deps: item.deps.length > 0 ? item.deps.map((dep) => dep.id()).sort() : undefined,
|
|
42
43
|
secrets: cachedSecrets.length > 0 ? cachedSecrets : undefined,
|
|
44
|
+
claimLayout: (0, claim_layout_1.hasPathsEndingAlike)(item) || (0, claim_layout_1.hasOutputsFlatteningAlike)(item) ? 2 : undefined,
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
exports.getWorkTaskCacheDescription = getWorkTaskCacheDescription;
|
|
@@ -19,7 +19,10 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
|
|
|
19
19
|
}
|
|
20
20
|
if (((0, work_task_1.isContainerWorkTask)(depNode.data) || (0, work_service_1.isContainerWorkService)(depNode.data)) &&
|
|
21
21
|
((0, work_task_1.isContainerWorkTask)(item.data) || (0, work_service_1.isContainerWorkService)(item.data))) {
|
|
22
|
+
// the outputs of the dep's own deps too, which stay those of the task that made them
|
|
23
|
+
// (on Kubernetes, they are in its claim)
|
|
22
24
|
for (const generate of depNode.data.generates) {
|
|
25
|
+
const inherited = generate.inherited ?? depNode;
|
|
23
26
|
if (generate.isFile) {
|
|
24
27
|
// TODO check if thats correct
|
|
25
28
|
continue;
|
|
@@ -31,7 +34,7 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
|
|
|
31
34
|
export: false,
|
|
32
35
|
resetOnChange: false,
|
|
33
36
|
containerPath: generate.path,
|
|
34
|
-
inherited
|
|
37
|
+
inherited,
|
|
35
38
|
readOnly: false,
|
|
36
39
|
});
|
|
37
40
|
}
|
|
@@ -44,7 +47,7 @@ function appendWorkDependencies(workTree, referenced, item, environment, context
|
|
|
44
47
|
exportAlways: false,
|
|
45
48
|
resetOnChange: false,
|
|
46
49
|
path: generate.path,
|
|
47
|
-
inherited
|
|
50
|
+
inherited,
|
|
48
51
|
isFile: false,
|
|
49
52
|
});
|
|
50
53
|
}
|
|
@@ -120,6 +120,7 @@ function parseService(workTree, service, environment, context) {
|
|
|
120
120
|
volumeSize: service.schema.volumeSize ?? null,
|
|
121
121
|
network: (0, work_network_1.parseWorkNetwork)(service.schema.network),
|
|
122
122
|
networkCeiling: null,
|
|
123
|
+
args: service.schema.args ?? null,
|
|
123
124
|
};
|
|
124
125
|
}
|
|
125
126
|
}
|
|
@@ -83,6 +83,7 @@ function parseTask(cwd, task, environment, context) {
|
|
|
83
83
|
resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
|
|
84
84
|
network: (0, work_network_1.parseWorkNetwork)(task.schema.network),
|
|
85
85
|
networkCeiling: null,
|
|
86
|
+
args: task.schema.args ?? null,
|
|
86
87
|
runner: task.schema.runner ?? task.scope.schema.runner ?? null,
|
|
87
88
|
};
|
|
88
89
|
if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
|
|
@@ -100,7 +101,9 @@ function parseTask(cwd, task, environment, context) {
|
|
|
100
101
|
if (fileSecret) {
|
|
101
102
|
throw new Error(`task ${task.relativeName}: a local task takes secrets as env only (${(0, work_secret_1.getSecretName)(fileSecret)})`);
|
|
102
103
|
}
|
|
103
|
-
|
|
104
|
+
// on a server's cluster it runs in a pod of a pool's tool image (runInToolImage)
|
|
105
|
+
const contained = !!environment.hosted?.kubernetes || !!environment.hosted?.localTasksInContainers;
|
|
106
|
+
if (baseWorkNode.network.type !== 'open' && !contained) {
|
|
104
107
|
throw new Error(`task ${task.relativeName}: network ${(0, work_network_1.formatNetwork)(baseWorkNode.network)} needs a container, ` +
|
|
105
108
|
`hammerkit can't limit the network of a local task`);
|
|
106
109
|
}
|
|
@@ -101,6 +101,9 @@ function applyArguments(selection, args, keepCache = false) {
|
|
|
101
101
|
if (item.type === 'kubernetes-service') {
|
|
102
102
|
throw new Error(`service ${item.name} is a kubernetes service and has no cmd for the arguments after --`);
|
|
103
103
|
}
|
|
104
|
+
if (item.args === false) {
|
|
105
|
+
throw new Error(`${item.type === 'container-service' ? 'service' : 'task'} ${item.name} takes no arguments after -- (args: false)`);
|
|
106
|
+
}
|
|
104
107
|
const target = getArgumentsTarget(item);
|
|
105
108
|
if (!target) {
|
|
106
109
|
throw new Error(item.type === 'container-service'
|
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.kubernetesForwardRuntime = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = exports.describeFailedJob = void 0;
|
|
6
|
+
exports.kubernetesForwardRuntime = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = exports.getTaskJobContainers = exports.describeFailedJob = void 0;
|
|
7
7
|
const abort_1 = require("../executer/abort");
|
|
8
8
|
const kubernetes_service_1 = require("../executer/kubernetes-service");
|
|
9
9
|
const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
|
|
@@ -125,6 +125,41 @@ async function describeFailedJob(instance, kubernetes, task, job, container, sec
|
|
|
125
125
|
return undefined;
|
|
126
126
|
}
|
|
127
127
|
exports.describeFailedJob = describeFailedJob;
|
|
128
|
+
/**
|
|
129
|
+
* The containers of the Job of a command: the network gate first, nothing runs before the
|
|
130
|
+
* item's policy holds, then the init containers that read its secrets, then the command. The
|
|
131
|
+
* proxy's token (`network.secretEnv`) reaches the init containers through `secretSpec` only:
|
|
132
|
+
* the command's container gets the proxy variables of a limited network without it.
|
|
133
|
+
*/
|
|
134
|
+
function getTaskJobContainers(options) {
|
|
135
|
+
const { task, cmd, envs, secrets, network, secretSpec, kubernetes } = options;
|
|
136
|
+
// the arguments after `--` keep their `$` as text, Kubernetes expands `$(NAME)`
|
|
137
|
+
const command = secretSpec.command(task.data.shell, (0, task_arguments_1.getKubernetesCommandText)(cmd));
|
|
138
|
+
const initContainers = [...network.initContainers, ...secretSpec.initContainers];
|
|
139
|
+
return {
|
|
140
|
+
...(initContainers.length > 0 ? { initContainers } : {}),
|
|
141
|
+
containers: [
|
|
142
|
+
{
|
|
143
|
+
image: task.data.image,
|
|
144
|
+
// through the task's shell, as on Docker
|
|
145
|
+
command: command.command,
|
|
146
|
+
args: command.args,
|
|
147
|
+
workingDir: cmd.cwd,
|
|
148
|
+
env: [
|
|
149
|
+
...Object.entries(envs).map(([key, value]) => ({ name: key, value })),
|
|
150
|
+
...secrets.env,
|
|
151
|
+
...network.env,
|
|
152
|
+
...command.env,
|
|
153
|
+
],
|
|
154
|
+
name: options.container,
|
|
155
|
+
volumeMounts: [...options.mounts, ...secrets.mounts, ...secretSpec.mounts],
|
|
156
|
+
...(0, container_resources_1.getKubernetesResources)(task.data.resources),
|
|
157
|
+
...(0, job_placement_1.getContainerSecurity)(kubernetes),
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
exports.getTaskJobContainers = getTaskJobContainers;
|
|
128
163
|
function kubernetesTaskRuntime(task, kubernetes) {
|
|
129
164
|
const instance = (0, kubernetes_instance_1.createKubernetesInstances)(kubernetes);
|
|
130
165
|
// on a server that keeps them, the task's claim stays after the run with the state of its
|
|
@@ -168,8 +203,9 @@ function kubernetesTaskRuntime(task, kubernetes) {
|
|
|
168
203
|
const { delivered, inJob } = (0, job_secrets_1.splitJobSecrets)(task.data.secrets, server);
|
|
169
204
|
const secrets = (0, secrets_1.getKubernetesSecretRefs)(task, delivered);
|
|
170
205
|
const admitted = environment.hosted?.accounts;
|
|
206
|
+
let secretHosts;
|
|
171
207
|
try {
|
|
172
|
-
(0, job_secrets_1.
|
|
208
|
+
secretHosts = (0, job_secrets_1.getSecretHosts)(task, inJob, server);
|
|
173
209
|
if (server) {
|
|
174
210
|
(0, job_secrets_1.assertJobAccounts)(inJob, server, admitted);
|
|
175
211
|
}
|
|
@@ -210,7 +246,11 @@ function kubernetesTaskRuntime(task, kubernetes) {
|
|
|
210
246
|
// NetworkPolicy and proxy of a limited network, both of this run
|
|
211
247
|
let network;
|
|
212
248
|
try {
|
|
213
|
-
network = await (0, network_policy_1.ensureKubernetesItemNetwork)(instance, kubernetes, task, {
|
|
249
|
+
network = await (0, network_policy_1.ensureKubernetesItemNetwork)(instance, kubernetes, task, {
|
|
250
|
+
followLog: true,
|
|
251
|
+
secretHosts,
|
|
252
|
+
...scope,
|
|
253
|
+
});
|
|
214
254
|
}
|
|
215
255
|
catch (e) {
|
|
216
256
|
await removeTaskSecrets(instance, kubernetes, task).catch(() => undefined);
|
|
@@ -226,15 +266,13 @@ function kubernetesTaskRuntime(task, kubernetes) {
|
|
|
226
266
|
try {
|
|
227
267
|
// made for each Job right before it, so a later command's token is as fresh
|
|
228
268
|
const credentials = await (0, job_secrets_1.ensureJobCredentials)(instance, kubernetes, task, inJob, admitted);
|
|
229
|
-
secretSpec = (0, job_secrets_1.getJobSecretSpec)(task, inJob, server, credentials, environment);
|
|
269
|
+
secretSpec = (0, job_secrets_1.getJobSecretSpec)(task, inJob, server, credentials, environment, network.secretEnv);
|
|
230
270
|
}
|
|
231
271
|
catch (e) {
|
|
232
272
|
await removeTaskSecrets(instance, kubernetes, task).catch(() => undefined);
|
|
233
273
|
options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
|
|
234
274
|
return;
|
|
235
275
|
}
|
|
236
|
-
// the arguments after `--` keep their `$` as text, Kubernetes expands `$(NAME)`
|
|
237
|
-
const command = secretSpec.command(task.data.shell, (0, task_arguments_1.getKubernetesCommandText)(cmd));
|
|
238
276
|
const spec = {
|
|
239
277
|
kind: 'Job',
|
|
240
278
|
apiVersion: 'batch/v1',
|
|
@@ -255,33 +293,17 @@ function kubernetesTaskRuntime(task, kubernetes) {
|
|
|
255
293
|
labels,
|
|
256
294
|
},
|
|
257
295
|
spec: {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
env: [
|
|
270
|
-
...Object.entries(envs).map(([key, value]) => ({ name: key, value })),
|
|
271
|
-
...secrets.env,
|
|
272
|
-
...network.env,
|
|
273
|
-
...command.env,
|
|
274
|
-
],
|
|
275
|
-
name: container,
|
|
276
|
-
volumeMounts: [
|
|
277
|
-
...persistence.mounts.map((m) => m.mount),
|
|
278
|
-
...secrets.mounts,
|
|
279
|
-
...secretSpec.mounts,
|
|
280
|
-
],
|
|
281
|
-
...(0, container_resources_1.getKubernetesResources)(task.data.resources),
|
|
282
|
-
...(0, job_placement_1.getContainerSecurity)(kubernetes),
|
|
283
|
-
},
|
|
284
|
-
],
|
|
296
|
+
...getTaskJobContainers({
|
|
297
|
+
task,
|
|
298
|
+
cmd,
|
|
299
|
+
container,
|
|
300
|
+
envs,
|
|
301
|
+
secrets,
|
|
302
|
+
network,
|
|
303
|
+
secretSpec,
|
|
304
|
+
mounts: persistence.mounts.map((m) => m.mount),
|
|
305
|
+
kubernetes,
|
|
306
|
+
}),
|
|
285
307
|
volumes: [
|
|
286
308
|
...persistence.volumes,
|
|
287
309
|
...persistence.workspace,
|
package/dist/program.js
CHANGED
|
@@ -84,7 +84,8 @@ function printTaskDefinition(environment, definition) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
// `ls --json`: what a caller (an agent picking a task) needs to know, with the
|
|
87
|
-
// command that arguments after `--` are appended to (null: it takes none)
|
|
87
|
+
// command that arguments after `--` are appended to (null: it takes none) and
|
|
88
|
+
// `args` as the build file says it (false: it refuses them).
|
|
88
89
|
function getListJson(tasks, services) {
|
|
89
90
|
return {
|
|
90
91
|
tasks: tasks.map(({ item }) => ({
|
|
@@ -96,6 +97,7 @@ function getListJson(tasks, services) {
|
|
|
96
97
|
needs: item.needs.map((n) => n.name),
|
|
97
98
|
labels: item.data.labels,
|
|
98
99
|
command: (0, task_arguments_1.getArgumentsTarget)(item.data)?.cmd ?? null,
|
|
100
|
+
args: item.data.args,
|
|
99
101
|
})),
|
|
100
102
|
services: services.map(({ item }) => ({
|
|
101
103
|
name: item.name,
|
|
@@ -105,6 +107,7 @@ function getListJson(tasks, services) {
|
|
|
105
107
|
ports: item.data.ports.map((p) => ({ host: p.hostPort, container: p.containerPort })),
|
|
106
108
|
labels: item.data.labels,
|
|
107
109
|
command: (0, task_arguments_1.getArgumentsTarget)(item.data)?.cmd ?? null,
|
|
110
|
+
args: item.data.type === 'container-service' ? item.data.args : null,
|
|
108
111
|
})),
|
|
109
112
|
};
|
|
110
113
|
}
|
|
@@ -40,8 +40,12 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
|
|
|
40
40
|
account: (0, zod_1.string)().optional(),
|
|
41
41
|
// CPU and memory limits of the container; a local task is not limited
|
|
42
42
|
resources: resources_schema_1.resourcesSchema.optional(),
|
|
43
|
-
// what the container reaches besides its needs; a local task can
|
|
43
|
+
// what the container reaches besides its needs; a local task can be limited only where
|
|
44
|
+
// it runs in a container, on a server's cluster
|
|
44
45
|
network: network_schema_1.networkSchema.optional(),
|
|
46
|
+
// whether its last command takes the arguments after `--`: false refuses them, true
|
|
47
|
+
// says it is meant to, which a server with `runs.args: declared` requires
|
|
48
|
+
args: (0, zod_1.boolean)().optional(),
|
|
45
49
|
// Name of the server (see `hammerkit remote`) the task has to run on; defaults
|
|
46
50
|
// to the build file's `runner`. Never a local fallback when it is unknown.
|
|
47
51
|
runner: runner_schema_1.runnerSchema.optional(),
|
|
@@ -45,6 +45,8 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
|
|
|
45
45
|
volumeSize: size_schema_1.sizeSchema.describe('size of its claim on Kubernetes, e.g. 10Gi').optional(),
|
|
46
46
|
// what the service reaches besides its needs; tasks needing it always reach it
|
|
47
47
|
network: network_schema_1.networkSchema.optional(),
|
|
48
|
+
// whether its `cmd` takes the arguments after `--` of `up`: false refuses them
|
|
49
|
+
args: (0, zod_1.boolean)().optional(),
|
|
48
50
|
})
|
|
49
51
|
.strict()
|
|
50
52
|
.describe('container service');
|
|
@@ -129,6 +129,7 @@ function applySchemaExtension(reference, task) {
|
|
|
129
129
|
task.schema.timeout = task.schema.timeout ?? extend.schema.timeout;
|
|
130
130
|
task.schema.runner = task.schema.runner ?? extend.schema.runner;
|
|
131
131
|
task.schema.network = task.schema.network ?? extend.schema.network;
|
|
132
|
+
task.schema.args = task.schema.args ?? extend.schema.args;
|
|
132
133
|
if (!reset.has('cmds'))
|
|
133
134
|
task.schema.cmds = extendArray(task.schema.cmds, extend.schema.cmds);
|
|
134
135
|
if (!reset.has('src'))
|
package/dist/server/executor.js
CHANGED
|
@@ -100,7 +100,7 @@ class HostExecutor {
|
|
|
100
100
|
if (extra.length > 0) {
|
|
101
101
|
throw new Error(`the run would use ${extra.join(', ')}, which it was not admitted for`);
|
|
102
102
|
}
|
|
103
|
-
const refused = (0, run_plan_1.refuseArguments)(run, planned.plan);
|
|
103
|
+
const refused = (0, run_plan_1.refuseArguments)(run, planned.plan, policy);
|
|
104
104
|
if (refused) {
|
|
105
105
|
throw new Error(refused);
|
|
106
106
|
}
|
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");
|
|
@@ -20,6 +20,20 @@ const work_network_1 = require("../planner/work-network");
|
|
|
20
20
|
class DefinitionError extends Error {
|
|
21
21
|
}
|
|
22
22
|
exports.DefinitionError = DefinitionError;
|
|
23
|
+
// Variables of a server that hold no credential (FR-008): a task may read these and
|
|
24
|
+
// still take arguments, or reach any network.
|
|
25
|
+
exports.PLAIN_SERVER_ENVS = ['PATH', 'HOME', 'CI'];
|
|
26
|
+
// What an item holds that its command must not hand out: its secrets, and the variables
|
|
27
|
+
// of the server its `envs` read, but the plain ones.
|
|
28
|
+
function getCredentialReads(item) {
|
|
29
|
+
return [
|
|
30
|
+
...item.secrets.map((secret) => `the secret ${secret.target}`),
|
|
31
|
+
...(item.serverEnvs ?? [])
|
|
32
|
+
.filter((env) => !exports.PLAIN_SERVER_ENVS.includes(env.toUpperCase()))
|
|
33
|
+
.map((env) => `$${env} of the server`),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
exports.getCredentialReads = getCredentialReads;
|
|
23
37
|
// 40 (sha-1) or 64 (sha-256) hex digits
|
|
24
38
|
const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
25
39
|
// A hash alone can only name what is inside the bundle, so everything a build
|
|
@@ -170,13 +184,32 @@ function findPolicyProblems(items, policy, root) {
|
|
|
170
184
|
problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
|
|
171
185
|
}
|
|
172
186
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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: '' });
|
|
196
|
+
}
|
|
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
|
+
});
|
|
203
|
+
}
|
|
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)})`);
|
|
177
209
|
}
|
|
178
210
|
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
|
|
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)})`);
|
|
180
213
|
}
|
|
181
214
|
}
|
|
182
215
|
if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
|
|
@@ -205,11 +238,13 @@ function findPolicyProblems(items, policy, root) {
|
|
|
205
238
|
problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
|
|
206
239
|
'the value before the Job of the task reads it, which this server does not support yet');
|
|
207
240
|
}
|
|
208
|
-
else if (item.data.network
|
|
209
|
-
// the init containers that read it share the pod's NetworkPolicy
|
|
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
|
|
210
244
|
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
|
-
|
|
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');
|
|
213
248
|
}
|
|
214
249
|
}
|
|
215
250
|
if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
|
|
@@ -254,6 +289,7 @@ function toPlan(root, item) {
|
|
|
254
289
|
cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
|
|
255
290
|
command: (0, task_arguments_1.getArgumentsTarget)(data)?.cmd ?? null,
|
|
256
291
|
network: (0, work_network_1.toNetworkSchema)('network' in data ? data.network : { type: 'open' }),
|
|
292
|
+
...('args' in data && data.args !== null ? { args: data.args } : {}),
|
|
257
293
|
deps: item.deps.map((dep) => dep.name).sort(),
|
|
258
294
|
needs: item.needs.map((need) => need.name).sort(),
|
|
259
295
|
accounts,
|
package/dist/server/run-plan.js
CHANGED
|
@@ -46,26 +46,16 @@ async function planRun(bundle, run, environment, policy) {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
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
49
|
/**
|
|
61
50
|
* Why a run may not pass its arguments, or null when it may. They go to a task, never a
|
|
62
51
|
* service, and never to a run in which an item reads a secret or takes a variable of the
|
|
63
52
|
* server into its `envs`: an argument can make the command print what the task holds,
|
|
64
53
|
* and what its dependencies write into their outputs or its services serve, past the
|
|
65
54
|
* masking of secrets. So a shell on a server runs with no account and no credential of
|
|
66
|
-
* the server.
|
|
55
|
+
* the server. Under `runs.args: declared` they go only to a task that says `args: true`,
|
|
56
|
+
* so a task nobody meant to take them never does.
|
|
67
57
|
*/
|
|
68
|
-
function refuseArguments(run, plan) {
|
|
58
|
+
function refuseArguments(run, plan, policy = {}) {
|
|
69
59
|
if (!run.options.args || run.options.args.length === 0) {
|
|
70
60
|
return null;
|
|
71
61
|
}
|
|
@@ -75,14 +65,21 @@ function refuseArguments(run, plan) {
|
|
|
75
65
|
if (!entry || entry.kind !== 'task') {
|
|
76
66
|
return `${name} is not a task of this version`;
|
|
77
67
|
}
|
|
78
|
-
|
|
68
|
+
// planning the run refuses it before, this keeps the check with the others
|
|
69
|
+
if (entry.args === false) {
|
|
70
|
+
return `${name} takes no arguments (args: false)`;
|
|
71
|
+
}
|
|
72
|
+
if (policy.args === 'declared' && entry.args !== true) {
|
|
73
|
+
return `${name} takes no arguments on this server: it takes them only for tasks that say args: true (runs.args: declared)`;
|
|
74
|
+
}
|
|
75
|
+
const reads = (0, plan_1.getCredentialReads)(entry);
|
|
79
76
|
if (reads.length > 0) {
|
|
80
77
|
return `${name} reads ${reads.join(', ')}, it takes no arguments on a server: an argument could make it print them`;
|
|
81
78
|
}
|
|
82
79
|
// what a dependency or a service of the run holds reaches the task through its outputs or
|
|
83
80
|
// its answers, where an argument can print it in a form no mask knows (`rev token.txt`)
|
|
84
81
|
for (const [other, item] of Object.entries(plan).sort(([a], [b]) => a.localeCompare(b))) {
|
|
85
|
-
const held = (other === name ? [] :
|
|
82
|
+
const held = (other === name ? [] : (0, plan_1.getCredentialReads)(item)).join(', ');
|
|
86
83
|
if (held.length > 0) {
|
|
87
84
|
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
85
|
}
|
|
@@ -238,8 +238,22 @@ const kubernetesBackendSchema = zod_1.z
|
|
|
238
238
|
.min(1)
|
|
239
239
|
.default([{ name: 'default', platform: { os: 'linux', arch: 'amd64' }, nodeSelector: {}, tolerations: [] }]),
|
|
240
240
|
// per secret provider, the image its command runs in: secrets are read in the
|
|
241
|
-
// Job of the task, not by the server
|
|
242
|
-
|
|
241
|
+
// Job of the task, not by the server. `allow` are the hosts its command reaches, such
|
|
242
|
+
// as secretmanager.googleapis.com: the egress proxy of a task with a limited network
|
|
243
|
+
// lets the init containers that read its secrets reach them, and nothing else does
|
|
244
|
+
providers: zod_1.z
|
|
245
|
+
.record(zod_1.z
|
|
246
|
+
.object({
|
|
247
|
+
image: zod_1.z.string().min(1),
|
|
248
|
+
allow: zod_1.z
|
|
249
|
+
.array(zod_1.z
|
|
250
|
+
.string()
|
|
251
|
+
.refine((host) => (0, network_schema_1.isAllowEntry)(host) && !host.includes(':'), 'expected a host name such as secretmanager.googleapis.com or *.example.com'))
|
|
252
|
+
.min(1)
|
|
253
|
+
.optional(),
|
|
254
|
+
})
|
|
255
|
+
.strict())
|
|
256
|
+
.default({}),
|
|
243
257
|
// the ResourceQuota of every caller's namespace, such as `requests.cpu: '8'`
|
|
244
258
|
quota: zod_1.z.record(zod_1.z.union([zod_1.z.string().min(1), zod_1.z.number()]).transform(String)).optional(),
|
|
245
259
|
// what a container without `resources` requests (a LimitRange), so it is
|
|
@@ -451,6 +465,13 @@ exports.serverConfigSchema = zod_1.z
|
|
|
451
465
|
// the most any task or service of a run reaches besides its needs: none, open
|
|
452
466
|
// or { allow: [hosts] }. A version that asks for more is refused at publish.
|
|
453
467
|
network: network_schema_1.networkSchema.default('open'),
|
|
468
|
+
// the most a task or service that reads a secret, or a variable of the server
|
|
469
|
+
// other than PATH, HOME and CI, reaches, under `network`: so a tool it runs can't
|
|
470
|
+
// send the value anywhere. Checked like `network`.
|
|
471
|
+
secretNetwork: network_schema_1.networkSchema.default('open'),
|
|
472
|
+
// which tasks take arguments: `any` but those that say `args: false`, or
|
|
473
|
+
// `declared`, only those that say `args: true`
|
|
474
|
+
args: zod_1.z.enum(['any', 'declared']).default('any'),
|
|
454
475
|
})
|
|
455
476
|
.strict()
|
|
456
477
|
.default({}),
|
|
@@ -586,7 +607,10 @@ exports.serverConfigSchema = zod_1.z
|
|
|
586
607
|
message: `under runs.network, ${shared}`,
|
|
587
608
|
});
|
|
588
609
|
}
|
|
589
|
-
|
|
610
|
+
// on a cluster a local task runs in a pod of a pool's tool image, which can be limited
|
|
611
|
+
if (config.sandboxes.runtimes?.includes('local') &&
|
|
612
|
+
config.sandboxes.network !== 'open' &&
|
|
613
|
+
config.backend.type === 'host') {
|
|
590
614
|
ctx.addIssue({
|
|
591
615
|
code: zod_1.z.ZodIssueCode.custom,
|
|
592
616
|
path: ['sandboxes', 'network'],
|
|
@@ -697,6 +721,13 @@ exports.serverConfigSchema = zod_1.z
|
|
|
697
721
|
message: 'runs.network limits every task with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them',
|
|
698
722
|
});
|
|
699
723
|
}
|
|
724
|
+
if (config.runs.secretNetwork !== 'open' && backend.networkPolicy !== 'enforced') {
|
|
725
|
+
ctx.addIssue({
|
|
726
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
727
|
+
path: ['backend', 'networkPolicy'],
|
|
728
|
+
message: 'runs.secretNetwork limits the tasks that read secrets with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them',
|
|
729
|
+
});
|
|
730
|
+
}
|
|
700
731
|
if (config.sandboxes.network !== 'open' &&
|
|
701
732
|
backend.networkPolicy !== 'enforced' &&
|
|
702
733
|
config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
|
|
@@ -707,7 +738,7 @@ exports.serverConfigSchema = zod_1.z
|
|
|
707
738
|
});
|
|
708
739
|
}
|
|
709
740
|
}
|
|
710
|
-
if (config.runs.network !== 'open' && config.runtimes.includes('local')) {
|
|
741
|
+
if (config.runs.network !== 'open' && config.runtimes.includes('local') && backend.type === 'host') {
|
|
711
742
|
ctx.addIssue({
|
|
712
743
|
code: zod_1.z.ZodIssueCode.custom,
|
|
713
744
|
path: ['runs', 'network'],
|
package/dist/server/server.js
CHANGED
|
@@ -275,7 +275,7 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
|
|
|
275
275
|
(0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
|
|
276
276
|
return;
|
|
277
277
|
}
|
|
278
|
-
const refused = (0, run_plan_1.refuseArguments)(request, planned.plan);
|
|
278
|
+
const refused = (0, run_plan_1.refuseArguments)(request, planned.plan, sandbox ? sandboxes.policy : planning.policy);
|
|
279
279
|
if (refused) {
|
|
280
280
|
log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${refused}`);
|
|
281
281
|
(0, http_2.send)(res, 400, { error: refused });
|
|
@@ -442,7 +442,8 @@ async function startServer(config, environment, options = {}) {
|
|
|
442
442
|
environment: {
|
|
443
443
|
...environment,
|
|
444
444
|
processEnvs: (0, run_environment_1.runEnvironment)(config, environment.processEnvs),
|
|
445
|
-
|
|
445
|
+
// on a cluster a local task runs in a pod of a pool's tool image
|
|
446
|
+
hosted: { catalog: (0, server_config_1.getSecretCatalog)(config), localTasksInContainers: config.backend.type === 'kubernetes' },
|
|
446
447
|
},
|
|
447
448
|
policy: {
|
|
448
449
|
envs: config.runs.env.allow,
|
|
@@ -450,6 +451,13 @@ async function startServer(config, environment, options = {}) {
|
|
|
450
451
|
runtimes: config.runtimes,
|
|
451
452
|
backend: config.backend.type,
|
|
452
453
|
network: (0, work_network_1.parseWorkNetwork)(config.runs.network),
|
|
454
|
+
secretNetwork: (0, work_network_1.parseWorkNetwork)(config.runs.secretNetwork),
|
|
455
|
+
args: config.runs.args,
|
|
456
|
+
proxiedProviders: config.backend.type === 'kubernetes'
|
|
457
|
+
? Object.entries(config.backend.providers)
|
|
458
|
+
.filter(([, provider]) => provider.allow)
|
|
459
|
+
.map(([name]) => name)
|
|
460
|
+
: [],
|
|
453
461
|
},
|
|
454
462
|
};
|
|
455
463
|
const storage = config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server');
|
package/package.json
CHANGED