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