hammerkit 1.26.0 → 1.27.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.schema.json +53 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/explain.js +13 -11
- package/dist/cli.js +3 -0
- package/dist/docker/item-network.js +471 -0
- package/dist/docker/pull.js +4 -2
- package/dist/docker/remove-orphaned-containers.js +13 -6
- package/dist/environment/replace-env-variables.js +8 -6
- package/dist/executer/await-completed-dependencies.js +2 -1
- package/dist/executer/check-readiness.js +2 -2
- package/dist/executer/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- package/dist/executer/execute-command.js +18 -5
- package/dist/executer/execute-docker.js +4 -1
- package/dist/executer/execution-steps.js +33 -7
- package/dist/executer/get-docker-executor.js +14 -7
- package/dist/executer/scheduler/enqueue-next.js +17 -3
- package/dist/executer/watch-loop.js +2 -2
- package/dist/executer/watch-state-key.js +1 -1
- package/dist/file/within-root.js +42 -0
- package/dist/harness/connect.js +99 -0
- package/dist/harness/format.js +73 -0
- package/dist/harness/mcp-command.js +59 -0
- package/dist/harness/mcp.js +432 -0
- package/dist/harness/opencode.js +492 -0
- package/dist/harness/pi.js +217 -0
- package/dist/harness/sandbox-client.js +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/log.js +1 -1
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -0
- package/dist/network/port-relay.js +35 -0
- package/dist/optimizer/get-work-cache-stats.js +16 -0
- package/dist/optimizer/work-task-cache-description.js +3 -2
- package/dist/planner/service-definition.js +5 -0
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +41 -0
- package/dist/planner/work-runtime-kubernetes.js +335 -102
- package/dist/program.js +23 -1
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- package/dist/remote/server-client.js +18 -5
- package/dist/remote/source-manifest.js +15 -2
- package/dist/remote/token-source.js +67 -0
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -0
- package/dist/schema/build-file-container-task-schema.js +3 -0
- package/dist/schema/build-file-environment-schema.js +9 -0
- package/dist/schema/network-schema.js +78 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +5 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +26 -0
- package/dist/secrets/github-app-token.js +191 -0
- package/dist/server/accounts-api.js +9 -2
- package/dist/server/definitions-api.js +69 -47
- package/dist/server/definitions.js +76 -51
- package/dist/server/executor.js +156 -41
- package/dist/server/git-checkout.js +72 -8
- package/dist/server/kubernetes-backend.js +562 -0
- package/dist/server/kubernetes-sandbox-storage.js +996 -0
- package/dist/server/oauth.js +1 -0
- package/dist/server/plan.js +51 -1
- package/dist/server/policy.js +104 -15
- package/dist/server/proposals-api.js +24 -40
- package/dist/server/proposals.js +12 -25
- package/dist/server/repo-access.js +37 -6
- package/dist/server/run-logs.js +217 -0
- package/dist/server/run-plan.js +51 -2
- package/dist/server/run-request.js +23 -10
- package/dist/server/run-store.js +5 -0
- package/dist/server/run-worker-command.js +13 -1
- package/dist/server/run-worker.js +121 -24
- package/dist/server/runs.js +184 -10
- package/dist/server/sandbox-docker.js +62 -0
- package/dist/server/sandbox-files-api.js +269 -0
- package/dist/server/sandbox-files.js +1054 -0
- package/dist/server/sandbox-patch.js +481 -0
- package/dist/server/sandbox-pod-command.js +15 -0
- package/dist/server/sandbox-pod.js +146 -0
- package/dist/server/sandbox-storage.js +462 -0
- package/dist/server/sandboxes-api.js +210 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-config.js +412 -22
- package/dist/server/server.js +204 -39
- package/dist/utils/run-id.js +12 -0
- package/harness/opencode.ts +11 -0
- package/harness/pi.ts +7 -0
- package/package.json +8 -2
- package/dist/remote/text-diff.js +0 -89
|
@@ -5,6 +5,7 @@ const work_secret_1 = require("../planner/work-secret");
|
|
|
5
5
|
const apply_1 = require("./apply");
|
|
6
6
|
const resources_1 = require("./resources");
|
|
7
7
|
const version_1 = require("../version");
|
|
8
|
+
const job_placement_1 = require("./job-placement");
|
|
8
9
|
const SECRET_VOLUME_NAME = 'hammerkit-secrets';
|
|
9
10
|
function getSecretResourceName(item) {
|
|
10
11
|
return (0, resources_1.getResourceName)(item, '-secrets');
|
|
@@ -55,6 +56,7 @@ async function ensureKubernetesSecret(instance, kubernetes, item, secrets, envir
|
|
|
55
56
|
},
|
|
56
57
|
labels: {
|
|
57
58
|
'hammerkit.dev/id': item.id(),
|
|
59
|
+
...(0, job_placement_1.getRunLabels)(kubernetes),
|
|
58
60
|
},
|
|
59
61
|
},
|
|
60
62
|
type: 'Opaque',
|
|
@@ -6,7 +6,7 @@ const event_cache_1 = require("../executer/event-cache");
|
|
|
6
6
|
const volumes_1 = require("./volumes");
|
|
7
7
|
const ensure_persistent_data_1 = require("./ensure-persistent-data");
|
|
8
8
|
async function storeKubernetesData(work, kubernetes, instance, environment, path) {
|
|
9
|
-
const persistence = await (0, volumes_1.getKubernetesPersistence)(work);
|
|
9
|
+
const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes);
|
|
10
10
|
await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'read', async (name) => {
|
|
11
11
|
for (const generatedArchive of (0, event_cache_1.getArchivePaths)(work.data, path)) {
|
|
12
12
|
const writerStream = environment.file.createWriteStream(generatedArchive.filename);
|
|
@@ -1,37 +1,88 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.appendVolume = exports.getKubernetesPersistence = exports.getVolumeName = void 0;
|
|
3
|
+
exports.getSandboxAffinity = exports.SANDBOX_LABEL = exports.getWorkspacePodSpec = exports.appendVolume = exports.getKubernetesPersistence = exports.getVolumeName = void 0;
|
|
4
4
|
const path_1 = require("path");
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const kept_claims_1 = require("./kept-claims");
|
|
7
|
+
function getVolumeName(service, kubernetes) {
|
|
8
|
+
return (0, kept_claims_1.getClaimName)(service, kubernetes);
|
|
7
9
|
}
|
|
8
10
|
exports.getVolumeName = getVolumeName;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
const WORKSPACE_VOLUME = 'hammerkit-sandbox';
|
|
12
|
+
// The path of `path` below the root of a sandbox's files, or undefined when it is not
|
|
13
|
+
// below it
|
|
14
|
+
function workspacePath(workspace, path) {
|
|
15
|
+
const rel = (0, path_1.relative)(workspace.root, path);
|
|
16
|
+
if (rel.startsWith('..') || (0, path_1.isAbsolute)(rel)) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return [workspace.subPath, ...rel.split(path_1.sep).filter((part) => part.length > 0)].join('/');
|
|
20
|
+
}
|
|
21
|
+
// A path of a sandbox is mounted from its claim, read and write as on a host's docker,
|
|
22
|
+
// so what the task writes is in the sandbox afterwards. A path that is not in the
|
|
23
|
+
// sandbox is left out: Kubernetes would make a directory of it in the claim.
|
|
24
|
+
function appendWorkspaceMount(workspace, persistence, containerPath, localPath, isFile, readOnly) {
|
|
25
|
+
const subPath = workspacePath(workspace, localPath);
|
|
26
|
+
if (subPath === undefined) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (!(0, fs_1.existsSync)(localPath)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (persistence.workspace.length === 0) {
|
|
33
|
+
persistence.workspace.push({ name: WORKSPACE_VOLUME, persistentVolumeClaim: { claimName: workspace.claim } });
|
|
34
|
+
}
|
|
35
|
+
persistence.mounts.push({
|
|
36
|
+
mount: { readOnly, mountPath: containerPath, name: WORKSPACE_VOLUME, subPath },
|
|
37
|
+
uploadPath: '',
|
|
38
|
+
cwd: isFile ? (0, path_1.dirname)(containerPath) : containerPath,
|
|
39
|
+
workspace: true,
|
|
40
|
+
});
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
async function getKubernetesPersistence(work, kubernetes) {
|
|
44
|
+
const persistence = {
|
|
45
|
+
volumes: [],
|
|
46
|
+
sizes: {},
|
|
47
|
+
kept: [],
|
|
48
|
+
workspace: [],
|
|
49
|
+
sources: [],
|
|
50
|
+
mounts: [],
|
|
51
|
+
initCommands: [],
|
|
52
|
+
};
|
|
53
|
+
const workspace = kubernetes?.server?.workspace;
|
|
11
54
|
for (const mount of work.data.mounts) {
|
|
12
|
-
|
|
55
|
+
if (workspace &&
|
|
56
|
+
appendWorkspaceMount(workspace, persistence, mount.containerPath, mount.localPath, mount.isFile, mount.readOnly)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
appendVolume(work, persistence, mount.containerPath, mount.localPath, mount.isFile, true, kubernetes);
|
|
13
60
|
}
|
|
14
61
|
for (const src of work.data.src) {
|
|
15
62
|
if (src.inherited) {
|
|
16
63
|
continue;
|
|
17
64
|
}
|
|
18
|
-
|
|
65
|
+
if (workspace &&
|
|
66
|
+
appendWorkspaceMount(workspace, persistence, src.absolutePath, src.absolutePath, src.isFile, false)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes); // TODO matcher
|
|
19
70
|
}
|
|
20
71
|
if (work.data.type === 'container-task') {
|
|
21
72
|
for (const generate of work.data.generates) {
|
|
22
|
-
appendVolume(generate.inherited ?? work, persistence, generate.path, null, false, !!generate.inherited);
|
|
73
|
+
appendVolume(generate.inherited ?? work, persistence, generate.path, null, false, !!generate.inherited, kubernetes);
|
|
23
74
|
}
|
|
24
75
|
}
|
|
25
76
|
else {
|
|
26
77
|
for (const volume of work.data.volumes) {
|
|
27
|
-
appendVolume(volume.inherited ?? work, persistence, volume.containerPath, null, false, !!volume.inherited || volume.readOnly);
|
|
78
|
+
appendVolume(volume.inherited ?? work, persistence, volume.containerPath, null, false, !!volume.inherited || volume.readOnly, kubernetes);
|
|
28
79
|
}
|
|
29
80
|
}
|
|
30
81
|
return persistence;
|
|
31
82
|
}
|
|
32
83
|
exports.getKubernetesPersistence = getKubernetesPersistence;
|
|
33
|
-
function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly) {
|
|
34
|
-
const volumeName = getVolumeName(sourceWork);
|
|
84
|
+
function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes) {
|
|
85
|
+
const volumeName = getVolumeName(sourceWork, kubernetes);
|
|
35
86
|
const existVolume = persistence.volumes.some((v) => v.name === volumeName);
|
|
36
87
|
if (!existVolume) {
|
|
37
88
|
persistence.volumes.push({
|
|
@@ -40,6 +91,10 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
|
|
|
40
91
|
claimName: volumeName,
|
|
41
92
|
},
|
|
42
93
|
});
|
|
94
|
+
persistence.sizes[volumeName] = 'volumeSize' in sourceWork.data ? sourceWork.data.volumeSize : null;
|
|
95
|
+
if (kubernetes && (0, kept_claims_1.isKeptClaim)(sourceWork, kubernetes)) {
|
|
96
|
+
persistence.kept.push(volumeName);
|
|
97
|
+
}
|
|
43
98
|
}
|
|
44
99
|
persistence.mounts.push({
|
|
45
100
|
mount: {
|
|
@@ -61,4 +116,38 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
|
|
|
61
116
|
}
|
|
62
117
|
}
|
|
63
118
|
exports.appendVolume = appendVolume;
|
|
119
|
+
/**
|
|
120
|
+
* What a pod that mounts the claim of a sandbox needs: its label, and to go where the
|
|
121
|
+
* other pods that mount it run. A claim is usually `ReadWriteOnce`, which several pods
|
|
122
|
+
* share only on one node; the first pod (no other one runs) goes anywhere. The pod of the
|
|
123
|
+
* sandbox's files runs on the first pool while a run works, so a task on another pool
|
|
124
|
+
* would never start: it is refused.
|
|
125
|
+
*/
|
|
126
|
+
function getWorkspacePodSpec(kubernetes, persistence, task) {
|
|
127
|
+
const workspace = kubernetes.server?.workspace;
|
|
128
|
+
if (!workspace || persistence.workspace.length === 0) {
|
|
129
|
+
return { labels: {} };
|
|
130
|
+
}
|
|
131
|
+
const first = kubernetes.server?.pools[0]?.name;
|
|
132
|
+
if (task?.pool && first && task.pool !== first) {
|
|
133
|
+
throw new Error(`task ${task.name}: runs on the pool ${task.pool}, and the tasks of a run on a sandbox run on the first pool, ${first}, where its files are`);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
labels: { [exports.SANDBOX_LABEL]: workspace.label },
|
|
137
|
+
affinity: getSandboxAffinity(workspace.label),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
exports.getWorkspacePodSpec = getWorkspacePodSpec;
|
|
141
|
+
// the label of the pods that mount the claim of a sandbox
|
|
142
|
+
exports.SANDBOX_LABEL = 'hammerkit.dev/sandbox';
|
|
143
|
+
function getSandboxAffinity(label) {
|
|
144
|
+
return {
|
|
145
|
+
podAffinity: {
|
|
146
|
+
requiredDuringSchedulingIgnoredDuringExecution: [
|
|
147
|
+
{ labelSelector: { matchLabels: { [exports.SANDBOX_LABEL]: label } }, topologyKey: 'kubernetes.io/hostname' },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
exports.getSandboxAffinity = getSandboxAffinity;
|
|
64
153
|
//# sourceMappingURL=volumes.js.map
|
package/dist/log.js
CHANGED
|
@@ -216,7 +216,7 @@ function writeWorkItemLogToConsole(env, log, maxNodeNameLength) {
|
|
|
216
216
|
exports.writeWorkItemLogToConsole = writeWorkItemLogToConsole;
|
|
217
217
|
function formatDate(date) {
|
|
218
218
|
return colors_1.default.grey(`[${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date
|
|
219
|
-
.
|
|
219
|
+
.getDate()
|
|
220
220
|
.toString()
|
|
221
221
|
.padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date
|
|
222
222
|
.getMinutes()
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// The CLI: what `hammerkit` runs, for all but the command of a sandbox's pod (see index.ts).
|
|
4
|
+
const log_1 = require("./log");
|
|
5
|
+
const get_file_context_1 = require("./file/get-file-context");
|
|
6
|
+
const work_item_status_1 = require("./planner/work-item-status");
|
|
7
|
+
const redact_1 = require("./utils/redact");
|
|
8
|
+
const empty_writable_1 = require("./utils/empty-writable");
|
|
9
|
+
const run_program_1 = require("./run-program");
|
|
10
|
+
const abort_on_signals_1 = require("./utils/abort-on-signals");
|
|
11
|
+
const abortCtrl = new AbortController();
|
|
12
|
+
(0, abort_on_signals_1.abortOnSignals)(process, abortCtrl);
|
|
13
|
+
let settled = false;
|
|
14
|
+
// A run that never settles (a task waiting on something that can't happen)
|
|
15
|
+
// lets the event loop drain, and Node then exits 0: CI would report success
|
|
16
|
+
// for a build that never finished. Fail loudly instead.
|
|
17
|
+
process.on('beforeExit', () => {
|
|
18
|
+
if (!settled) {
|
|
19
|
+
process.stderr.write('hammerkit stopped before the run finished\n');
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const secrets = (0, redact_1.createSecretRegistry)();
|
|
24
|
+
(0, run_program_1.runProgram)({
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
abortCtrl,
|
|
27
|
+
processEnvs: process.env,
|
|
28
|
+
file: (0, get_file_context_1.getFileContext)(process.cwd()),
|
|
29
|
+
console: (0, log_1.consoleContext)(process.stdout),
|
|
30
|
+
status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
|
|
31
|
+
secrets,
|
|
32
|
+
stdout: process.stdout,
|
|
33
|
+
stderr: process.stderr,
|
|
34
|
+
stdoutColumns: process.stdout.columns,
|
|
35
|
+
}, process.argv, false).then(() => {
|
|
36
|
+
settled = true;
|
|
37
|
+
}, () => {
|
|
38
|
+
settled = true;
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
41
|
+
//# sourceMappingURL=main.js.map
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The egress proxy of a task or service with `network: { allow: [hosts] }`.
|
|
3
|
+
// It runs in its own container (a pod on Kubernetes) next to the item, on a
|
|
4
|
+
// network only the item and the proxy join, and is the item's only way out: HTTP requests and
|
|
5
|
+
// `CONNECT` tunnels to an allowed host pass, everything else gets a 403 and a
|
|
6
|
+
// `refused` event on stdout. For each `host:port` entry it also listens on that
|
|
7
|
+
// port and forwards raw TCP to that host, which the item reaches under the
|
|
8
|
+
// host's name. The script is passed to `node -e`, so the proxy
|
|
9
|
+
// needs no image of its own, only a stock Node image.
|
|
10
|
+
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;
|
|
12
|
+
const work_network_1 = require("../planner/work-network");
|
|
13
|
+
const network_schema_1 = require("../schema/network-schema");
|
|
14
|
+
// Pinned, so the code behind the boundary is the same on every host. To bump
|
|
15
|
+
// it, take the index digest of the tag (`docker buildx imagetools inspect
|
|
16
|
+
// node:24-alpine`). The port relay runs in it too.
|
|
17
|
+
exports.EGRESS_PROXY_IMAGE = 'node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1';
|
|
18
|
+
exports.EGRESS_PROXY_PORT = network_schema_1.PROXY_PORT;
|
|
19
|
+
// the name the item reaches the proxy under
|
|
20
|
+
exports.EGRESS_PROXY_HOST = 'hammerkit-proxy';
|
|
21
|
+
// Kept free of anything but Node's own modules. The host rule is the one of
|
|
22
|
+
// matchesHost in planner/work-network.ts. A name a wildcard allows must not
|
|
23
|
+
// resolve to the host or a private network, and none to link-local.
|
|
24
|
+
exports.EGRESS_PROXY_SCRIPT = `
|
|
25
|
+
const http = require('http')
|
|
26
|
+
const net = require('net')
|
|
27
|
+
const dns = require('dns')
|
|
28
|
+
const entries = JSON.parse(process.env.HAMMERKIT_ALLOW || '[]').map((h) => h.toLowerCase())
|
|
29
|
+
// names and patterns for HTTP(S)
|
|
30
|
+
const allow = entries.filter((e) => !e.includes(':'))
|
|
31
|
+
// names and ports for raw TCP, each forwarded on its port
|
|
32
|
+
const forwards = entries
|
|
33
|
+
.filter((e) => e.includes(':'))
|
|
34
|
+
.map((e) => ({ host: e.substring(0, e.lastIndexOf(':')), port: parseInt(e.substring(e.lastIndexOf(':') + 1), 10) }))
|
|
35
|
+
// entries of both kinds that a ceiling allows only through a pattern: they
|
|
36
|
+
// reach no private address, like a pattern
|
|
37
|
+
const publicOnly = JSON.parse(process.env.HAMMERKIT_PUBLIC_ONLY || '[]').map((h) => h.toLowerCase())
|
|
38
|
+
// the names of the needed services, for tools that ignore NO_PROXY: exact
|
|
39
|
+
// names only, never patterns
|
|
40
|
+
const needs = JSON.parse(process.env.HAMMERKIT_NEEDS || '[]').map((h) => h.toLowerCase())
|
|
41
|
+
const port = parseInt(process.env.HAMMERKIT_PROXY_PORT || '${exports.EGRESS_PROXY_PORT}', 10)
|
|
42
|
+
// the name whose address the proxy listens on, every address when unset
|
|
43
|
+
const bind = process.env.HAMMERKIT_PROXY_BIND
|
|
44
|
+
const refused = new Set()
|
|
45
|
+
const httpsUrls = new Set()
|
|
46
|
+
function emit(event) {
|
|
47
|
+
process.stdout.write(JSON.stringify(event) + '\\n')
|
|
48
|
+
}
|
|
49
|
+
// the rule that allows a host and port: its exact name, a wildcard, or none
|
|
50
|
+
function allowedBy(host, port) {
|
|
51
|
+
const name = host.toLowerCase().replace(/\\.$/, '')
|
|
52
|
+
if (needs.includes(name)) {
|
|
53
|
+
return 'exact'
|
|
54
|
+
}
|
|
55
|
+
const named = []
|
|
56
|
+
if (allow.includes(name)) {
|
|
57
|
+
named.push(name)
|
|
58
|
+
}
|
|
59
|
+
if (forwards.some((f) => f.host === name && f.port === port)) {
|
|
60
|
+
named.push(name + ':' + port)
|
|
61
|
+
}
|
|
62
|
+
if (named.length > 0) {
|
|
63
|
+
return named.some((entry) => !publicOnly.includes(entry)) ? 'exact' : 'wildcard'
|
|
64
|
+
}
|
|
65
|
+
return allow.some((rule) => rule.startsWith('*.') && name.endsWith(rule.substring(1))) ? 'wildcard' : null
|
|
66
|
+
}
|
|
67
|
+
function ipv4(address) {
|
|
68
|
+
const mapped = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/i.exec(address)
|
|
69
|
+
const value = mapped ? mapped[1] : address
|
|
70
|
+
return net.isIPv4(value) ? value.split('.').map(Number) : null
|
|
71
|
+
}
|
|
72
|
+
// never allowed: the cloud metadata service lives there
|
|
73
|
+
function isLinkLocal(address) {
|
|
74
|
+
const v4 = ipv4(address)
|
|
75
|
+
return v4 ? v4[0] === 169 && v4[1] === 254 : /^fe[89ab]/i.test(address)
|
|
76
|
+
}
|
|
77
|
+
// the host, its private networks (Docker's bridges among them) and the
|
|
78
|
+
// cluster: only for a name listed exactly, such as a needed service
|
|
79
|
+
function isInternal(address) {
|
|
80
|
+
const v4 = ipv4(address)
|
|
81
|
+
if (v4) {
|
|
82
|
+
const [a, b] = v4
|
|
83
|
+
return (
|
|
84
|
+
a === 0 ||
|
|
85
|
+
a === 10 ||
|
|
86
|
+
a === 127 ||
|
|
87
|
+
(a === 100 && b >= 64 && b < 128) ||
|
|
88
|
+
(a === 172 && b >= 16 && b < 32) ||
|
|
89
|
+
(a === 192 && b === 168)
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
return address === '::' || address === '::1' || /^f[cd]/i.test(address)
|
|
93
|
+
}
|
|
94
|
+
// The proxy resolves the name itself and connects only to the addresses it
|
|
95
|
+
// checked, each in turn (a name can have an IPv6 address the network has no
|
|
96
|
+
// route to).
|
|
97
|
+
function resolve(host, rule, callback) {
|
|
98
|
+
dns.lookup(host, { all: true }, (err, addresses) => {
|
|
99
|
+
if (err || addresses.length === 0) {
|
|
100
|
+
callback(err || new Error('no address'), null)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const blocked = addresses.some((a) => isLinkLocal(a.address) || (rule !== 'exact' && isInternal(a.address)))
|
|
104
|
+
callback(null, blocked ? null : addresses)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
// connection options that reach the checked addresses and nothing else
|
|
108
|
+
function connectTo(host, addresses) {
|
|
109
|
+
const lookup = (hostname, options, callback) => {
|
|
110
|
+
const cb = typeof options === 'function' ? options : callback
|
|
111
|
+
if (typeof options === 'object' && options && options.all) {
|
|
112
|
+
cb(null, addresses)
|
|
113
|
+
} else {
|
|
114
|
+
cb(null, addresses[0].address, addresses[0].family)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { host, lookup, autoSelectFamily: true }
|
|
118
|
+
}
|
|
119
|
+
function refuse(host) {
|
|
120
|
+
if (!refused.has(host)) {
|
|
121
|
+
refused.add(host)
|
|
122
|
+
emit({ type: 'refused', host })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function httpsUrl(host) {
|
|
126
|
+
if (!httpsUrls.has(host)) {
|
|
127
|
+
httpsUrls.add(host)
|
|
128
|
+
emit({ type: 'https-url', host })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function parseAuthority(value) {
|
|
132
|
+
const match = /^\\[([^\\]]+)\\](?::(\\d+))?$/.exec(value) || /^([^:]+)(?::(\\d+))?$/.exec(value)
|
|
133
|
+
return match ? { host: match[1], port: match[2] ? parseInt(match[2], 10) : 443 } : null
|
|
134
|
+
}
|
|
135
|
+
const server = http.createServer((req, res) => {
|
|
136
|
+
let url
|
|
137
|
+
try {
|
|
138
|
+
url = new URL(req.url)
|
|
139
|
+
} catch (e) {
|
|
140
|
+
res.writeHead(400, { 'content-type': 'text/plain' })
|
|
141
|
+
res.end('hammerkit: only proxy requests are served\\n')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
// BusyBox wget sends an https URL to the proxy as it is: TLS belongs between
|
|
145
|
+
// the client and the host, so only a CONNECT tunnel reaches an https host
|
|
146
|
+
if (url.protocol === 'https:' && allowedBy(url.hostname, parseInt(url.port || '443', 10))) {
|
|
147
|
+
httpsUrl(url.hostname)
|
|
148
|
+
res.writeHead(501, { 'content-type': 'text/plain' })
|
|
149
|
+
res.end('hammerkit: ' + url.hostname + ' is allowed through a CONNECT tunnel only, not by its https URL\\n')
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const rule = url.protocol === 'http:' ? allowedBy(url.hostname, parseInt(url.port || '80', 10)) : null
|
|
153
|
+
if (!rule) {
|
|
154
|
+
refuse(url.hostname)
|
|
155
|
+
res.writeHead(403, { 'content-type': 'text/plain' })
|
|
156
|
+
res.end('hammerkit: network access to ' + url.hostname + ' is not allowed\\n')
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
resolve(url.hostname, rule, (err, addresses) => {
|
|
160
|
+
if (err) {
|
|
161
|
+
res.writeHead(502, { 'content-type': 'text/plain' })
|
|
162
|
+
res.end()
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (!addresses) {
|
|
166
|
+
refuse(url.hostname)
|
|
167
|
+
res.writeHead(403, { 'content-type': 'text/plain' })
|
|
168
|
+
res.end('hammerkit: ' + url.hostname + ' resolves to an address that is not allowed\\n')
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const headers = Object.assign({}, req.headers)
|
|
172
|
+
delete headers['proxy-connection']
|
|
173
|
+
delete headers['proxy-authorization']
|
|
174
|
+
// the absolute URL wins over a Host header that disagrees
|
|
175
|
+
headers.host = url.host
|
|
176
|
+
const upstream = http.request(
|
|
177
|
+
Object.assign(connectTo(url.hostname, addresses), {
|
|
178
|
+
port: url.port || 80,
|
|
179
|
+
method: req.method,
|
|
180
|
+
path: url.pathname + url.search,
|
|
181
|
+
headers,
|
|
182
|
+
}),
|
|
183
|
+
(response) => {
|
|
184
|
+
res.writeHead(response.statusCode || 502, response.headers)
|
|
185
|
+
response.pipe(res)
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
upstream.on('error', () => {
|
|
189
|
+
if (!res.headersSent) {
|
|
190
|
+
res.writeHead(502, { 'content-type': 'text/plain' })
|
|
191
|
+
}
|
|
192
|
+
res.end()
|
|
193
|
+
})
|
|
194
|
+
req.pipe(upstream)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
server.on('connect', (req, socket, head) => {
|
|
198
|
+
const target = parseAuthority(req.url || '')
|
|
199
|
+
socket.on('error', () => socket.destroy())
|
|
200
|
+
const rule = target ? allowedBy(target.host, target.port) : null
|
|
201
|
+
if (!rule) {
|
|
202
|
+
refuse(target ? target.host : String(req.url))
|
|
203
|
+
socket.end('HTTP/1.1 403 Forbidden\\r\\ncontent-type: text/plain\\r\\n\\r\\nhammerkit: network access is not allowed\\n')
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
resolve(target.host, rule, (err, addresses) => {
|
|
207
|
+
if (err) {
|
|
208
|
+
socket.end('HTTP/1.1 502 Bad Gateway\\r\\n\\r\\n')
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
if (!addresses) {
|
|
212
|
+
refuse(target.host)
|
|
213
|
+
socket.end('HTTP/1.1 403 Forbidden\\r\\ncontent-type: text/plain\\r\\n\\r\\nhammerkit: network access is not allowed\\n')
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
let established = false
|
|
217
|
+
const upstream = net.connect(Object.assign(connectTo(target.host, addresses), { port: target.port }), () => {
|
|
218
|
+
established = true
|
|
219
|
+
socket.write('HTTP/1.1 200 Connection Established\\r\\n\\r\\n')
|
|
220
|
+
if (head && head.length > 0) {
|
|
221
|
+
upstream.write(head)
|
|
222
|
+
}
|
|
223
|
+
upstream.pipe(socket)
|
|
224
|
+
socket.pipe(upstream)
|
|
225
|
+
})
|
|
226
|
+
upstream.on('error', () => {
|
|
227
|
+
if (established) {
|
|
228
|
+
socket.destroy()
|
|
229
|
+
} else {
|
|
230
|
+
socket.end('HTTP/1.1 502 Bad Gateway\\r\\n\\r\\n')
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
socket.on('close', () => upstream.destroy())
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
// A TCP forward: each connection goes to the checked addresses of its host,
|
|
237
|
+
// on the same port.
|
|
238
|
+
function forward(target) {
|
|
239
|
+
return net.createServer({ allowHalfOpen: true }, (client) => {
|
|
240
|
+
client.on('error', () => client.destroy())
|
|
241
|
+
resolve(target.host, allowedBy(target.host, target.port), (err, addresses) => {
|
|
242
|
+
if (err || !addresses) {
|
|
243
|
+
if (!err) {
|
|
244
|
+
refuse(target.host)
|
|
245
|
+
}
|
|
246
|
+
client.destroy()
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
const upstream = net.connect(Object.assign(connectTo(target.host, addresses), { port: target.port }))
|
|
250
|
+
upstream.on('error', () => client.destroy())
|
|
251
|
+
client.on('close', () => upstream.destroy())
|
|
252
|
+
client.pipe(upstream)
|
|
253
|
+
upstream.pipe(client)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
function listen(address) {
|
|
258
|
+
const servers = [{ server, port }].concat(forwards.map((f) => ({ server: forward(f), port: f.port })))
|
|
259
|
+
let pending = servers.length
|
|
260
|
+
for (const entry of servers) {
|
|
261
|
+
entry.server.on('error', (err) => {
|
|
262
|
+
process.stderr.write('hammerkit: unable to listen on port ' + entry.port + ': ' + err.message + '\\n')
|
|
263
|
+
process.exit(1)
|
|
264
|
+
})
|
|
265
|
+
entry.server.listen(entry.port, address, () => {
|
|
266
|
+
pending--
|
|
267
|
+
if (pending === 0) {
|
|
268
|
+
emit({ type: 'ready' })
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (bind) {
|
|
274
|
+
dns.lookup(bind, { family: 4 }, (err, address) => {
|
|
275
|
+
if (err) {
|
|
276
|
+
process.stderr.write('hammerkit: unable to resolve ' + bind + ': ' + err.message + '\\n')
|
|
277
|
+
process.exit(1)
|
|
278
|
+
}
|
|
279
|
+
listen(address)
|
|
280
|
+
})
|
|
281
|
+
} else {
|
|
282
|
+
listen(undefined)
|
|
283
|
+
}
|
|
284
|
+
process.on('SIGTERM', () => process.exit(0))
|
|
285
|
+
`;
|
|
286
|
+
function getEgressProxyCommand() {
|
|
287
|
+
return ['node', '-e', exports.EGRESS_PROXY_SCRIPT];
|
|
288
|
+
}
|
|
289
|
+
exports.getEgressProxyCommand = getEgressProxyCommand;
|
|
290
|
+
// `needs` are the names of the needed services, allowed as exactly those
|
|
291
|
+
// names. `bind` is the name the proxy listens on alone, so that nothing on its
|
|
292
|
+
// other networks reaches it. `publicOnly` are the entries of `hosts` that reach
|
|
293
|
+
// no private address (see WorkNetwork).
|
|
294
|
+
function getEgressProxyEnvironment(hosts, needs, bind, publicOnly = []) {
|
|
295
|
+
return {
|
|
296
|
+
HAMMERKIT_ALLOW: JSON.stringify(hosts),
|
|
297
|
+
...(publicOnly.length > 0 ? { HAMMERKIT_PUBLIC_ONLY: JSON.stringify(publicOnly) } : {}),
|
|
298
|
+
HAMMERKIT_NEEDS: JSON.stringify(needs),
|
|
299
|
+
HAMMERKIT_PROXY_PORT: `${exports.EGRESS_PROXY_PORT}`,
|
|
300
|
+
...(bind ? { HAMMERKIT_PROXY_BIND: bind } : {}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
exports.getEgressProxyEnvironment = getEgressProxyEnvironment;
|
|
304
|
+
// The proxy of a task or service: the hosts of its network in this run (under
|
|
305
|
+
// the run's ceiling), and the names of the services it needs, on every runtime.
|
|
306
|
+
function getItemProxyEnvironment(item, bind) {
|
|
307
|
+
const network = (0, work_network_1.getEffectiveNetwork)(item.data);
|
|
308
|
+
return getEgressProxyEnvironment(network.type === 'allow' ? network.hosts : [], item.needs.map((need) => need.name), bind, network.type === 'allow' ? network.publicOnly ?? [] : []);
|
|
309
|
+
}
|
|
310
|
+
exports.getItemProxyEnvironment = getItemProxyEnvironment;
|
|
311
|
+
// The variables that send the item's HTTP(S) through the proxy, both spellings
|
|
312
|
+
// since tools read either. The services it needs are reached directly.
|
|
313
|
+
// The ports the proxy of a network listens on: its own, and one per TCP forward.
|
|
314
|
+
function getEgressProxyPorts(network) {
|
|
315
|
+
return [exports.EGRESS_PROXY_PORT, ...(0, work_network_1.getTcpForwards)(network).map((forward) => forward.port)];
|
|
316
|
+
}
|
|
317
|
+
exports.getEgressProxyPorts = getEgressProxyPorts;
|
|
318
|
+
function getProxyClientEnvironment(directHosts) {
|
|
319
|
+
const proxy = `http://${exports.EGRESS_PROXY_HOST}:${exports.EGRESS_PROXY_PORT}`;
|
|
320
|
+
const noProxy = ['localhost', '127.0.0.1', ...directHosts].join(',');
|
|
321
|
+
return {
|
|
322
|
+
HTTP_PROXY: proxy,
|
|
323
|
+
HTTPS_PROXY: proxy,
|
|
324
|
+
NO_PROXY: noProxy,
|
|
325
|
+
http_proxy: proxy,
|
|
326
|
+
https_proxy: proxy,
|
|
327
|
+
no_proxy: noProxy,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
exports.getProxyClientEnvironment = getProxyClientEnvironment;
|
|
331
|
+
// The proxy variables emptied: a healthcheck checks the service itself, and
|
|
332
|
+
// tools that ignore NO_PROXY (busybox wget) would ask the proxy for localhost.
|
|
333
|
+
function getProxylessEnvironment(env) {
|
|
334
|
+
const proxyKeys = Object.keys(env).filter((key) => /^(https?|no)_proxy$/i.test(key));
|
|
335
|
+
return proxyKeys.length > 0 ? Object.fromEntries(proxyKeys.map((key) => [key, ''])) : undefined;
|
|
336
|
+
}
|
|
337
|
+
exports.getProxylessEnvironment = getProxylessEnvironment;
|
|
338
|
+
// One line of the proxy's stdout, null for anything else.
|
|
339
|
+
function parseEgressProxyEvent(line) {
|
|
340
|
+
try {
|
|
341
|
+
const value = JSON.parse(line);
|
|
342
|
+
if (value?.type === 'ready') {
|
|
343
|
+
return { type: 'ready' };
|
|
344
|
+
}
|
|
345
|
+
if ((value?.type === 'refused' || value?.type === 'https-url') && typeof value.host === 'string') {
|
|
346
|
+
return { type: value.type, host: value.host };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// not an event
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
exports.parseEgressProxyEvent = parseEgressProxyEvent;
|
|
355
|
+
function formatRefusedHost(host) {
|
|
356
|
+
return `network: refused ${host}`;
|
|
357
|
+
}
|
|
358
|
+
exports.formatRefusedHost = formatRefusedHost;
|
|
359
|
+
// The line of an event other than `ready` in the item's log.
|
|
360
|
+
function formatEgressProxyEvent(event) {
|
|
361
|
+
if (event.type === 'https-url') {
|
|
362
|
+
return (`network: ${event.host} is allowed, but the client asked the proxy for its https URL instead of ` +
|
|
363
|
+
'tunneling with CONNECT (BusyBox wget does), use curl or GNU wget');
|
|
364
|
+
}
|
|
365
|
+
return formatRefusedHost(event.host);
|
|
366
|
+
}
|
|
367
|
+
exports.formatEgressProxyEvent = formatEgressProxyEvent;
|
|
368
|
+
//# sourceMappingURL=egress-proxy.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The published ports of a service with a limited network. Docker publishes
|
|
3
|
+
// ports only on a network with a way out, which a limited service must not
|
|
4
|
+
// have, so a relay container on the default bridge publishes them and passes
|
|
5
|
+
// each connection on to the service over the service's internal network.
|
|
6
|
+
// Like the egress proxy, it is a script for `node -e` in a stock Node image.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.getPortRelayEnvironment = exports.getPortRelayCommand = exports.PORT_RELAY_SCRIPT = void 0;
|
|
9
|
+
// Kept free of anything but Node's own modules.
|
|
10
|
+
exports.PORT_RELAY_SCRIPT = `
|
|
11
|
+
const net = require('net')
|
|
12
|
+
const targets = JSON.parse(process.env.HAMMERKIT_RELAY || '[]')
|
|
13
|
+
for (const target of targets) {
|
|
14
|
+
// a client that ends its side still gets the answer
|
|
15
|
+
net
|
|
16
|
+
.createServer({ allowHalfOpen: true }, (client) => {
|
|
17
|
+
const upstream = net.connect(target.targetPort, target.host)
|
|
18
|
+
client.on('error', () => upstream.destroy())
|
|
19
|
+
upstream.on('error', () => client.destroy())
|
|
20
|
+
client.pipe(upstream)
|
|
21
|
+
upstream.pipe(client)
|
|
22
|
+
})
|
|
23
|
+
.listen(target.port)
|
|
24
|
+
}
|
|
25
|
+
process.on('SIGTERM', () => process.exit(0))
|
|
26
|
+
`;
|
|
27
|
+
function getPortRelayCommand() {
|
|
28
|
+
return ['node', '-e', exports.PORT_RELAY_SCRIPT];
|
|
29
|
+
}
|
|
30
|
+
exports.getPortRelayCommand = getPortRelayCommand;
|
|
31
|
+
function getPortRelayEnvironment(targets) {
|
|
32
|
+
return { HAMMERKIT_RELAY: JSON.stringify(targets) };
|
|
33
|
+
}
|
|
34
|
+
exports.getPortRelayEnvironment = getPortRelayEnvironment;
|
|
35
|
+
//# sourceMappingURL=port-relay.js.map
|
|
@@ -4,6 +4,7 @@ exports.getStateKey = exports.getWorkItemCacheStats = exports.getWorkCacheStats
|
|
|
4
4
|
const calculate_checksum_1 = require("./calculate-checksum");
|
|
5
5
|
const path_1 = require("path");
|
|
6
6
|
const crypto_1 = require("crypto");
|
|
7
|
+
const promises_1 = require("fs/promises");
|
|
7
8
|
function isExcluded(path, excluded) {
|
|
8
9
|
return excluded.some((excludedPath) => path === excludedPath || path.startsWith(excludedPath + path_1.sep));
|
|
9
10
|
}
|
|
@@ -11,6 +12,21 @@ async function addWorkCacheStats(result, cwd, path, matcher, context, root, excl
|
|
|
11
12
|
if (isExcluded(path, excluded)) {
|
|
12
13
|
return 0;
|
|
13
14
|
}
|
|
15
|
+
// On a server the files were written by a commit or a task: a link is a source as it is,
|
|
16
|
+
// its target, and never followed. A `**` would walk `x -> /` through the server's disk.
|
|
17
|
+
if (context.hosted) {
|
|
18
|
+
const link = await (0, promises_1.lstat)(path).catch(() => undefined);
|
|
19
|
+
if (link?.isSymbolicLink()) {
|
|
20
|
+
if (!matcher(path)) {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
const checksum = (0, crypto_1.createHash)('sha1')
|
|
24
|
+
.update(`link:${await (0, promises_1.readlink)(path)}`)
|
|
25
|
+
.digest('hex');
|
|
26
|
+
result.files[(0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep)] = { lastModified: link.mtimeMs, checksum };
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
14
30
|
const exists = await context.file.exists(path);
|
|
15
31
|
if (!exists) {
|
|
16
32
|
return 0;
|
|
@@ -24,8 +24,9 @@ function getWorkTaskCacheDescription(item) {
|
|
|
24
24
|
shell: task.shell ?? undefined,
|
|
25
25
|
platform: task.type === 'container-task' ? task.image : (0, os_1.platform)(),
|
|
26
26
|
// outputs hold native binaries, and docker pulls images for the host
|
|
27
|
-
// architecture, so container tasks are keyed by it too
|
|
28
|
-
|
|
27
|
+
// architecture, so container tasks are keyed by it too; on a cluster it is the
|
|
28
|
+
// one of the nodes the task runs on
|
|
29
|
+
arch: (task.type === 'container-task' && task.arch) || process.arch,
|
|
29
30
|
generates: task.generates.map((g) => portable(g.path)).sort() ?? undefined,
|
|
30
31
|
src: task.src.map((s) => s.source).sort() ?? undefined,
|
|
31
32
|
envs: Object.keys(envs)
|