hammerkit 1.27.0-next.2 → 1.27.0-next.21
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 +27 -0
- package/dist/cache/backends/registry-cache-backend.js +2 -2
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +1 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/event-cache.js +20 -2
- package/dist/executer/execute-work-task.js +5 -3
- package/dist/executer/execution-steps.js +25 -4
- package/dist/executer/reset-work-tree.js +4 -0
- package/dist/executer/scheduler/enqueue-next.js +7 -0
- package/dist/executer/scheduler/state-key.js +42 -5
- package/dist/harness/connect.js +20 -6
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +63 -114
- package/dist/harness/opencode.js +158 -50
- package/dist/harness/pi.js +29 -0
- package/dist/harness/sandbox-client.js +207 -65
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +27 -16
- package/dist/kubernetes/claim-layout.js +71 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
- package/dist/kubernetes/ensure-persistent-data.js +30 -20
- package/dist/kubernetes/get-service-ip.js +2 -2
- package/dist/kubernetes/job-secrets.js +33 -19
- package/dist/kubernetes/kept-claims.js +22 -10
- package/dist/kubernetes/network-policy.js +85 -27
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/resources.js +25 -3
- package/dist/kubernetes/restore-kubernetes-data.js +9 -3
- package/dist/kubernetes/sandbox-runner.js +626 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/volumes.js +6 -3
- package/dist/network/egress-proxy.js +45 -6
- package/dist/optimizer/calculate-checksum.js +3 -2
- package/dist/optimizer/check-key.js +94 -0
- package/dist/optimizer/check-keys.js +64 -0
- package/dist/optimizer/get-work-cache-stats.js +17 -3
- package/dist/optimizer/work-task-cache-description.js +51 -1
- package/dist/planner/utils/append-work-dependencies.js +10 -5
- package/dist/planner/utils/append-work-service.js +1 -0
- package/dist/planner/utils/append-work-task.js +15 -2
- package/dist/planner/utils/get-work-runtime.js +6 -1
- package/dist/planner/utils/normalize-path.js +19 -1
- package/dist/planner/utils/parse-work-mount.js +7 -1
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/validate.js +22 -0
- package/dist/planner/work-network.js +3 -1
- package/dist/planner/work-runtime-aggregate.js +32 -0
- package/dist/planner/work-runtime-docker.js +2 -1
- package/dist/planner/work-runtime-kubernetes.js +97 -48
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +35 -13
- package/dist/remote/annotations.js +13 -1
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +64 -3
- package/dist/remote/remote-commands.js +41 -9
- package/dist/remote/remote-work-commands.js +246 -1
- package/dist/remote/server-client.js +15 -2
- package/dist/remote/token-source.js +64 -26
- package/dist/run-program.js +9 -0
- package/dist/schema/build-file-base-task-schema.js +5 -1
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-include-schema.js +10 -4
- package/dist/schema/reference-parser.js +9 -1
- package/dist/schema/schema-parser.js +36 -4
- package/dist/schema/work-scope-parser.js +33 -10
- package/dist/secrets/github-app-token-command.js +3 -1
- package/dist/server/accounts-api.js +3 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/definitions-api.js +4 -2
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +56 -5
- package/dist/server/kubernetes-backend.js +303 -64
- package/dist/server/kubernetes-sandbox-storage.js +48 -3
- package/dist/server/oauth.js +12 -1
- package/dist/server/plan.js +126 -62
- package/dist/server/policy.js +32 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +50 -19
- package/dist/server/run-request.js +64 -3
- package/dist/server/run-worker.js +146 -10
- package/dist/server/runs.js +347 -27
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes-api.js +10 -4
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-command.js +39 -20
- package/dist/server/server-config.js +213 -14
- package/dist/server/server.js +268 -15
- package/dist/server/service-runs.js +82 -0
- package/dist/utils/abort-on-signals.js +46 -6
- package/dist/utils/tar.js +36 -0
- package/package.json +1 -1
package/dist/server/executor.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.HostExecutor = exports.volumeScope = exports.cacheNamespace = exports.defaultWorker = void 0;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
5
|
const crypto_1 = require("crypto");
|
|
6
|
+
const checks_1 = require("./checks");
|
|
6
7
|
const fs_1 = require("fs");
|
|
7
8
|
const promises_1 = require("fs/promises");
|
|
8
9
|
const path_1 = require("path");
|
|
@@ -35,9 +36,18 @@ exports.defaultWorker = defaultWorker;
|
|
|
35
36
|
// share one, those of an upload or a sandbox only their owner's. Somebody who can only
|
|
36
37
|
// upload, or an agent on a sandbox, can not leave an entry for a run from a reviewed
|
|
37
38
|
// repository to find.
|
|
39
|
+
//
|
|
40
|
+
// A check on a sandbox runs on a copy of the files a commit of them would hold, so it is a
|
|
41
|
+
// run of the repository like a ref run of that commit, and shares its cache: what its
|
|
42
|
+
// owner may write in the sandbox is not part of it. It leaves entries a run from main can
|
|
43
|
+
// find, keyed by the files it ran on, as a ref run of a branch does: so only a caller who
|
|
44
|
+
// may push a branch of the repository and run from it may check (server.ts).
|
|
38
45
|
function cacheNamespace(run) {
|
|
39
|
-
const
|
|
40
|
-
|
|
46
|
+
const repository = run.source.type === 'ref' || (run.source.type === 'sandbox' && run.source.check);
|
|
47
|
+
const key = repository
|
|
48
|
+
? `ref\0${run.source.repo}`
|
|
49
|
+
: `${run.source.type}\0${run.owner.iss}\0${run.owner.sub}`;
|
|
50
|
+
return `${repository ? 'ref' : run.source.type}-${(0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 24)}`;
|
|
41
51
|
}
|
|
42
52
|
exports.cacheNamespace = cacheNamespace;
|
|
43
53
|
// The docker volumes of a run's services are its caller's, within the cache's source trust:
|
|
@@ -100,7 +110,7 @@ class HostExecutor {
|
|
|
100
110
|
if (extra.length > 0) {
|
|
101
111
|
throw new Error(`the run would use ${extra.join(', ')}, which it was not admitted for`);
|
|
102
112
|
}
|
|
103
|
-
const refused = (0, run_plan_1.refuseArguments)(run, planned.plan);
|
|
113
|
+
const refused = (0, run_plan_1.refuseArguments)(run, planned.plan, policy);
|
|
104
114
|
if (refused) {
|
|
105
115
|
throw new Error(refused);
|
|
106
116
|
}
|
|
@@ -121,13 +131,42 @@ class HostExecutor {
|
|
|
121
131
|
let claim;
|
|
122
132
|
// the files of the sandbox the tasks work on
|
|
123
133
|
let work;
|
|
124
|
-
if (source.type === '
|
|
134
|
+
if (source.type === 'sandbox' && source.check) {
|
|
135
|
+
// a check works on the files a push of the sandbox commits, copied out of it: never
|
|
136
|
+
// on the sandbox, whose ignored files (node_modules, outputs) its owner writes
|
|
137
|
+
const { registry, network } = sandboxes;
|
|
138
|
+
cwd = (0, path_1.join)(root, 'source');
|
|
139
|
+
try {
|
|
140
|
+
await registry.exportTree(source.id, source.tree, cwd, abortCtrl.signal);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
throw new Error(`the files of the sandbox ${source.id} could not be copied for the check, submit it again: ${e instanceof Error ? e.message : String(e)}`);
|
|
144
|
+
}
|
|
145
|
+
// a repository of its own, as a checkout of the commit is: the root of the project,
|
|
146
|
+
// which the paths of the tasks' keys are relative to, is the one of git
|
|
147
|
+
await (0, git_checkout_1.git)(['init', '--quiet', cwd], {}, abortCtrl.signal, `the check of sandbox ${source.id}`, 'init');
|
|
148
|
+
context.emit({
|
|
149
|
+
type: 'log',
|
|
150
|
+
task: 'hammerkit',
|
|
151
|
+
stream: 'stdout',
|
|
152
|
+
text: `check of sandbox ${source.id} of ${source.repo}: files ${source.tree}, fetched at ${source.commit}`,
|
|
153
|
+
});
|
|
154
|
+
await this.writeBundle(cwd, bundle.files);
|
|
155
|
+
input = {
|
|
156
|
+
entry: (0, path_1.join)(cwd, version.entry),
|
|
157
|
+
...(planning.policy.network ? { network: (0, work_network_1.toNetworkSchema)(planning.policy.network) } : {}),
|
|
158
|
+
sandboxNetwork: network,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
else if (source.type === 'ref') {
|
|
125
162
|
cwd = (0, path_1.join)(root, 'source');
|
|
126
163
|
const { repo, ref, commit: pinned } = source;
|
|
127
164
|
const credentials = await repos.credentials(repo);
|
|
128
165
|
let commit;
|
|
129
166
|
try {
|
|
130
|
-
commit
|
|
167
|
+
// a woken service run starts the commit it started first, where the ref is now or not
|
|
168
|
+
const fetched = run.wakes ? pinned : ref;
|
|
169
|
+
commit = await (0, git_checkout_1.checkoutRef)(repos.url(repo), fetched, cwd, credentials.env, abortCtrl.signal, pinned);
|
|
131
170
|
}
|
|
132
171
|
catch (e) {
|
|
133
172
|
throw repos.scrub(e, credentials.values);
|
|
@@ -188,7 +227,16 @@ class HostExecutor {
|
|
|
188
227
|
options: run.options,
|
|
189
228
|
accounts: run.accounts,
|
|
190
229
|
volumeScope: volumeScope(run),
|
|
230
|
+
...((0, checks_1.isCheckRun)(run) ? { check: true } : {}),
|
|
191
231
|
catalog: planning.environment.hosted?.catalog,
|
|
232
|
+
...(run.up
|
|
233
|
+
? {
|
|
234
|
+
up: {
|
|
235
|
+
services: run.up.services,
|
|
236
|
+
endpoints: run.up.endpoints.map(({ service, port }) => ({ service, port })),
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
: {}),
|
|
192
240
|
...(placed ? { kubernetes: placed.kubernetes } : {}),
|
|
193
241
|
}, cwd, { ...this.processEnvironment(home, cache), ...placed?.env }, context, abortCtrl.signal);
|
|
194
242
|
}
|
|
@@ -282,6 +330,9 @@ class HostExecutor {
|
|
|
282
330
|
if (message.kind === 'event') {
|
|
283
331
|
context.emit(message.event);
|
|
284
332
|
}
|
|
333
|
+
else if (message.kind === 'up') {
|
|
334
|
+
context.up?.(message.targets);
|
|
335
|
+
}
|
|
285
336
|
else if (message.kind === 'result') {
|
|
286
337
|
result = message;
|
|
287
338
|
// nothing left to tell the process
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.awaitRunObjectsGone = exports.removeRunObjects = exports.CallerNamespaces = exports.getVolumeScope = exports.KubernetesExecutor = exports.getRunRoleBinding = exports.getNamespacePolicies = exports.getClusterAccess = exports.isCallerNamespace = exports.getCallerNamespace = void 0;
|
|
3
|
+
exports.awaitRunObjectsGone = exports.removeRunObjects = exports.CallerNamespaces = exports.getVolumeScope = exports.KubernetesExecutor = exports.getRunRoleBinding = exports.removeServiceRunNamespace = exports.getServerIngressPolicy = exports.getNamespacePolicies = exports.getClusterAccess = exports.isCallerNamespace = exports.SERVICE_RUN_LABEL = exports.getServiceRunNamespace = exports.getCallerNamespace = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
const fs_1 = require("fs");
|
|
6
6
|
const os_1 = require("os");
|
|
@@ -22,6 +22,8 @@ const POLICY_NAME = 'hammerkit';
|
|
|
22
22
|
const MANAGED_LABEL = 'hammerkit.dev/managed';
|
|
23
23
|
// how long the end of a run waits for its pods and claims to be gone
|
|
24
24
|
const RELEASE_TIMEOUT = 120000;
|
|
25
|
+
// how long the setup of a caller's namespace is taken as it is, before it is applied again
|
|
26
|
+
const NAMESPACE_SETUP_REUSE_MS = 5 * 60000;
|
|
25
27
|
// where a pod of the cluster finds the token and certificate of its ServiceAccount
|
|
26
28
|
const IN_CLUSTER_NAMESPACE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace';
|
|
27
29
|
/**
|
|
@@ -32,6 +34,19 @@ function getCallerNamespace(prefix, owner) {
|
|
|
32
34
|
return `${prefix}${(0, crypto_1.createHash)('sha256').update(`${owner.iss}\0${owner.sub}`).digest('hex').slice(0, 16)}`;
|
|
33
35
|
}
|
|
34
36
|
exports.getCallerNamespace = getCallerNamespace;
|
|
37
|
+
/**
|
|
38
|
+
* The namespace of a service run: one of its own, as the services stay up for hours while
|
|
39
|
+
* the caller's other runs go on in theirs (one at a time, each removing what the one
|
|
40
|
+
* before left).
|
|
41
|
+
*/
|
|
42
|
+
function getServiceRunNamespace(prefix, run, wakes = 0) {
|
|
43
|
+
// a woken run starts in a namespace of its own, the one it slept in may still be removed
|
|
44
|
+
const name = wakes > 0 ? `up\0${run}\0${wakes}` : `up\0${run}`;
|
|
45
|
+
return `${prefix}${(0, crypto_1.createHash)('sha256').update(name).digest('hex').slice(0, 16)}`;
|
|
46
|
+
}
|
|
47
|
+
exports.getServiceRunNamespace = getServiceRunNamespace;
|
|
48
|
+
// the label of the namespace of a service run: the run label value of its run
|
|
49
|
+
exports.SERVICE_RUN_LABEL = 'hammerkit.dev/service-run';
|
|
35
50
|
// Whether a namespace is one getCallerNamespace makes with this prefix: a server named `hk`
|
|
36
51
|
// passes over the namespaces of a server named `hk-test` on the same cluster.
|
|
37
52
|
function isCallerNamespace(prefix, name) {
|
|
@@ -143,6 +158,64 @@ function getNamespacePolicies(namespace, deniedEgress, allowedEgress = []) {
|
|
|
143
158
|
];
|
|
144
159
|
}
|
|
145
160
|
exports.getNamespacePolicies = getNamespacePolicies;
|
|
161
|
+
/**
|
|
162
|
+
* What lets the server into the namespace of a service run, to the ports of its endpoints:
|
|
163
|
+
* policies add up, so this one opens the pods to the server's namespace besides what the
|
|
164
|
+
* others let in, limited ones too.
|
|
165
|
+
*/
|
|
166
|
+
// The server reaches the ports of the endpoints, from its own namespace, and no other port:
|
|
167
|
+
// what else the run starts (a database) stays for the pods of the run.
|
|
168
|
+
function getServerIngressPolicy(namespace, server, ports) {
|
|
169
|
+
return {
|
|
170
|
+
apiVersion: 'networking.k8s.io/v1',
|
|
171
|
+
kind: 'NetworkPolicy',
|
|
172
|
+
metadata: {
|
|
173
|
+
name: 'hammerkit-server-ingress',
|
|
174
|
+
namespace,
|
|
175
|
+
annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
|
|
176
|
+
labels: { [MANAGED_LABEL]: 'true' },
|
|
177
|
+
},
|
|
178
|
+
spec: {
|
|
179
|
+
podSelector: {},
|
|
180
|
+
policyTypes: ['Ingress'],
|
|
181
|
+
ingress: ports.length > 0
|
|
182
|
+
? [
|
|
183
|
+
{
|
|
184
|
+
_from: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': server } } }],
|
|
185
|
+
ports: [...new Set(ports)].map((port) => ({ protocol: 'TCP', port })),
|
|
186
|
+
},
|
|
187
|
+
]
|
|
188
|
+
: [],
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
exports.getServerIngressPolicy = getServerIngressPolicy;
|
|
193
|
+
// The namespace of a service run goes with all it holds; waits up to `timeout` for it.
|
|
194
|
+
async function removeServiceRunNamespace(instance, namespace, timeout) {
|
|
195
|
+
try {
|
|
196
|
+
await instance.coreApi.deleteNamespace({ name: namespace, propagationPolicy: 'Background' });
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
if ((0, apply_1.statusCodeOf)(e) === 404) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
throw e;
|
|
203
|
+
}
|
|
204
|
+
const deadline = Date.now() + timeout;
|
|
205
|
+
while (Date.now() < deadline) {
|
|
206
|
+
try {
|
|
207
|
+
await instance.coreApi.readNamespace({ name: namespace });
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
if ((0, apply_1.statusCodeOf)(e) === 404) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
throw e;
|
|
214
|
+
}
|
|
215
|
+
await (0, sleep_1.sleep)(1000);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
exports.removeServiceRunNamespace = removeServiceRunNamespace;
|
|
146
219
|
/**
|
|
147
220
|
* The server's rights in a caller's namespace: the operator's ClusterRole, bound there
|
|
148
221
|
* only, so the server needs no rights on Secrets or pods anywhere else.
|
|
@@ -167,22 +240,31 @@ exports.getRunRoleBinding = getRunRoleBinding;
|
|
|
167
240
|
* process of the server as on a host (see HostExecutor); what changes is where its
|
|
168
241
|
* container tasks and services go: into the caller's namespace, on the nodes of the
|
|
169
242
|
* operator's pools, with the secrets of providers read in the Job itself. The runs
|
|
170
|
-
* of
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
243
|
+
* of a caller execute side by side in their namespace, up to `runs.limits.running`:
|
|
244
|
+
* the objects of a run are named and found by its run label (getItemKey), so two runs
|
|
245
|
+
* of the same task have their own. What two runs share are the claims the server keeps
|
|
246
|
+
* (`backend.keepVolumes`) of a scope (getVolumeScope): the runs of one scope go one at
|
|
247
|
+
* a time (exclusive). When a run ends, everything made for it is removed, but for the
|
|
248
|
+
* kept claims, which go once they expired.
|
|
174
249
|
*/
|
|
175
250
|
class KubernetesExecutor {
|
|
176
251
|
constructor(config, processEnvs, options) {
|
|
177
252
|
this.config = config;
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
// the run that executes, or the last one waiting, per caller namespace
|
|
253
|
+
// per caller namespace, what changes objects of the whole namespace: its setup, the
|
|
254
|
+
// removal of what a stopped server left, the sweeps of kept claims. The last one waiting.
|
|
181
255
|
this.queues = new Map();
|
|
256
|
+
// per caller namespace, the runs that execute in it: by run label, their volume scope
|
|
257
|
+
this.active = new Map();
|
|
182
258
|
// per caller namespace, when its kept claims are next looked at
|
|
183
259
|
this.sweeps = new Map();
|
|
184
260
|
// whether the kept claims a server left when it stopped were looked for
|
|
185
261
|
this.swept = false;
|
|
262
|
+
// the namespaces where no run of this process left anything: what a run makes goes at its
|
|
263
|
+
// end, unless its release failed
|
|
264
|
+
this.tidy = new Set();
|
|
265
|
+
// the namespaces of the service runs that execute, and whether those of earlier ones went
|
|
266
|
+
this.serviceRuns = new Set();
|
|
267
|
+
this.serviceRunsSwept = false;
|
|
186
268
|
this.access = getClusterAccess(config.backend, processEnvs);
|
|
187
269
|
this.instance = (0, kubernetes_instance_1.createKubernetesInstances)({
|
|
188
270
|
type: 'kubernetes',
|
|
@@ -203,17 +285,56 @@ class KubernetesExecutor {
|
|
|
203
285
|
this.log = options.log;
|
|
204
286
|
this.host = new executor_1.HostExecutor({ ...options, placement: this.placement() });
|
|
205
287
|
}
|
|
206
|
-
execute(run, context) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
throw new Error('the run was cancelled');
|
|
288
|
+
async execute(run, context) {
|
|
289
|
+
if (run.up) {
|
|
290
|
+
if (run.source.type === 'sandbox') {
|
|
291
|
+
throw new Error('on a cluster a service run starts from a branch, push the sandbox and start it from there');
|
|
211
292
|
}
|
|
293
|
+
// in a namespace of its own, apart from the caller's runs and their claims
|
|
212
294
|
return this.host.execute(run, context);
|
|
213
|
-
}
|
|
295
|
+
}
|
|
296
|
+
const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
|
|
297
|
+
const label = (0, job_placement_1.getRunLabelValue)(run.id);
|
|
298
|
+
let runs = this.active.get(namespace);
|
|
299
|
+
if (!runs) {
|
|
300
|
+
runs = new Map();
|
|
301
|
+
this.active.set(namespace, runs);
|
|
302
|
+
}
|
|
303
|
+
runs.set(label, getVolumeScope(run));
|
|
304
|
+
try {
|
|
305
|
+
await this.host.execute(run, context);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
runs.delete(label);
|
|
309
|
+
if (runs.size === 0 && this.active.get(namespace) === runs) {
|
|
310
|
+
this.active.delete(namespace);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
namespaceOf(run) {
|
|
315
|
+
const prefix = this.config.backend.namespacePrefix;
|
|
316
|
+
return run.up ? getServiceRunNamespace(prefix, run.id, run.wakes) : getCallerNamespace(prefix, run.owner);
|
|
317
|
+
}
|
|
318
|
+
// The runs of a scope share its kept claims, which one of them may change at a time:
|
|
319
|
+
// what a task finds in one is what the last run left there. Without kept claims, runs
|
|
320
|
+
// share no object and nothing is held.
|
|
321
|
+
exclusive(run) {
|
|
322
|
+
if (this.keep === 0 || run.up) {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
return `${getCallerNamespace(this.config.backend.namespacePrefix, run.owner)}/${getVolumeScope(run)}`;
|
|
326
|
+
}
|
|
327
|
+
// The scopes of the runs that execute in the namespace, but `except`'s: their kept claims
|
|
328
|
+
// are theirs to mount and stay. A server that keeps none mounts none.
|
|
329
|
+
scopesInUse(namespace, except) {
|
|
330
|
+
if (this.keep === 0) {
|
|
331
|
+
return new Set();
|
|
332
|
+
}
|
|
333
|
+
return new Set([...(this.active.get(namespace) ?? new Map()).entries()]
|
|
334
|
+
.filter(([label]) => label !== except)
|
|
335
|
+
.map(([, scope]) => scope));
|
|
214
336
|
}
|
|
215
|
-
// What
|
|
216
|
-
// removal of the claims their runs kept
|
|
337
|
+
// What changes objects of a whole namespace happens one at a time, not what its runs do
|
|
217
338
|
enqueue(namespace, fn) {
|
|
218
339
|
const previous = this.queues.get(namespace) ?? Promise.resolve();
|
|
219
340
|
const execution = previous.then(fn);
|
|
@@ -227,54 +348,94 @@ class KubernetesExecutor {
|
|
|
227
348
|
return execution;
|
|
228
349
|
}
|
|
229
350
|
placement() {
|
|
230
|
-
const backend = this.config.backend;
|
|
231
351
|
return {
|
|
232
352
|
prepare: async (run, context, work) => {
|
|
233
|
-
const namespace =
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
353
|
+
const namespace = this.namespaceOf(run);
|
|
354
|
+
if (run.up) {
|
|
355
|
+
if (context.signal.aborted) {
|
|
356
|
+
throw new Error('the run was cancelled');
|
|
357
|
+
}
|
|
358
|
+
this.serviceRuns.add(namespace);
|
|
359
|
+
this.sweepServiceRuns();
|
|
360
|
+
// reached by the server at the Services of its endpoints
|
|
361
|
+
await this.namespaces.prepare(namespace, {
|
|
362
|
+
serviceRun: (0, job_placement_1.getRunLabelValue)(run.id),
|
|
363
|
+
server: this.namespace,
|
|
364
|
+
ports: (run.up.endpoints ?? []).map((endpoint) => endpoint.port),
|
|
365
|
+
quota: this.config.runs.up.quota,
|
|
366
|
+
});
|
|
367
|
+
context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
|
|
368
|
+
return { kubernetes: this.environmentOf(run, namespace), env: this.access.env };
|
|
246
369
|
}
|
|
370
|
+
const label = (0, job_placement_1.getRunLabelValue)(run.id);
|
|
371
|
+
await this.enqueue(namespace, async () => {
|
|
372
|
+
if (context.signal.aborted) {
|
|
373
|
+
throw new Error('the run was cancelled');
|
|
374
|
+
}
|
|
375
|
+
await this.namespaces.prepare(namespace);
|
|
376
|
+
// what an earlier run left when the server stopped before its end, or when its
|
|
377
|
+
// release failed: the objects of a run that does not execute. One that starts
|
|
378
|
+
// after this waits for it here before it makes any.
|
|
379
|
+
if (!this.tidy.has(namespace)) {
|
|
380
|
+
const running = [...new Set([label, ...(this.active.get(namespace)?.keys() ?? [])])];
|
|
381
|
+
await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL} notin (${running.join(',')})`, RELEASE_TIMEOUT);
|
|
382
|
+
this.tidy.add(namespace);
|
|
383
|
+
}
|
|
384
|
+
// the claims earlier runs kept that expired; the others this run may mount
|
|
385
|
+
const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
|
|
386
|
+
now: new Date(),
|
|
387
|
+
keep: this.keep > 0,
|
|
388
|
+
timeout: RELEASE_TIMEOUT,
|
|
389
|
+
inUse: this.scopesInUse(namespace, label),
|
|
390
|
+
});
|
|
391
|
+
if (next) {
|
|
392
|
+
this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
|
|
393
|
+
}
|
|
394
|
+
});
|
|
247
395
|
this.sweepLeftClaims();
|
|
248
396
|
context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
|
|
249
|
-
|
|
250
|
-
type: 'kubernetes',
|
|
251
|
-
namespace,
|
|
252
|
-
kubeConfig: this.access.kubeConfig,
|
|
253
|
-
context: this.access.context,
|
|
254
|
-
httpRoutes: [],
|
|
255
|
-
storageClass: backend.storageClass,
|
|
256
|
-
volumeSize: backend.volumeSize,
|
|
257
|
-
maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
|
|
258
|
-
networkPolicyEnforced: backend.networkPolicy === 'enforced',
|
|
259
|
-
server: {
|
|
260
|
-
run: run.id,
|
|
261
|
-
namespace: this.namespace,
|
|
262
|
-
pools: backend.pools,
|
|
263
|
-
providers: backend.providers,
|
|
264
|
-
accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
|
|
265
|
-
...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace) } : {}),
|
|
266
|
-
...(this.keep > 0 ? { volumeScope: getVolumeScope(run) } : {}),
|
|
267
|
-
},
|
|
268
|
-
};
|
|
269
|
-
return { kubernetes, env: this.access.env };
|
|
397
|
+
return { kubernetes: this.environmentOf(run, namespace, work), env: this.access.env };
|
|
270
398
|
},
|
|
271
399
|
release: async (run, context) => {
|
|
272
|
-
const namespace =
|
|
400
|
+
const namespace = this.namespaceOf(run);
|
|
401
|
+
if (run.up) {
|
|
402
|
+
try {
|
|
403
|
+
await removeServiceRunNamespace(this.instance, namespace, RELEASE_TIMEOUT);
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
// at least nothing of it runs on: its objects are the server's to remove (runRole)
|
|
407
|
+
const forbidden = (0, apply_1.statusCodeOf)(e) === 403;
|
|
408
|
+
const text = forbidden
|
|
409
|
+
? `the server may not delete the namespace ${namespace}, its ClusterRole needs delete on namespaces (see the backend docs); removing what runs in it`
|
|
410
|
+
: `the namespace ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(e)}`;
|
|
411
|
+
this.log?.(text);
|
|
412
|
+
context.emit({ type: 'log', task: 'hammerkit', stream: 'stderr', text });
|
|
413
|
+
try {
|
|
414
|
+
await removeRunObjects(this.instance, namespace, job_placement_1.RUN_LABEL, RELEASE_TIMEOUT);
|
|
415
|
+
}
|
|
416
|
+
catch (inner) {
|
|
417
|
+
context.emit({
|
|
418
|
+
type: 'log',
|
|
419
|
+
task: 'hammerkit',
|
|
420
|
+
stream: 'stderr',
|
|
421
|
+
text: `what the run made in ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(inner)}`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
// the next look removes the namespace
|
|
425
|
+
this.serviceRunsSwept = false;
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
this.serviceRuns.delete(namespace);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
273
432
|
try {
|
|
274
433
|
const selector = `${job_placement_1.RUN_LABEL}=${(0, job_placement_1.getRunLabelValue)(run.id)}`;
|
|
275
434
|
await removeRunObjects(this.instance, namespace, selector, RELEASE_TIMEOUT);
|
|
276
435
|
}
|
|
277
436
|
catch (e) {
|
|
437
|
+
// the next run removes what is left
|
|
438
|
+
this.tidy.delete(namespace);
|
|
278
439
|
context.emit({
|
|
279
440
|
type: 'log',
|
|
280
441
|
task: 'hammerkit',
|
|
@@ -300,8 +461,59 @@ class KubernetesExecutor {
|
|
|
300
461
|
},
|
|
301
462
|
};
|
|
302
463
|
}
|
|
303
|
-
// The
|
|
304
|
-
|
|
464
|
+
// The cluster as the process of a run sees it: the namespace its tasks and services go in.
|
|
465
|
+
environmentOf(run, namespace, work) {
|
|
466
|
+
const backend = this.config.backend;
|
|
467
|
+
return {
|
|
468
|
+
type: 'kubernetes',
|
|
469
|
+
namespace,
|
|
470
|
+
kubeConfig: this.access.kubeConfig,
|
|
471
|
+
context: this.access.context,
|
|
472
|
+
httpRoutes: [],
|
|
473
|
+
storageClass: backend.storageClass,
|
|
474
|
+
volumeSize: backend.volumeSize,
|
|
475
|
+
maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
|
|
476
|
+
networkPolicyEnforced: backend.networkPolicy === 'enforced',
|
|
477
|
+
server: {
|
|
478
|
+
run: run.id,
|
|
479
|
+
namespace: this.namespace,
|
|
480
|
+
pools: backend.pools,
|
|
481
|
+
providers: backend.providers,
|
|
482
|
+
accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
|
|
483
|
+
...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace, backend.sandboxes.runner) } : {}),
|
|
484
|
+
// the claims of a service run go with its namespace
|
|
485
|
+
...(this.keep > 0 && !run.up ? { volumeScope: getVolumeScope(run) } : {}),
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
// Once, on the first service run: the namespaces of service runs the server left when it
|
|
490
|
+
// stopped, whose services went with it
|
|
491
|
+
// Once when the server starts, and on the next service run after a namespace could not be
|
|
492
|
+
// removed: the namespaces of service runs that none of the server's runs is in, whose
|
|
493
|
+
// services went with the process of a server that stopped
|
|
494
|
+
sweepServiceRuns() {
|
|
495
|
+
if (this.serviceRunsSwept) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
this.serviceRunsSwept = true;
|
|
499
|
+
Promise.resolve()
|
|
500
|
+
.then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,${exports.SERVICE_RUN_LABEL}` }))
|
|
501
|
+
.then(async (namespaces) => {
|
|
502
|
+
for (const namespace of namespaces.items) {
|
|
503
|
+
const name = namespace.metadata?.name;
|
|
504
|
+
if (name &&
|
|
505
|
+
isCallerNamespace(this.config.backend.namespacePrefix, name) &&
|
|
506
|
+
!this.serviceRuns.has(name) &&
|
|
507
|
+
!namespace.metadata?.deletionTimestamp) {
|
|
508
|
+
this.log?.(`removing the namespace ${name} of a service run from before the server started`);
|
|
509
|
+
await removeServiceRunNamespace(this.instance, name, 0);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
.catch((e) => this.log?.(`the namespaces of service runs could not be removed: ${(0, log_1.getErrorMessage)(e)}`));
|
|
514
|
+
}
|
|
515
|
+
// The kept claims of a namespace that expired go at the soonest expiry the server knows
|
|
516
|
+
// of, but those of a scope a run of the caller executes in
|
|
305
517
|
sweepLater(namespace, delay) {
|
|
306
518
|
const at = Date.now() + delay + 1000;
|
|
307
519
|
const pending = this.sweeps.get(namespace);
|
|
@@ -321,6 +533,7 @@ class KubernetesExecutor {
|
|
|
321
533
|
now: new Date(),
|
|
322
534
|
keep: this.keep > 0,
|
|
323
535
|
timeout: RELEASE_TIMEOUT,
|
|
536
|
+
inUse: this.scopesInUse(namespace),
|
|
324
537
|
});
|
|
325
538
|
if (removed.length > 0) {
|
|
326
539
|
this.log?.(`removed ${removed.length} kept claims in ${namespace}`);
|
|
@@ -337,7 +550,7 @@ class KubernetesExecutor {
|
|
|
337
550
|
}
|
|
338
551
|
this.swept = true;
|
|
339
552
|
Promise.resolve()
|
|
340
|
-
.then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true` }))
|
|
553
|
+
.then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,!${exports.SERVICE_RUN_LABEL}` }))
|
|
341
554
|
.then((namespaces) => {
|
|
342
555
|
for (const namespace of namespaces.items) {
|
|
343
556
|
const name = namespace.metadata?.name;
|
|
@@ -356,7 +569,7 @@ exports.KubernetesExecutor = KubernetesExecutor;
|
|
|
356
569
|
// cache, and on a sandbox the runs of that sandbox only, whose claim is on the same node or
|
|
357
570
|
// zone as the kept ones. The owner is the namespace's.
|
|
358
571
|
function getVolumeScope(run) {
|
|
359
|
-
if (run.source.type === 'sandbox') {
|
|
572
|
+
if (run.source.type === 'sandbox' && !run.source.check) {
|
|
360
573
|
return `sandbox-${(0, crypto_1.createHash)('sha256').update(run.source.id).digest('hex').slice(0, 24)}`;
|
|
361
574
|
}
|
|
362
575
|
return (0, executor_1.cacheNamespace)(run);
|
|
@@ -370,6 +583,8 @@ class CallerNamespaces {
|
|
|
370
583
|
constructor(instance, backend) {
|
|
371
584
|
this.instance = instance;
|
|
372
585
|
this.backend = backend;
|
|
586
|
+
// the namespaces this process set up, by their uid, and when
|
|
587
|
+
this.applied = new Map();
|
|
373
588
|
}
|
|
374
589
|
name(owner) {
|
|
375
590
|
return getCallerNamespace(this.backend.namespacePrefix, owner);
|
|
@@ -377,9 +592,14 @@ class CallerNamespaces {
|
|
|
377
592
|
// The caller's namespace: made by the server and kept apart from the others. The
|
|
378
593
|
// server binds its rights there, isolates the network of the pods, applies the
|
|
379
594
|
// operator's quota and gives containers without resources default requests, so they
|
|
380
|
-
// are admitted under a quota on compute. All of it is replaced
|
|
381
|
-
// namespace
|
|
382
|
-
|
|
595
|
+
// are admitted under a quota on compute. All of it is replaced when the server sets up
|
|
596
|
+
// a namespace it did not set up in the last `NAMESPACE_SETUP_REUSE_MS` (it reads the
|
|
597
|
+
// config once, at its start), so the namespace follows the config; a namespace made anew
|
|
598
|
+
// since (deleted and made again) is set up again at once.
|
|
599
|
+
async prepare(namespace,
|
|
600
|
+
// a service run's own namespace: its label, the namespace of the server that reaches its
|
|
601
|
+
// endpoints, and its quota
|
|
602
|
+
serviceRun) {
|
|
383
603
|
const backend = this.backend;
|
|
384
604
|
let existing;
|
|
385
605
|
try {
|
|
@@ -393,15 +613,26 @@ class CallerNamespaces {
|
|
|
393
613
|
if (existing && existing.metadata?.labels?.[MANAGED_LABEL] !== 'true') {
|
|
394
614
|
throw new Error(`the namespace ${namespace} was not made by hammerkit, the server does not run tasks in it`);
|
|
395
615
|
}
|
|
616
|
+
const started = Date.now();
|
|
617
|
+
const applied = this.applied.get(namespace);
|
|
618
|
+
if (existing &&
|
|
619
|
+
!existing.metadata?.deletionTimestamp &&
|
|
620
|
+
applied &&
|
|
621
|
+
applied.uid === existing.metadata?.uid &&
|
|
622
|
+
started - applied.at < NAMESPACE_SETUP_REUSE_MS) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
this.applied.delete(namespace);
|
|
396
626
|
if (!existing) {
|
|
397
627
|
try {
|
|
398
|
-
await this.instance.coreApi.createNamespace({
|
|
628
|
+
existing = await this.instance.coreApi.createNamespace({
|
|
399
629
|
body: {
|
|
400
630
|
metadata: {
|
|
401
631
|
name: namespace,
|
|
402
632
|
labels: {
|
|
403
633
|
[MANAGED_LABEL]: 'true',
|
|
404
634
|
'hammerkit.dev/caller': namespace,
|
|
635
|
+
...(serviceRun ? { [exports.SERVICE_RUN_LABEL]: serviceRun.serviceRun } : {}),
|
|
405
636
|
// no privileged pods, host paths or host network, whatever a build asks
|
|
406
637
|
'pod-security.kubernetes.io/enforce': 'baseline',
|
|
407
638
|
},
|
|
@@ -419,7 +650,10 @@ class CallerNamespaces {
|
|
|
419
650
|
const [first, ...policies] = getNamespacePolicies(namespace, backend.deniedEgress, backend.allowedEgress);
|
|
420
651
|
// the API server may refuse what the binding allows for a moment after it was made
|
|
421
652
|
await untilAllowed(() => (0, apply_1.replace)(this.instance, first));
|
|
422
|
-
for (const policy of
|
|
653
|
+
for (const policy of [
|
|
654
|
+
...policies,
|
|
655
|
+
...(serviceRun ? [getServerIngressPolicy(namespace, serviceRun.server, serviceRun.ports)] : []),
|
|
656
|
+
]) {
|
|
423
657
|
await (0, apply_1.replace)(this.instance, policy);
|
|
424
658
|
}
|
|
425
659
|
const metadata = {
|
|
@@ -432,7 +666,7 @@ class CallerNamespaces {
|
|
|
432
666
|
apiVersion: 'v1',
|
|
433
667
|
kind: 'ResourceQuota',
|
|
434
668
|
metadata,
|
|
435
|
-
spec: { hard: backend.quota },
|
|
669
|
+
spec: { hard: serviceRun?.quota ?? backend.quota },
|
|
436
670
|
};
|
|
437
671
|
const defaults = backend.defaultResources;
|
|
438
672
|
const limits = {
|
|
@@ -450,7 +684,7 @@ class CallerNamespaces {
|
|
|
450
684
|
},
|
|
451
685
|
};
|
|
452
686
|
for (const [object, wanted] of [
|
|
453
|
-
[quota, !!backend.quota],
|
|
687
|
+
[quota, !!(serviceRun?.quota ?? backend.quota)],
|
|
454
688
|
[limits, !!defaults],
|
|
455
689
|
]) {
|
|
456
690
|
if (wanted) {
|
|
@@ -465,6 +699,10 @@ class CallerNamespaces {
|
|
|
465
699
|
});
|
|
466
700
|
}
|
|
467
701
|
}
|
|
702
|
+
const uid = existing?.metadata?.uid;
|
|
703
|
+
if (uid) {
|
|
704
|
+
this.applied.set(namespace, { uid, at: started });
|
|
705
|
+
}
|
|
468
706
|
}
|
|
469
707
|
whoAmI() {
|
|
470
708
|
if (!this.subject) {
|
|
@@ -487,7 +725,7 @@ class CallerNamespaces {
|
|
|
487
725
|
exports.CallerNamespaces = CallerNamespaces;
|
|
488
726
|
// The claim of a sandbox as the Jobs of its run mount it: the run's copy of the files is
|
|
489
727
|
// `root`, a source below it is the same path below `subPath` in the claim.
|
|
490
|
-
function getWorkspace(root, claim, namespace) {
|
|
728
|
+
function getWorkspace(root, claim, namespace, runner) {
|
|
491
729
|
if (claim.namespace !== namespace) {
|
|
492
730
|
throw new Error(`the sandbox is in ${claim.namespace}, the run in ${namespace}`);
|
|
493
731
|
}
|
|
@@ -497,6 +735,7 @@ function getWorkspace(root, claim, namespace) {
|
|
|
497
735
|
subPath: claim.subPath,
|
|
498
736
|
label: claim.label,
|
|
499
737
|
...(claim.pod ? { pod: claim.pod } : {}),
|
|
738
|
+
...(runner ? { runner: true } : {}),
|
|
500
739
|
};
|
|
501
740
|
}
|
|
502
741
|
// Everything a run made in the caller's namespace, by the label selector of the run:
|