hammerkit 1.26.0 → 1.27.0-next.10
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 +62 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/backends/registry-cache-backend.js +2 -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 +24 -2
- 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 +411 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +42 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/claim-layout.js +71 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +46 -28
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +132 -5
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +27 -11
- package/dist/kubernetes/ensure-persistent-data.js +75 -24
- package/dist/kubernetes/get-service-ip.js +16 -2
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +320 -0
- package/dist/kubernetes/kept-claims.js +203 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +583 -0
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/resources.js +37 -4
- package/dist/kubernetes/restore-kubernetes-data.js +28 -7
- package/dist/kubernetes/secrets.js +7 -9
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +104 -12
- package/dist/log.js +1 -1
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +407 -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 +5 -2
- package/dist/planner/service-definition.js +5 -0
- package/dist/planner/utils/append-work-dependencies.js +5 -2
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +5 -0
- package/dist/planner/utils/append-work-task.js +20 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +43 -1
- package/dist/planner/work-runtime-kubernetes.js +370 -111
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/program.js +33 -5
- package/dist/remote/annotations.js +115 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +15 -3
- package/dist/remote/remote-work-commands.js +272 -71
- 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 +7 -0
- package/dist/schema/build-file-container-service-schema.js +8 -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 +2 -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 +603 -0
- package/dist/server/kubernetes-sandbox-storage.js +997 -0
- package/dist/server/oauth.js +12 -0
- package/dist/server/plan.js +88 -2
- 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 +48 -2
- package/dist/server/run-request.js +29 -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 +191 -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 +216 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-command.js +39 -20
- package/dist/server/server-config.js +443 -22
- package/dist/server/server.js +219 -40
- package/dist/utils/abort-on-signals.js +46 -6
- package/dist/utils/run-id.js +12 -0
- package/dist/utils/tar.js +25 -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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ensurePersistentData = 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
|
-
const tar_1 = require("tar");
|
|
6
|
+
const tar_1 = require("../utils/tar");
|
|
7
7
|
const resources_1 = require("./resources");
|
|
8
8
|
const await_running_state_1 = require("./await-running-state");
|
|
9
9
|
const sleep_1 = require("../utils/sleep");
|
|
@@ -11,6 +11,8 @@ const version_1 = require("../version");
|
|
|
11
11
|
const log_1 = require("../log");
|
|
12
12
|
const ensure_kubernetes_persistent_volume_claim_exists_1 = require("./ensure-kubernetes-persistent-volume-claim-exists");
|
|
13
13
|
const stream_1 = require("stream");
|
|
14
|
+
const job_placement_1 = require("./job-placement");
|
|
15
|
+
const assert_item_paths_1 = require("../planner/utils/assert-item-paths");
|
|
14
16
|
// A previous run that crashed between creating an upload/download pod and its
|
|
15
17
|
// `finally` cleanup leaves a pod behind under the same deterministic name. apply()
|
|
16
18
|
// would then patch that stale pod in place — and if it is stuck in a terminal
|
|
@@ -43,9 +45,12 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
43
45
|
if (persistence.volumes.length === 0) {
|
|
44
46
|
return;
|
|
45
47
|
}
|
|
48
|
+
// the claim of a sandbox is mounted by the pods of the work only: it holds no output
|
|
49
|
+
// and takes no upload
|
|
50
|
+
const own = persistence.mounts.filter((m) => !m.workspace);
|
|
46
51
|
const mounts = type === 'read'
|
|
47
|
-
?
|
|
48
|
-
:
|
|
52
|
+
? own.map((m) => m.mount)
|
|
53
|
+
: own.reduce((array, v) => {
|
|
49
54
|
if (!array.some((i) => i.name === v.mount.name)) {
|
|
50
55
|
array.push({
|
|
51
56
|
mountPath: v.uploadPath,
|
|
@@ -60,23 +65,29 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
60
65
|
// function directly, and a claim shared with another work item can be removed
|
|
61
66
|
// concurrently — without this the pod would hang Pending on "pvc not found".
|
|
62
67
|
for (const volume of persistence.volumes) {
|
|
63
|
-
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service);
|
|
68
|
+
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
|
|
64
69
|
}
|
|
65
|
-
const name = (0, resources_1.getResourceName)(service, type === 'write' ? '-upload' : '-download');
|
|
70
|
+
const name = (0, resources_1.getResourceName)(service, env, type === 'write' ? '-upload' : '-download');
|
|
66
71
|
const podSpec = {
|
|
67
72
|
kind: 'Pod',
|
|
68
73
|
apiVersion: 'v1',
|
|
69
74
|
metadata: {
|
|
70
75
|
namespace: env.namespace,
|
|
71
76
|
name,
|
|
72
|
-
labels:
|
|
73
|
-
'hammerkit.dev/id': service.id(),
|
|
74
|
-
},
|
|
77
|
+
labels: (0, resources_1.getItemLabels)(service, env),
|
|
75
78
|
annotations: {
|
|
76
79
|
'hammerkit.dev/version': (0, version_1.getVersion)(),
|
|
77
80
|
},
|
|
78
81
|
},
|
|
79
82
|
spec: {
|
|
83
|
+
// on a server the claims are bound where the pod of the task can go: the nodes
|
|
84
|
+
// of its pool
|
|
85
|
+
...(env.server
|
|
86
|
+
? {
|
|
87
|
+
automountServiceAccountToken: false,
|
|
88
|
+
...(0, job_placement_1.getPodPlacement)(env.server, 'pool' in service.data ? service.data.pool : undefined),
|
|
89
|
+
}
|
|
90
|
+
: {}),
|
|
80
91
|
containers: [
|
|
81
92
|
{
|
|
82
93
|
name: 'volume',
|
|
@@ -84,52 +95,92 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
84
95
|
command: ['sh'],
|
|
85
96
|
tty: true,
|
|
86
97
|
volumeMounts: mounts,
|
|
98
|
+
...(0, job_placement_1.getContainerSecurity)(env),
|
|
87
99
|
},
|
|
88
100
|
],
|
|
89
101
|
volumes: persistence.volumes,
|
|
90
102
|
},
|
|
91
103
|
};
|
|
92
|
-
service.status.
|
|
104
|
+
service.status.write('debug', `create pod ${name}`);
|
|
93
105
|
await removeStalePod(instance, env.namespace, name);
|
|
94
106
|
const pod = await (0, apply_1.apply)(instance, podSpec);
|
|
95
107
|
if (pod.status?.phase !== 'Running') {
|
|
96
|
-
service.status.
|
|
108
|
+
service.status.write('debug', `await running state ${name}`);
|
|
97
109
|
await (0, await_running_state_1.awaitRunningState)(instance, env, name, 'Running');
|
|
98
110
|
}
|
|
99
111
|
try {
|
|
100
112
|
await fn(name);
|
|
101
113
|
}
|
|
102
114
|
finally {
|
|
103
|
-
service.status.
|
|
115
|
+
service.status.write('debug', `delete pod ${name}`);
|
|
104
116
|
await instance.coreApi.deleteNamespacedPod({ name, namespace: env.namespace });
|
|
105
117
|
}
|
|
106
118
|
}
|
|
107
119
|
exports.getPodForPersistence = getPodForPersistence;
|
|
108
|
-
|
|
120
|
+
// Empties the claim of `work` in an upload pod: a kept claim an earlier run left, before
|
|
121
|
+
// the task runs on it or outputs are restored into it, so it holds what a new one would
|
|
122
|
+
async function clearClaim(instance, env, work, pod) {
|
|
123
|
+
const targetPath = `/dev/hammerkit/${work.id()}`;
|
|
124
|
+
work.status.write('info', `clear what an earlier run left in ${targetPath}`);
|
|
125
|
+
let failure = null;
|
|
126
|
+
const res = await instance.exec.exec(env.namespace, pod, 'volume', ['find', targetPath, '-mindepth', '1', '-maxdepth', '1', '-exec', 'rm', '-rf', '{}', '+'], process.stdout, process.stderr, null, false, (status) => {
|
|
127
|
+
if (status.status !== 'Success') {
|
|
128
|
+
failure = status.message ?? status.reason ?? 'unknown';
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
await new Promise((resolve, reject) => {
|
|
132
|
+
res.on('error', reject);
|
|
133
|
+
res.on('close', () => failure ? reject(new Error(`unable to clear ${targetPath} in ${pod}: ${failure}`)) : resolve());
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
exports.clearClaim = clearClaim;
|
|
137
|
+
// The archive that uploads a source into the claim: its file or directory, at its place in
|
|
138
|
+
// the claim (see getClaimSubPath) instead of under its own name
|
|
139
|
+
function packSource(source) {
|
|
140
|
+
const fileItem = (0, path_1.basename)(source.localPath);
|
|
141
|
+
return stream_1.Readable.from((0, tar_1.packArchive)({
|
|
142
|
+
cwd: (0, path_1.dirname)(source.localPath),
|
|
143
|
+
filter(path) {
|
|
144
|
+
return source.matcher(path, source.localPath);
|
|
145
|
+
},
|
|
146
|
+
onWriteEntry(entry) {
|
|
147
|
+
entry.path = source.subPath + entry.path.slice(fileItem.length);
|
|
148
|
+
// the second of two hard-linked files links to the first by its path in the archive
|
|
149
|
+
if (entry.type === 'Link' && entry.linkpath?.startsWith(fileItem)) {
|
|
150
|
+
entry.linkpath = source.subPath + entry.linkpath.slice(fileItem.length);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}, [fileItem]));
|
|
154
|
+
}
|
|
155
|
+
exports.packSource = packSource;
|
|
156
|
+
async function ensurePersistentData(instance, env, environment, service, persistence,
|
|
157
|
+
// the kept claim of the work holds what an earlier run left, which goes first
|
|
158
|
+
options = {}) {
|
|
159
|
+
// what a run of a server uploads comes from its checkout and nowhere else
|
|
160
|
+
if (environment.hosted) {
|
|
161
|
+
await (0, assert_item_paths_1.assertItemPathsWithinRoot)(service, environment.cwd);
|
|
162
|
+
}
|
|
109
163
|
// Dedupe of already-uploaded state (skip the upload when the persisted state
|
|
110
164
|
// key still matches) needs an in-cluster state marker per volume — deferred
|
|
111
165
|
// post-1.6.0, tracked in FOLLOWUPS.md alongside the volumes.ts stateKey/matcher.
|
|
112
166
|
for (const volume of persistence.volumes) {
|
|
113
|
-
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service);
|
|
167
|
+
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
|
|
114
168
|
}
|
|
115
|
-
if (persistence.sources.length === 0) {
|
|
169
|
+
if (persistence.sources.length === 0 && !options.reset) {
|
|
116
170
|
return;
|
|
117
171
|
}
|
|
118
|
-
service.status.
|
|
172
|
+
service.status.write('debug', 'start upload container');
|
|
119
173
|
await getPodForPersistence(instance, env, service, persistence, 'write', async (name) => {
|
|
174
|
+
if (options.reset) {
|
|
175
|
+
await clearClaim(instance, env, service, name);
|
|
176
|
+
}
|
|
120
177
|
for (const source of persistence.sources) {
|
|
121
178
|
// skip sources that don't exist on disk — nothing to upload
|
|
122
179
|
if (!(await environment.file.exists(source.localPath))) {
|
|
123
180
|
continue;
|
|
124
181
|
}
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
cwd: (0, path_1.dirname)(source.localPath),
|
|
128
|
-
filter(path) {
|
|
129
|
-
return source.matcher(path, source.localPath);
|
|
130
|
-
},
|
|
131
|
-
}, [fileItem]));
|
|
132
|
-
service.status.console('stdout', `upload ${source.localPath}`);
|
|
182
|
+
const data = packSource(source);
|
|
183
|
+
service.status.write('info', `upload ${source.localPath}`);
|
|
133
184
|
const targetPath = `/dev/hammerkit/${service.id()}`;
|
|
134
185
|
const res = await instance.exec.exec(env.namespace, name, 'volume', ['tar', 'xf', '-', '-C', targetPath], process.stdout, process.stderr, data, false, (status) => {
|
|
135
186
|
service.status.write('debug', `upload ${source.localPath} to ${name}: ${status}`);
|
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getServiceIp = void 0;
|
|
4
4
|
const resources_1 = require("./resources");
|
|
5
|
+
// The address a task reaches the service on: its clusterIP, or for a headless
|
|
6
|
+
// Service (a service without ports) the address of its ready pod.
|
|
5
7
|
async function getServiceIp(instance, env, workService) {
|
|
6
|
-
const name = (0, resources_1.getResourceName)(workService);
|
|
8
|
+
const name = (0, resources_1.getResourceName)(workService, env);
|
|
7
9
|
const svc = await instance.coreApi.readNamespacedService({ name, namespace: env.namespace });
|
|
8
|
-
|
|
10
|
+
const clusterIP = svc.spec?.clusterIP ?? null;
|
|
11
|
+
if (clusterIP !== 'None') {
|
|
12
|
+
return clusterIP;
|
|
13
|
+
}
|
|
14
|
+
const pods = await instance.coreApi.listNamespacedPod({
|
|
15
|
+
namespace: env.namespace,
|
|
16
|
+
labelSelector: (0, resources_1.getItemSelector)(workService, env),
|
|
17
|
+
});
|
|
18
|
+
const ready = pods.items.find((pod) => !pod.metadata?.deletionTimestamp &&
|
|
19
|
+
pod.status?.podIP &&
|
|
20
|
+
pod.status.conditions?.some((c) => c.type === 'Ready' && c.status === 'True'));
|
|
21
|
+
const running = ready ?? pods.items.find((pod) => !pod.metadata?.deletionTimestamp && pod.status?.podIP);
|
|
22
|
+
return running?.status?.podIP ?? null;
|
|
9
23
|
}
|
|
10
24
|
exports.getServiceIp = getServiceIp;
|
|
11
25
|
//# sourceMappingURL=get-service-ip.js.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getContainerSecurity = exports.getRunLabels = exports.getRunLabelValue = exports.RUN_LABEL = exports.getPodPlacement = exports.placeOnPool = exports.getNodeArch = exports.runInToolImage = exports.getLocalTaskPool = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
/**
|
|
6
|
+
* The pool a local task runs on: the first that offers its platform. A pool says
|
|
7
|
+
* `linux` with `amd64` or `arm64`, so a task that names another platform has none.
|
|
8
|
+
*/
|
|
9
|
+
function getLocalTaskPool(server, name, platform) {
|
|
10
|
+
if (platform?.os === 'macos' || platform?.os === 'win') {
|
|
11
|
+
throw new Error(`task ${name}: runs on ${platform.os === 'win' ? 'Windows' : 'macOS'}, this server runs tasks on Linux nodes`);
|
|
12
|
+
}
|
|
13
|
+
if (platform?.arch === 'arm') {
|
|
14
|
+
throw new Error(`task ${name}: runs on arm, the pools of this server offer amd64 and arm64`);
|
|
15
|
+
}
|
|
16
|
+
const pools = server.pools.filter((pool) => !platform?.arch || pool.platform.arch === platform.arch);
|
|
17
|
+
const pool = pools.find((candidate) => candidate.toolImage);
|
|
18
|
+
if (!pool) {
|
|
19
|
+
throw new Error(`task ${name}: a local task runs in the toolImage of a pool, and no pool${platform?.arch ? ` for ${platform.arch}` : ''} of this server has one`);
|
|
20
|
+
}
|
|
21
|
+
return pool;
|
|
22
|
+
}
|
|
23
|
+
exports.getLocalTaskPool = getLocalTaskPool;
|
|
24
|
+
/**
|
|
25
|
+
* A local task on a cluster: a container of the pool's tool image, which runs its
|
|
26
|
+
* commands in the same directories as a container task would.
|
|
27
|
+
*/
|
|
28
|
+
function runInToolImage(task, server, platform) {
|
|
29
|
+
const pool = getLocalTaskPool(server, task.name, platform);
|
|
30
|
+
return {
|
|
31
|
+
...task,
|
|
32
|
+
type: 'container-task',
|
|
33
|
+
image: pool.toolImage,
|
|
34
|
+
user: null,
|
|
35
|
+
mounts: [],
|
|
36
|
+
volumeSize: null,
|
|
37
|
+
pool: pool.name,
|
|
38
|
+
arch: getNodeArch(pool),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
exports.runInToolImage = runInToolImage;
|
|
42
|
+
// The architecture of a pool's nodes as process.arch names it, so a cache entry made on
|
|
43
|
+
// amd64 nodes is never restored on arm64 ones
|
|
44
|
+
function getNodeArch(pool) {
|
|
45
|
+
return pool.platform.arch === 'amd64' ? 'x64' : pool.platform.arch;
|
|
46
|
+
}
|
|
47
|
+
exports.getNodeArch = getNodeArch;
|
|
48
|
+
// A task on a server's cluster: a container task runs on the first pool
|
|
49
|
+
function placeOnPool(task, server) {
|
|
50
|
+
return task.arch ? task : { ...task, arch: getNodeArch(server.pools[0]) };
|
|
51
|
+
}
|
|
52
|
+
exports.placeOnPool = placeOnPool;
|
|
53
|
+
// Where the pod of a task goes: its pool's nodes, the first pool unless a local task
|
|
54
|
+
// chose another.
|
|
55
|
+
function getPodPlacement(server, pool) {
|
|
56
|
+
const chosen = server.pools.find((candidate) => candidate.name === pool) ?? server.pools[0];
|
|
57
|
+
return {
|
|
58
|
+
nodeSelector: {
|
|
59
|
+
'kubernetes.io/os': chosen.platform.os,
|
|
60
|
+
'kubernetes.io/arch': chosen.platform.arch,
|
|
61
|
+
...chosen.nodeSelector,
|
|
62
|
+
},
|
|
63
|
+
tolerations: chosen.tolerations,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
exports.getPodPlacement = getPodPlacement;
|
|
67
|
+
// On a server everything made for a run carries its id, so the server removes all of
|
|
68
|
+
// it when the run ends, and a NetworkPolicy can select the pods of a run.
|
|
69
|
+
exports.RUN_LABEL = 'hammerkit.dev/run';
|
|
70
|
+
/**
|
|
71
|
+
* The value of the run label. A run id is base64url and may end in `-` or `_`, which a
|
|
72
|
+
* label value can't, so the label carries a hex digest of it.
|
|
73
|
+
*/
|
|
74
|
+
function getRunLabelValue(run) {
|
|
75
|
+
return (0, crypto_1.createHash)('sha256').update(run).digest('hex').slice(0, 40);
|
|
76
|
+
}
|
|
77
|
+
exports.getRunLabelValue = getRunLabelValue;
|
|
78
|
+
function getRunLabels(kubernetes) {
|
|
79
|
+
return kubernetes.server ? { [exports.RUN_LABEL]: getRunLabelValue(kubernetes.server.run) } : {};
|
|
80
|
+
}
|
|
81
|
+
exports.getRunLabels = getRunLabels;
|
|
82
|
+
/**
|
|
83
|
+
* What a container of a run may do on a server, where it shares nodes with the pods of
|
|
84
|
+
* other callers: no privilege escalation (setuid binaries such as sudo), no raw sockets
|
|
85
|
+
* (spoofing on a bridge network) and the runtime's seccomp profile.
|
|
86
|
+
*/
|
|
87
|
+
function getContainerSecurity(kubernetes) {
|
|
88
|
+
return kubernetes.server
|
|
89
|
+
? {
|
|
90
|
+
securityContext: {
|
|
91
|
+
allowPrivilegeEscalation: false,
|
|
92
|
+
capabilities: { drop: ['NET_RAW'] },
|
|
93
|
+
seccompProfile: { type: 'RuntimeDefault' },
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
: {};
|
|
97
|
+
}
|
|
98
|
+
exports.getContainerSecurity = getContainerSecurity;
|
|
99
|
+
//# sourceMappingURL=job-placement.js.map
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
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
|
+
const work_secret_1 = require("../planner/work-secret");
|
|
5
|
+
const command_secret_provider_1 = require("../secrets/command-secret-provider");
|
|
6
|
+
const task_arguments_1 = require("../planner/utils/task-arguments");
|
|
7
|
+
const version_1 = require("../version");
|
|
8
|
+
const apply_1 = require("./apply");
|
|
9
|
+
const resources_1 = require("./resources");
|
|
10
|
+
const job_placement_1 = require("./job-placement");
|
|
11
|
+
const work_network_1 = require("../planner/work-network");
|
|
12
|
+
// Where the values are in the pod (an in-memory volume), and where the init container
|
|
13
|
+
// of a secret finds the credential of its account
|
|
14
|
+
exports.SECRET_VALUES_PATH = '/var/run/hammerkit/secrets';
|
|
15
|
+
exports.ACCOUNT_PATH = '/var/run/hammerkit/account';
|
|
16
|
+
const VALUES_VOLUME = 'hammerkit-secret-values';
|
|
17
|
+
// a token is made for each Job right before it is created; it has to outlast the
|
|
18
|
+
// time its pod waits for a node
|
|
19
|
+
const TOKEN_SECONDS = 3600;
|
|
20
|
+
/**
|
|
21
|
+
* Which secrets of a task the Job reads itself. On a server's cluster the secrets of
|
|
22
|
+
* a provider are read in the Job, by an init container per secret with only its
|
|
23
|
+
* account's credential, so the server holds no secret manager credential and sees no
|
|
24
|
+
* value. The others (`env:`, the server's own variables) are handed over in a Secret
|
|
25
|
+
* of the task, as without a server.
|
|
26
|
+
*/
|
|
27
|
+
function splitJobSecrets(secrets, server) {
|
|
28
|
+
if (!server) {
|
|
29
|
+
return { delivered: secrets, inJob: [] };
|
|
30
|
+
}
|
|
31
|
+
const delivered = [];
|
|
32
|
+
const inJob = [];
|
|
33
|
+
for (const secret of secrets) {
|
|
34
|
+
if (secret.source.type === 'provider') {
|
|
35
|
+
inJob.push({ index: inJob.length, secret, source: secret.source });
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
delivered.push(secret);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { delivered, inJob };
|
|
42
|
+
}
|
|
43
|
+
exports.splitJobSecrets = splitJobSecrets;
|
|
44
|
+
/**
|
|
45
|
+
* The init containers that read secrets share the pod, and so the NetworkPolicy, of
|
|
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.
|
|
50
|
+
*/
|
|
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`);
|
|
60
|
+
}
|
|
61
|
+
return [...new Set(secrets.flatMap((secret) => server.providers[secret.source.provider].allow ?? []))].sort();
|
|
62
|
+
}
|
|
63
|
+
exports.getSecretHosts = getSecretHosts;
|
|
64
|
+
// The accounts of the secrets, in the order they are first read
|
|
65
|
+
function accountsOf(secrets) {
|
|
66
|
+
return [...new Set(secrets.map((s) => s.source.account))];
|
|
67
|
+
}
|
|
68
|
+
function getCredentialsName(item, kubernetes) {
|
|
69
|
+
return (0, resources_1.getResourceName)(item, kubernetes, '-credentials');
|
|
70
|
+
}
|
|
71
|
+
exports.getCredentialsName = getCredentialsName;
|
|
72
|
+
// The keys of the credentials Secret: `<account>.token` and `<account>.file.<key>`
|
|
73
|
+
function tokenKey(account) {
|
|
74
|
+
return `${account}.token`;
|
|
75
|
+
}
|
|
76
|
+
function fileKey(account, key) {
|
|
77
|
+
return `${account}.file.${key}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The credentials the init containers need, in a Secret of the task that only they
|
|
81
|
+
* mount: per account, a short-lived token of its ServiceAccount and the files of its
|
|
82
|
+
* Secret, both from the server's namespace. Removed with the task's other Secret.
|
|
83
|
+
*/
|
|
84
|
+
async function ensureJobCredentials(instance, kubernetes, item, secrets, admitted) {
|
|
85
|
+
const server = kubernetes.server;
|
|
86
|
+
const files = {};
|
|
87
|
+
if (!server || secrets.length === 0) {
|
|
88
|
+
return files;
|
|
89
|
+
}
|
|
90
|
+
assertJobAccounts(secrets, server, admitted);
|
|
91
|
+
const data = {};
|
|
92
|
+
for (const account of accountsOf(secrets)) {
|
|
93
|
+
const credentials = server.accounts[account];
|
|
94
|
+
files[account] = [];
|
|
95
|
+
if (credentials.serviceAccount) {
|
|
96
|
+
const response = await instance.coreApi.createNamespacedServiceAccountToken({
|
|
97
|
+
name: credentials.serviceAccount,
|
|
98
|
+
namespace: server.namespace,
|
|
99
|
+
body: {
|
|
100
|
+
spec: {
|
|
101
|
+
audiences: credentials.audience ? [credentials.audience] : [],
|
|
102
|
+
expirationSeconds: TOKEN_SECONDS,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const token = response.status?.token;
|
|
107
|
+
if (!token) {
|
|
108
|
+
throw new Error(`no token was issued for the ServiceAccount ${credentials.serviceAccount} of ${account}`);
|
|
109
|
+
}
|
|
110
|
+
data[tokenKey(account)] = Buffer.from(token, 'utf8').toString('base64');
|
|
111
|
+
files[account].push({ key: tokenKey(account), path: 'token' });
|
|
112
|
+
}
|
|
113
|
+
if (credentials.secret) {
|
|
114
|
+
let content;
|
|
115
|
+
try {
|
|
116
|
+
const secret = await instance.coreApi.readNamespacedSecret({
|
|
117
|
+
name: credentials.secret,
|
|
118
|
+
namespace: server.namespace,
|
|
119
|
+
});
|
|
120
|
+
content = secret.data ?? {};
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
if ((0, apply_1.statusCodeOf)(e) === 404) {
|
|
124
|
+
throw new Error(`the Secret ${credentials.secret} of ${account} is not in ${server.namespace}`);
|
|
125
|
+
}
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
for (const [key, value] of Object.entries(content)) {
|
|
129
|
+
data[fileKey(account, key)] = value;
|
|
130
|
+
files[account].push({ key: fileKey(account, key), path: key });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const spec = {
|
|
135
|
+
kind: 'Secret',
|
|
136
|
+
apiVersion: 'v1',
|
|
137
|
+
metadata: {
|
|
138
|
+
namespace: kubernetes.namespace,
|
|
139
|
+
name: getCredentialsName(item, kubernetes),
|
|
140
|
+
annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
|
|
141
|
+
labels: (0, resources_1.getItemLabels)(item, kubernetes),
|
|
142
|
+
},
|
|
143
|
+
type: 'Opaque',
|
|
144
|
+
data,
|
|
145
|
+
};
|
|
146
|
+
await (0, apply_1.apply)(instance, spec);
|
|
147
|
+
return files;
|
|
148
|
+
}
|
|
149
|
+
exports.ensureJobCredentials = ensureJobCredentials;
|
|
150
|
+
/**
|
|
151
|
+
* Refuses a secret of an account the run was not admitted for, or of one without a
|
|
152
|
+
* credential on the cluster: its command would run as whatever the node or the image
|
|
153
|
+
* of the provider is, which every account shares. Before anything is minted or copied.
|
|
154
|
+
*/
|
|
155
|
+
function assertJobAccounts(secrets, server, admitted) {
|
|
156
|
+
for (const { secret, source } of secrets) {
|
|
157
|
+
const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
|
|
158
|
+
if (admitted && !admitted.includes(source.account)) {
|
|
159
|
+
throw new Error(`${label}: the run was not admitted for the account ${source.account}`);
|
|
160
|
+
}
|
|
161
|
+
if (!server.accounts[source.account]) {
|
|
162
|
+
throw new Error(`${label}: the account ${source.account} has no credential on this cluster (accounts.${source.account}.kubernetes)`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
exports.assertJobAccounts = assertJobAccounts;
|
|
167
|
+
async function removeJobCredentials(instance, kubernetes, item) {
|
|
168
|
+
if (!kubernetes.server) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await instance.coreApi.deleteNamespacedSecret({
|
|
173
|
+
name: getCredentialsName(item, kubernetes),
|
|
174
|
+
namespace: kubernetes.namespace,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
exports.removeJobCredentials = removeJobCredentials;
|
|
184
|
+
// A string as one word of a POSIX shell
|
|
185
|
+
function quote(value) {
|
|
186
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* What the pod of a task needs to read its secrets itself. Each secret gets an init
|
|
190
|
+
* container in the image of its provider, which runs the provider's command (no
|
|
191
|
+
* shell between the reference and the command) with the provider's and its
|
|
192
|
+
* account's variables and the account's credential under /var/run/hammerkit/account,
|
|
193
|
+
* and writes the value verbatim into a volume in memory. The task's container mounts
|
|
194
|
+
* that volume only: a `path` secret is the file itself, an `env` secret is exported
|
|
195
|
+
* from it, and the task's output is masked before it leaves the pod, since the
|
|
196
|
+
* server, which masks everything else, never sees these values.
|
|
197
|
+
*/
|
|
198
|
+
function getJobSecretSpec(item, secrets, server, credentials, environment,
|
|
199
|
+
// under a limited network, what sends their requests through the egress proxy
|
|
200
|
+
proxyEnv = []) {
|
|
201
|
+
if (!server || secrets.length === 0) {
|
|
202
|
+
return {
|
|
203
|
+
initContainers: [],
|
|
204
|
+
volumes: [],
|
|
205
|
+
mounts: [],
|
|
206
|
+
command: (shell, cmd) => ({ command: [shell, '-c'], args: [cmd], env: [] }),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const volumes = [{ name: VALUES_VOLUME, emptyDir: { medium: 'Memory' } }];
|
|
210
|
+
// a volume per account with only its keys, mounted by the init containers of its secrets
|
|
211
|
+
const accountVolume = new Map();
|
|
212
|
+
accountsOf(secrets).forEach((account, index) => {
|
|
213
|
+
const items = credentials[account] ?? [];
|
|
214
|
+
if (items.length === 0) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const name = `hammerkit-account-${index}`;
|
|
218
|
+
accountVolume.set(account, name);
|
|
219
|
+
volumes.push({ name, secret: { secretName: getCredentialsName(item, { server }), items, defaultMode: 0o444 } });
|
|
220
|
+
});
|
|
221
|
+
const initContainers = secrets.map(({ index, secret, source }) => {
|
|
222
|
+
const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
|
|
223
|
+
// what the run was admitted for, as when the server reads a secret itself
|
|
224
|
+
const allowed = environment.hosted?.accounts;
|
|
225
|
+
if (allowed && !allowed.includes(source.account)) {
|
|
226
|
+
throw new Error(`${label}: the run was not admitted for the account ${source.account}`);
|
|
227
|
+
}
|
|
228
|
+
const image = server.providers[source.provider]?.image;
|
|
229
|
+
if (!image) {
|
|
230
|
+
throw new Error(`${label}: this server names no image for the provider ${source.provider}`);
|
|
231
|
+
}
|
|
232
|
+
const { command, args, env } = (0, command_secret_provider_1.getProviderInvocation)(source.binding, source.ref, environment);
|
|
233
|
+
const volume = accountVolume.get(source.account);
|
|
234
|
+
return {
|
|
235
|
+
name: `secret-${index}`,
|
|
236
|
+
image,
|
|
237
|
+
// the value as the command prints it, written once it is complete
|
|
238
|
+
command: [
|
|
239
|
+
'sh',
|
|
240
|
+
'-c',
|
|
241
|
+
'umask 022 && "$@" > "$HAMMERKIT_VALUE.part" && mv "$HAMMERKIT_VALUE.part" "$HAMMERKIT_VALUE"',
|
|
242
|
+
],
|
|
243
|
+
// Kubernetes would expand a `$(NAME)` of the container's env in them, the proxy's token
|
|
244
|
+
// among them: the reference is text, as on any other backend
|
|
245
|
+
args: ['sh', command, ...args].map(task_arguments_1.escapeKubernetesText),
|
|
246
|
+
env: [
|
|
247
|
+
...proxyEnv,
|
|
248
|
+
...Object.entries(env).map(([name, value]) => ({ name, value })),
|
|
249
|
+
{ name: 'HAMMERKIT_VALUE', value: `${exports.SECRET_VALUES_PATH}/secret-${index}` },
|
|
250
|
+
],
|
|
251
|
+
volumeMounts: [
|
|
252
|
+
{ name: VALUES_VOLUME, mountPath: exports.SECRET_VALUES_PATH },
|
|
253
|
+
...(volume ? [{ name: volume, mountPath: exports.ACCOUNT_PATH, readOnly: true }] : []),
|
|
254
|
+
],
|
|
255
|
+
...(0, job_placement_1.getContainerSecurity)({ server }),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
const mounts = [{ name: VALUES_VOLUME, mountPath: exports.SECRET_VALUES_PATH }];
|
|
259
|
+
const exportLines = [];
|
|
260
|
+
for (const { index, secret } of secrets) {
|
|
261
|
+
if (secret.target.type === 'file') {
|
|
262
|
+
mounts.push({ name: VALUES_VOLUME, mountPath: secret.target.path, subPath: `secret-${index}`, readOnly: true });
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
// verbatim: the x keeps the trailing newlines command substitution would drop
|
|
266
|
+
const name = secret.target.name;
|
|
267
|
+
exportLines.push(`${name}="$(cat "$HAMMERKIT_SECRETS/secret-${index}"; printf x)" && ${name}="\${${name}%x}" && export ${name}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const script = [
|
|
271
|
+
...exportLines,
|
|
272
|
+
`command -v awk >/dev/null 2>&1 || { echo 'hammerkit: the image has no awk, which masks the secrets of this task in its output' >&2; exit 127; }`,
|
|
273
|
+
`{ "$HAMMERKIT_SHELL" -c "$HAMMERKIT_CMD"; echo $? > "$HAMMERKIT_SECRETS/.status"; } 2>&1 | awk -v dir="$HAMMERKIT_SECRETS" -v count=${secrets.length} ${quote(MASK_PROGRAM)}`,
|
|
274
|
+
`exit "$(cat "$HAMMERKIT_SECRETS/.status" 2>/dev/null || echo 1)"`,
|
|
275
|
+
].join('\n');
|
|
276
|
+
return {
|
|
277
|
+
initContainers,
|
|
278
|
+
volumes,
|
|
279
|
+
mounts,
|
|
280
|
+
command: (shell, cmd) => ({
|
|
281
|
+
command: [shell, '-c'],
|
|
282
|
+
args: [script],
|
|
283
|
+
env: [
|
|
284
|
+
{ name: 'HAMMERKIT_SECRETS', value: exports.SECRET_VALUES_PATH },
|
|
285
|
+
{ name: 'HAMMERKIT_SHELL', value: shell },
|
|
286
|
+
{ name: 'HAMMERKIT_CMD', value: cmd },
|
|
287
|
+
],
|
|
288
|
+
}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
exports.getJobSecretSpec = getJobSecretSpec;
|
|
292
|
+
// Every line of every value (trimmed, 4 characters or more, like the server's own
|
|
293
|
+
// masking) is replaced by *** in each line of output. index() and not a regular
|
|
294
|
+
// expression, so a value is matched as written.
|
|
295
|
+
const MASK_PROGRAM = `
|
|
296
|
+
function mask(s, v, out, i) {
|
|
297
|
+
out = ""
|
|
298
|
+
while ((i = index(s, v)) > 0) { out = out substr(s, 1, i - 1) "***"; s = substr(s, i + length(v)) }
|
|
299
|
+
return out s
|
|
300
|
+
}
|
|
301
|
+
BEGIN {
|
|
302
|
+
n = 0
|
|
303
|
+
for (k = 0; k < count; k++) {
|
|
304
|
+
f = dir "/secret-" k
|
|
305
|
+
while ((getline line < f) > 0) {
|
|
306
|
+
gsub(/^[ \\t\\r]+|[ \\t\\r]+$/, "", line)
|
|
307
|
+
if (length(line) >= 4) values[n++] = line
|
|
308
|
+
}
|
|
309
|
+
close(f)
|
|
310
|
+
}
|
|
311
|
+
# longest first, so a value that holds another is masked as a whole
|
|
312
|
+
for (a = 0; a < n; a++) for (b = a + 1; b < n; b++) if (length(values[b]) > length(values[a])) { t = values[a]; values[a] = values[b]; values[b] = t }
|
|
313
|
+
}
|
|
314
|
+
{
|
|
315
|
+
out = $0
|
|
316
|
+
for (k = 0; k < n; k++) out = mask(out, values[k])
|
|
317
|
+
print out
|
|
318
|
+
fflush()
|
|
319
|
+
}`;
|
|
320
|
+
//# sourceMappingURL=job-secrets.js.map
|