hammerkit 1.25.0 → 1.27.0-next.1
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/container-task-state.js +8 -6
- 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 +44 -22
- 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/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 +8 -2
- 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/utils/task-arguments.js +155 -0
- package/dist/planner/work-cache-id.js +15 -1
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +43 -0
- package/dist/planner/work-runtime-kubernetes.js +336 -102
- package/dist/planner/work-runtime-local.js +16 -8
- package/dist/planner/work-service.js +7 -3
- package/dist/planner/work-state-record.js +30 -0
- package/dist/program.js +80 -7
- 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 +13 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +23 -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 +554 -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
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"use strict";
|
|
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.getCallerNamespace = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const os_1 = require("os");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const net_1 = require("net");
|
|
9
|
+
const apply_1 = require("../kubernetes/apply");
|
|
10
|
+
const job_placement_1 = require("../kubernetes/job-placement");
|
|
11
|
+
const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
|
|
12
|
+
const log_1 = require("../log");
|
|
13
|
+
const network_policy_1 = require("../kubernetes/network-policy");
|
|
14
|
+
const sleep_1 = require("../utils/sleep");
|
|
15
|
+
const version_1 = require("../version");
|
|
16
|
+
const executor_1 = require("./executor");
|
|
17
|
+
const kept_claims_1 = require("../kubernetes/kept-claims");
|
|
18
|
+
const units_1 = require("../utils/units");
|
|
19
|
+
const server_config_1 = require("./server-config");
|
|
20
|
+
// the name of the quota and limit range the server keeps in a caller's namespace
|
|
21
|
+
const POLICY_NAME = 'hammerkit';
|
|
22
|
+
const MANAGED_LABEL = 'hammerkit.dev/managed';
|
|
23
|
+
// how long the end of a run waits for its pods and claims to be gone
|
|
24
|
+
const RELEASE_TIMEOUT = 120000;
|
|
25
|
+
// where a pod of the cluster finds the token and certificate of its ServiceAccount
|
|
26
|
+
const IN_CLUSTER_NAMESPACE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace';
|
|
27
|
+
/**
|
|
28
|
+
* The namespace of a caller: the prefix and a hash of their issuer and subject. A
|
|
29
|
+
* subject is the caller's own, so nobody else's runs, claims or quota are in it.
|
|
30
|
+
*/
|
|
31
|
+
function getCallerNamespace(prefix, owner) {
|
|
32
|
+
return `${prefix}${(0, crypto_1.createHash)('sha256').update(`${owner.iss}\0${owner.sub}`).digest('hex').slice(0, 16)}`;
|
|
33
|
+
}
|
|
34
|
+
exports.getCallerNamespace = getCallerNamespace;
|
|
35
|
+
// How the server and the processes of its runs reach the cluster: a kubeconfig and
|
|
36
|
+
// context the operator names, else the server's default kubeconfig, else the cluster
|
|
37
|
+
// the server runs in (its pod's ServiceAccount, found through the variables
|
|
38
|
+
// Kubernetes sets in every pod, which a run's process gets too).
|
|
39
|
+
function getClusterAccess(backend, processEnvs) {
|
|
40
|
+
if (backend.kubeconfig) {
|
|
41
|
+
return { kubeConfig: backend.kubeconfig, context: backend.context, env: {} };
|
|
42
|
+
}
|
|
43
|
+
const fallback = processEnvs.KUBECONFIG?.split(process.platform === 'win32' ? ';' : ':')[0];
|
|
44
|
+
const kubeConfig = fallback || (0, path_1.join)(processEnvs.HOME ?? (0, os_1.homedir)(), '.kube', 'config');
|
|
45
|
+
if ((0, fs_1.existsSync)(kubeConfig)) {
|
|
46
|
+
return { kubeConfig, context: backend.context, env: {} };
|
|
47
|
+
}
|
|
48
|
+
const env = {};
|
|
49
|
+
for (const name of ['KUBERNETES_SERVICE_HOST', 'KUBERNETES_SERVICE_PORT']) {
|
|
50
|
+
const value = processEnvs[name];
|
|
51
|
+
if (value) {
|
|
52
|
+
env[name] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { context: backend.context, env };
|
|
56
|
+
}
|
|
57
|
+
exports.getClusterAccess = getClusterAccess;
|
|
58
|
+
function readInClusterNamespace() {
|
|
59
|
+
try {
|
|
60
|
+
return (0, fs_1.readFileSync)(IN_CLUSTER_NAMESPACE, 'utf8').trim() || undefined;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The NetworkPolicies of a caller's namespace. A namespace is no network boundary on its
|
|
68
|
+
* own: without them a task could connect to the pods of other callers, to the server
|
|
69
|
+
* and to the metadata endpoint of the cloud, which hands out the node's identity.
|
|
70
|
+
* - ingress of a pod with an open network: connections from the pods of its namespace
|
|
71
|
+
* only (the services a task needs)
|
|
72
|
+
* - egress of a pod with an open network, and of an egress proxy: its namespace, DNS,
|
|
73
|
+
* and any address but the denied ranges (link-local and private by default), plus the
|
|
74
|
+
* allowed ones
|
|
75
|
+
* Policies add up, so neither selects the pods of a limited item: its own policy (see
|
|
76
|
+
* buildNetworkPolicy) is all it may reach, DNS left out, and all that may reach it. An
|
|
77
|
+
* egress proxy takes connections from its item only (buildProxyPolicy).
|
|
78
|
+
*/
|
|
79
|
+
function getNamespacePolicies(namespace, deniedEgress, allowedEgress = []) {
|
|
80
|
+
const metadata = (name) => ({
|
|
81
|
+
name,
|
|
82
|
+
namespace,
|
|
83
|
+
annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
|
|
84
|
+
labels: { [MANAGED_LABEL]: 'true' },
|
|
85
|
+
});
|
|
86
|
+
const block = (cidr, except) => ({ ipBlock: { cidr, ...(except.length ? { except } : {}) } });
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
apiVersion: 'networking.k8s.io/v1',
|
|
90
|
+
kind: 'NetworkPolicy',
|
|
91
|
+
metadata: metadata('hammerkit-ingress'),
|
|
92
|
+
spec: {
|
|
93
|
+
podSelector: { matchExpressions: [{ key: network_policy_1.NETWORK_LABEL, operator: 'DoesNotExist' }] },
|
|
94
|
+
policyTypes: ['Ingress'],
|
|
95
|
+
ingress: [{ _from: [{ podSelector: {} }] }],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
apiVersion: 'networking.k8s.io/v1',
|
|
100
|
+
kind: 'NetworkPolicy',
|
|
101
|
+
metadata: metadata('hammerkit-egress'),
|
|
102
|
+
spec: {
|
|
103
|
+
podSelector: { matchExpressions: [{ key: network_policy_1.NETWORK_LABEL, operator: 'NotIn', values: [network_policy_1.NETWORK_LIMITED] }] },
|
|
104
|
+
policyTypes: ['Egress'],
|
|
105
|
+
egress: [
|
|
106
|
+
{ to: [{ podSelector: {} }] },
|
|
107
|
+
// to whatever resolver the pod is given, a node-local cache included
|
|
108
|
+
{
|
|
109
|
+
ports: [
|
|
110
|
+
{ protocol: 'UDP', port: 53 },
|
|
111
|
+
{ protocol: 'TCP', port: 53 },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
to: [
|
|
116
|
+
block('0.0.0.0/0', deniedEgress.filter((range) => (0, net_1.isIPv4)(range.split('/')[0]))),
|
|
117
|
+
block('::/0', deniedEgress.filter((range) => !(0, net_1.isIPv4)(range.split('/')[0]))),
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
// the operator's exceptions, which rules add up to
|
|
121
|
+
...(allowedEgress.length > 0 ? [{ to: allowedEgress.map((cidr) => ({ ipBlock: { cidr } })) }] : []),
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
apiVersion: 'networking.k8s.io/v1',
|
|
127
|
+
kind: 'NetworkPolicy',
|
|
128
|
+
metadata: metadata('hammerkit-proxy-ingress'),
|
|
129
|
+
spec: {
|
|
130
|
+
// an egress proxy takes only what its own policy lets in: one whose policy went
|
|
131
|
+
// before the pod ended takes nothing
|
|
132
|
+
podSelector: { matchLabels: { [network_policy_1.NETWORK_LABEL]: network_policy_1.NETWORK_PROXY } },
|
|
133
|
+
policyTypes: ['Ingress'],
|
|
134
|
+
ingress: [],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
exports.getNamespacePolicies = getNamespacePolicies;
|
|
140
|
+
/**
|
|
141
|
+
* The server's rights in a caller's namespace: the operator's ClusterRole, bound there
|
|
142
|
+
* only, so the server needs no rights on Secrets or pods anywhere else.
|
|
143
|
+
*/
|
|
144
|
+
function getRunRoleBinding(namespace, role, subject) {
|
|
145
|
+
return {
|
|
146
|
+
apiVersion: 'rbac.authorization.k8s.io/v1',
|
|
147
|
+
kind: 'RoleBinding',
|
|
148
|
+
metadata: {
|
|
149
|
+
name: 'hammerkit-runs',
|
|
150
|
+
namespace,
|
|
151
|
+
annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
|
|
152
|
+
labels: { [MANAGED_LABEL]: 'true' },
|
|
153
|
+
},
|
|
154
|
+
roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'ClusterRole', name: role },
|
|
155
|
+
subjects: [subject],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
exports.getRunRoleBinding = getRunRoleBinding;
|
|
159
|
+
/**
|
|
160
|
+
* Runs the tasks of runs as Jobs on a cluster. A run is planned and driven by a
|
|
161
|
+
* process of the server as on a host (see HostExecutor); what changes is where its
|
|
162
|
+
* container tasks and services go: into the caller's namespace, on the nodes of the
|
|
163
|
+
* operator's pools, with the secrets of providers read in the Job itself. The runs
|
|
164
|
+
* of one caller execute one after the other, since the objects of a task are named
|
|
165
|
+
* by its cache id in the caller's namespace; different callers run side by side.
|
|
166
|
+
* When a run ends, everything made for it is removed, but for the claims of its tasks
|
|
167
|
+
* the server keeps (`backend.keepVolumes`), which go once they expired.
|
|
168
|
+
*/
|
|
169
|
+
class KubernetesExecutor {
|
|
170
|
+
constructor(config, processEnvs, options) {
|
|
171
|
+
this.config = config;
|
|
172
|
+
// the registry keeps a caller's next run in its queue, not running and timed here
|
|
173
|
+
this.runningPerCaller = 1;
|
|
174
|
+
// the run that executes, or the last one waiting, per caller namespace
|
|
175
|
+
this.queues = new Map();
|
|
176
|
+
// per caller namespace, when its kept claims are next looked at
|
|
177
|
+
this.sweeps = new Map();
|
|
178
|
+
// whether the kept claims a server left when it stopped were looked for
|
|
179
|
+
this.swept = false;
|
|
180
|
+
this.access = getClusterAccess(config.backend, processEnvs);
|
|
181
|
+
this.instance = (0, kubernetes_instance_1.createKubernetesInstances)({
|
|
182
|
+
type: 'kubernetes',
|
|
183
|
+
namespace: 'default',
|
|
184
|
+
kubeConfig: this.access.kubeConfig,
|
|
185
|
+
context: this.access.context,
|
|
186
|
+
httpRoutes: [],
|
|
187
|
+
networkPolicyEnforced: config.backend.networkPolicy === 'enforced',
|
|
188
|
+
});
|
|
189
|
+
const namespace = config.backend.namespace ?? readInClusterNamespace();
|
|
190
|
+
if (!namespace) {
|
|
191
|
+
throw new Error('backend.namespace: name the namespace of the server, where the credentials of the accounts are; ' +
|
|
192
|
+
'it is only found on its own in a pod of the cluster');
|
|
193
|
+
}
|
|
194
|
+
this.namespace = namespace;
|
|
195
|
+
this.namespaces = new CallerNamespaces(this.instance, config.backend);
|
|
196
|
+
this.keep = config.backend.keepVolumes === false ? 0 : (0, units_1.parseDuration)(config.backend.keepVolumes);
|
|
197
|
+
this.log = options.log;
|
|
198
|
+
this.host = new executor_1.HostExecutor({ ...options, placement: this.placement() });
|
|
199
|
+
}
|
|
200
|
+
execute(run, context) {
|
|
201
|
+
const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
|
|
202
|
+
return this.enqueue(namespace, () => {
|
|
203
|
+
if (context.signal.aborted) {
|
|
204
|
+
throw new Error('the run was cancelled');
|
|
205
|
+
}
|
|
206
|
+
return this.host.execute(run, context);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
// What happens in a caller's namespace happens one at a time: their runs, and the
|
|
210
|
+
// removal of the claims their runs kept
|
|
211
|
+
enqueue(namespace, fn) {
|
|
212
|
+
const previous = this.queues.get(namespace) ?? Promise.resolve();
|
|
213
|
+
const execution = previous.then(fn);
|
|
214
|
+
const settled = execution.then(() => undefined, () => undefined);
|
|
215
|
+
this.queues.set(namespace, settled);
|
|
216
|
+
settled.then(() => {
|
|
217
|
+
if (this.queues.get(namespace) === settled) {
|
|
218
|
+
this.queues.delete(namespace);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return execution;
|
|
222
|
+
}
|
|
223
|
+
placement() {
|
|
224
|
+
const backend = this.config.backend;
|
|
225
|
+
return {
|
|
226
|
+
prepare: async (run, context, work) => {
|
|
227
|
+
const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
|
|
228
|
+
await this.namespaces.prepare(namespace);
|
|
229
|
+
// what an earlier run left when the server stopped before its end; the runs
|
|
230
|
+
// of a caller execute one at a time, so no other run is in the namespace
|
|
231
|
+
await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL}!=${(0, job_placement_1.getRunLabelValue)(run.id)}`, RELEASE_TIMEOUT);
|
|
232
|
+
// the claims earlier runs kept that expired; the others this run may mount
|
|
233
|
+
const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
|
|
234
|
+
now: new Date(),
|
|
235
|
+
keep: this.keep > 0,
|
|
236
|
+
timeout: RELEASE_TIMEOUT,
|
|
237
|
+
});
|
|
238
|
+
if (next) {
|
|
239
|
+
this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
|
|
240
|
+
}
|
|
241
|
+
this.sweepLeftClaims();
|
|
242
|
+
context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
|
|
243
|
+
const kubernetes = {
|
|
244
|
+
type: 'kubernetes',
|
|
245
|
+
namespace,
|
|
246
|
+
kubeConfig: this.access.kubeConfig,
|
|
247
|
+
context: this.access.context,
|
|
248
|
+
httpRoutes: [],
|
|
249
|
+
storageClass: backend.storageClass,
|
|
250
|
+
volumeSize: backend.volumeSize,
|
|
251
|
+
maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
|
|
252
|
+
networkPolicyEnforced: backend.networkPolicy === 'enforced',
|
|
253
|
+
server: {
|
|
254
|
+
run: run.id,
|
|
255
|
+
namespace: this.namespace,
|
|
256
|
+
pools: backend.pools,
|
|
257
|
+
providers: backend.providers,
|
|
258
|
+
accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
|
|
259
|
+
...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace) } : {}),
|
|
260
|
+
...(this.keep > 0 ? { volumeScope: getVolumeScope(run) } : {}),
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
return { kubernetes, env: this.access.env };
|
|
264
|
+
},
|
|
265
|
+
release: async (run, context) => {
|
|
266
|
+
const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
|
|
267
|
+
try {
|
|
268
|
+
const selector = `${job_placement_1.RUN_LABEL}=${(0, job_placement_1.getRunLabelValue)(run.id)}`;
|
|
269
|
+
await removeRunObjects(this.instance, namespace, selector, RELEASE_TIMEOUT);
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
context.emit({
|
|
273
|
+
type: 'log',
|
|
274
|
+
task: 'hammerkit',
|
|
275
|
+
stream: 'stderr',
|
|
276
|
+
text: `what the run made in ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(e)}`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (this.keep > 0) {
|
|
280
|
+
try {
|
|
281
|
+
await (0, kept_claims_1.expireKeptClaims)(this.instance, namespace, (0, job_placement_1.getRunLabelValue)(run.id), new Date(Date.now() + this.keep));
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
// without an expiry, the next look at the namespace removes them
|
|
285
|
+
context.emit({
|
|
286
|
+
type: 'log',
|
|
287
|
+
task: 'hammerkit',
|
|
288
|
+
stream: 'stderr',
|
|
289
|
+
text: `the claims the run kept in ${namespace} could not be given their expiry: ${(0, log_1.getErrorMessage)(e)}`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
this.sweepLater(namespace, this.keep);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// The kept claims of a namespace that expired go once no run of the caller is in it: at
|
|
298
|
+
// the soonest expiry the server knows of
|
|
299
|
+
sweepLater(namespace, delay) {
|
|
300
|
+
const at = Date.now() + delay + 1000;
|
|
301
|
+
const pending = this.sweeps.get(namespace);
|
|
302
|
+
if (pending && pending.at <= at) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
clearTimeout(pending?.timer);
|
|
306
|
+
const timer = setTimeout(() => {
|
|
307
|
+
this.sweeps.delete(namespace);
|
|
308
|
+
this.enqueue(namespace, () => this.sweep(namespace)).catch((e) => this.log?.(`the kept claims in ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(e)}`));
|
|
309
|
+
}, at - Date.now());
|
|
310
|
+
timer.unref();
|
|
311
|
+
this.sweeps.set(namespace, { at, timer });
|
|
312
|
+
}
|
|
313
|
+
async sweep(namespace) {
|
|
314
|
+
const { removed, next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
|
|
315
|
+
now: new Date(),
|
|
316
|
+
keep: this.keep > 0,
|
|
317
|
+
timeout: RELEASE_TIMEOUT,
|
|
318
|
+
});
|
|
319
|
+
if (removed.length > 0) {
|
|
320
|
+
this.log?.(`removed ${removed.length} kept claims in ${namespace}`);
|
|
321
|
+
}
|
|
322
|
+
if (next) {
|
|
323
|
+
this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Once, on the first run: the claims the server kept before it stopped, in the namespaces
|
|
327
|
+
// of other callers too, whose next run may be far off
|
|
328
|
+
sweepLeftClaims() {
|
|
329
|
+
if (this.swept) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.swept = true;
|
|
333
|
+
Promise.resolve()
|
|
334
|
+
.then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true` }))
|
|
335
|
+
.then((namespaces) => {
|
|
336
|
+
for (const namespace of namespaces.items) {
|
|
337
|
+
const name = namespace.metadata?.name;
|
|
338
|
+
if (name?.startsWith(this.config.backend.namespacePrefix) && !namespace.metadata?.deletionTimestamp) {
|
|
339
|
+
this.sweepLater(name, 0);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
.catch((e) => this.log?.(`the namespaces of callers could not be listed: ${(0, log_1.getErrorMessage)(e)}`));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
exports.KubernetesExecutor = KubernetesExecutor;
|
|
347
|
+
// What may mount a claim a run kept: the runs of the same repository, as they share the
|
|
348
|
+
// cache, and on a sandbox the runs of that sandbox only, whose claim is on the same node or
|
|
349
|
+
// zone as the kept ones. The owner is the namespace's.
|
|
350
|
+
function getVolumeScope(run) {
|
|
351
|
+
if (run.source.type === 'sandbox') {
|
|
352
|
+
return `sandbox-${(0, crypto_1.createHash)('sha256').update(run.source.id).digest('hex').slice(0, 24)}`;
|
|
353
|
+
}
|
|
354
|
+
return (0, executor_1.cacheNamespace)(run);
|
|
355
|
+
}
|
|
356
|
+
exports.getVolumeScope = getVolumeScope;
|
|
357
|
+
/**
|
|
358
|
+
* The namespaces of callers on the cluster: named by the caller (getCallerNamespace),
|
|
359
|
+
* made on first use, with the operator's quota.
|
|
360
|
+
*/
|
|
361
|
+
class CallerNamespaces {
|
|
362
|
+
constructor(instance, backend) {
|
|
363
|
+
this.instance = instance;
|
|
364
|
+
this.backend = backend;
|
|
365
|
+
}
|
|
366
|
+
name(owner) {
|
|
367
|
+
return getCallerNamespace(this.backend.namespacePrefix, owner);
|
|
368
|
+
}
|
|
369
|
+
// The caller's namespace: made by the server and kept apart from the others. The
|
|
370
|
+
// server binds its rights there, isolates the network of the pods, applies the
|
|
371
|
+
// operator's quota and gives containers without resources default requests, so they
|
|
372
|
+
// are admitted under a quota on compute. All of it is replaced on every run, so the
|
|
373
|
+
// namespace follows the config.
|
|
374
|
+
async prepare(namespace) {
|
|
375
|
+
const backend = this.backend;
|
|
376
|
+
let existing;
|
|
377
|
+
try {
|
|
378
|
+
existing = await this.instance.coreApi.readNamespace({ name: namespace });
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (existing && existing.metadata?.labels?.[MANAGED_LABEL] !== 'true') {
|
|
386
|
+
throw new Error(`the namespace ${namespace} was not made by hammerkit, the server does not run tasks in it`);
|
|
387
|
+
}
|
|
388
|
+
if (!existing) {
|
|
389
|
+
try {
|
|
390
|
+
await this.instance.coreApi.createNamespace({
|
|
391
|
+
body: {
|
|
392
|
+
metadata: {
|
|
393
|
+
name: namespace,
|
|
394
|
+
labels: {
|
|
395
|
+
[MANAGED_LABEL]: 'true',
|
|
396
|
+
'hammerkit.dev/caller': namespace,
|
|
397
|
+
// no privileged pods, host paths or host network, whatever a build asks
|
|
398
|
+
'pod-security.kubernetes.io/enforce': 'baseline',
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch (createError) {
|
|
405
|
+
if ((0, apply_1.statusCodeOf)(createError) !== 409) {
|
|
406
|
+
throw createError;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
await (0, apply_1.replace)(this.instance, getRunRoleBinding(namespace, backend.runRole, await this.whoAmI()));
|
|
411
|
+
const [first, ...policies] = getNamespacePolicies(namespace, backend.deniedEgress, backend.allowedEgress);
|
|
412
|
+
// the API server may refuse what the binding allows for a moment after it was made
|
|
413
|
+
await untilAllowed(() => (0, apply_1.replace)(this.instance, first));
|
|
414
|
+
for (const policy of policies) {
|
|
415
|
+
await (0, apply_1.replace)(this.instance, policy);
|
|
416
|
+
}
|
|
417
|
+
const metadata = {
|
|
418
|
+
name: POLICY_NAME,
|
|
419
|
+
namespace,
|
|
420
|
+
annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
|
|
421
|
+
labels: { [MANAGED_LABEL]: 'true' },
|
|
422
|
+
};
|
|
423
|
+
const quota = {
|
|
424
|
+
apiVersion: 'v1',
|
|
425
|
+
kind: 'ResourceQuota',
|
|
426
|
+
metadata,
|
|
427
|
+
spec: { hard: backend.quota },
|
|
428
|
+
};
|
|
429
|
+
const defaults = backend.defaultResources;
|
|
430
|
+
const limits = {
|
|
431
|
+
apiVersion: 'v1',
|
|
432
|
+
kind: 'LimitRange',
|
|
433
|
+
metadata,
|
|
434
|
+
spec: {
|
|
435
|
+
limits: [
|
|
436
|
+
{
|
|
437
|
+
type: 'Container',
|
|
438
|
+
...(defaults?.requests ? { defaultRequest: defaults.requests } : {}),
|
|
439
|
+
...(defaults?.limits ? { _default: defaults.limits } : {}),
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
for (const [object, wanted] of [
|
|
445
|
+
[quota, !!backend.quota],
|
|
446
|
+
[limits, !!defaults],
|
|
447
|
+
]) {
|
|
448
|
+
if (wanted) {
|
|
449
|
+
await (0, apply_1.replace)(this.instance, object);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
// one the config had before
|
|
453
|
+
await this.instance.objectApi.delete(object).catch((e) => {
|
|
454
|
+
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
455
|
+
throw e;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
whoAmI() {
|
|
462
|
+
if (!this.subject) {
|
|
463
|
+
this.subject = this.instance.authApi
|
|
464
|
+
.createSelfSubjectReview({ body: { apiVersion: 'authentication.k8s.io/v1', kind: 'SelfSubjectReview' } })
|
|
465
|
+
.then((review) => {
|
|
466
|
+
const name = review.status?.userInfo?.username;
|
|
467
|
+
if (!name) {
|
|
468
|
+
throw new Error('the cluster did not say who the server is (SelfSubjectReview)');
|
|
469
|
+
}
|
|
470
|
+
return { kind: 'User', apiGroup: 'rbac.authorization.k8s.io', name };
|
|
471
|
+
});
|
|
472
|
+
this.subject.catch(() => {
|
|
473
|
+
this.subject = undefined;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return this.subject;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
exports.CallerNamespaces = CallerNamespaces;
|
|
480
|
+
// The claim of a sandbox as the Jobs of its run mount it: the run's copy of the files is
|
|
481
|
+
// `root`, a source below it is the same path below `subPath` in the claim.
|
|
482
|
+
function getWorkspace(root, claim, namespace) {
|
|
483
|
+
if (claim.namespace !== namespace) {
|
|
484
|
+
throw new Error(`the sandbox is in ${claim.namespace}, the run in ${namespace}`);
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
root,
|
|
488
|
+
claim: claim.name,
|
|
489
|
+
subPath: claim.subPath,
|
|
490
|
+
label: claim.label,
|
|
491
|
+
...(claim.pod ? { pod: claim.pod } : {}),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
// Everything a run made in the caller's namespace, by the label selector of the run:
|
|
495
|
+
// Jobs with their pods, Deployments (services and egress proxies), Services, claims,
|
|
496
|
+
// Secrets and the NetworkPolicies of limited networks, which go last so no pod of the
|
|
497
|
+
// run outlives its policy.
|
|
498
|
+
// Deletes what a run made. The NetworkPolicies go last, once the pods are: a limited
|
|
499
|
+
// pod's own policy is all that limits it.
|
|
500
|
+
async function removeRunObjects(instance, namespace, labelSelector, timeout) {
|
|
501
|
+
const selector = { namespace, labelSelector };
|
|
502
|
+
await instance.batchApi.deleteCollectionNamespacedJob({ ...selector, propagationPolicy: 'Background' });
|
|
503
|
+
await instance.appsApi.deleteCollectionNamespacedDeployment({ ...selector, propagationPolicy: 'Background' });
|
|
504
|
+
await instance.coreApi.deleteCollectionNamespacedPod({ ...selector, gracePeriodSeconds: 0 });
|
|
505
|
+
await instance.coreApi.deleteCollectionNamespacedService(selector);
|
|
506
|
+
await instance.coreApi.deleteCollectionNamespacedSecret(selector);
|
|
507
|
+
await instance.coreApi.deleteCollectionNamespacedPersistentVolumeClaim(selector);
|
|
508
|
+
// the next run of the caller also uses the same claim names
|
|
509
|
+
await awaitRunObjectsGone(instance, namespace, labelSelector, timeout);
|
|
510
|
+
const policies = await instance.objectApi.list('networking.k8s.io/v1', 'NetworkPolicy', namespace, undefined, undefined, undefined, undefined, labelSelector);
|
|
511
|
+
for (const policy of policies.items) {
|
|
512
|
+
// the items of a list do not say their kind
|
|
513
|
+
const metadata = { name: policy.metadata?.name, namespace };
|
|
514
|
+
await instance.objectApi
|
|
515
|
+
.delete({ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy', metadata })
|
|
516
|
+
.catch((e) => {
|
|
517
|
+
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
518
|
+
throw e;
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
exports.removeRunObjects = removeRunObjects;
|
|
524
|
+
async function untilAllowed(fn, attempts = 20) {
|
|
525
|
+
for (let attempt = 1;; attempt++) {
|
|
526
|
+
try {
|
|
527
|
+
return await fn();
|
|
528
|
+
}
|
|
529
|
+
catch (e) {
|
|
530
|
+
if ((0, apply_1.statusCodeOf)(e) !== 403 || attempt >= attempts) {
|
|
531
|
+
throw e;
|
|
532
|
+
}
|
|
533
|
+
await (0, sleep_1.sleep)(500);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Until the pods and claims of a run are gone: a claim is kept while a pod uses it.
|
|
538
|
+
async function awaitRunObjectsGone(instance, namespace, labelSelector, timeout) {
|
|
539
|
+
const deadline = Date.now() + timeout;
|
|
540
|
+
for (;;) {
|
|
541
|
+
const pods = await instance.coreApi.listNamespacedPod({ namespace, labelSelector });
|
|
542
|
+
const claims = await instance.coreApi.listNamespacedPersistentVolumeClaim({ namespace, labelSelector });
|
|
543
|
+
const left = [...pods.items, ...claims.items].map((item) => `${item.kind ?? ''} ${item.metadata?.name}`.trim());
|
|
544
|
+
if (left.length === 0) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (Date.now() > deadline) {
|
|
548
|
+
throw new Error(`still there after ${Math.round(timeout / 1000)}s: ${left.join(', ')}`);
|
|
549
|
+
}
|
|
550
|
+
await (0, sleep_1.sleep)(1000);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
exports.awaitRunObjectsGone = awaitRunObjectsGone;
|
|
554
|
+
//# sourceMappingURL=kubernetes-backend.js.map
|