hammerkit 1.26.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/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- package/dist/executer/execute-command.js +18 -5
- package/dist/executer/execute-docker.js +4 -1
- package/dist/executer/execution-steps.js +33 -7
- package/dist/executer/get-docker-executor.js +14 -7
- package/dist/executer/scheduler/enqueue-next.js +17 -3
- package/dist/executer/watch-loop.js +2 -2
- package/dist/executer/watch-state-key.js +1 -1
- package/dist/file/within-root.js +42 -0
- package/dist/harness/connect.js +99 -0
- package/dist/harness/format.js +73 -0
- package/dist/harness/mcp-command.js +59 -0
- package/dist/harness/mcp.js +432 -0
- package/dist/harness/opencode.js +492 -0
- package/dist/harness/pi.js +217 -0
- package/dist/harness/sandbox-client.js +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -0
- package/dist/network/port-relay.js +35 -0
- package/dist/optimizer/get-work-cache-stats.js +16 -0
- package/dist/optimizer/work-task-cache-description.js +3 -2
- package/dist/planner/service-definition.js +5 -0
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +41 -0
- package/dist/planner/work-runtime-kubernetes.js +335 -102
- package/dist/program.js +21 -0
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- package/dist/remote/server-client.js +18 -5
- package/dist/remote/source-manifest.js +15 -2
- package/dist/remote/token-source.js +67 -0
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -0
- package/dist/schema/build-file-container-task-schema.js +3 -0
- package/dist/schema/build-file-environment-schema.js +9 -0
- package/dist/schema/network-schema.js +78 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +5 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +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
package/dist/kubernetes/apply.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.apply = exports.statusCodeOf = void 0;
|
|
3
|
+
exports.replace = exports.apply = exports.statusCodeOf = void 0;
|
|
4
4
|
// The client throws an ApiException carrying the HTTP status as `code`; watch
|
|
5
5
|
// errors carry it as `statusCode`.
|
|
6
6
|
function statusCodeOf(e) {
|
|
@@ -30,4 +30,34 @@ async function apply(instance, spec) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
exports.apply = apply;
|
|
33
|
+
/**
|
|
34
|
+
* Makes the object exactly as given: replaced as a whole when it exists, so what was
|
|
35
|
+
* dropped from it goes too, which a merge patch would keep (a key of a quota, a rule
|
|
36
|
+
* of a policy).
|
|
37
|
+
*/
|
|
38
|
+
async function replace(instance, spec) {
|
|
39
|
+
let current;
|
|
40
|
+
try {
|
|
41
|
+
current = await instance.objectApi.read(spec);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (statusCodeOf(e) !== 404) {
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return await instance.objectApi.create(spec);
|
|
49
|
+
}
|
|
50
|
+
catch (createError) {
|
|
51
|
+
if (statusCodeOf(createError) !== 409) {
|
|
52
|
+
throw createError;
|
|
53
|
+
}
|
|
54
|
+
current = await instance.objectApi.read(spec);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return await instance.objectApi.replace({
|
|
58
|
+
...spec,
|
|
59
|
+
metadata: { ...spec.metadata, resourceVersion: current.metadata?.resourceVersion },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
exports.replace = replace;
|
|
33
63
|
//# sourceMappingURL=apply.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.deleteJobAndWait = exports.awaitDeployRunningState = exports.deleteJob = exports.awaitJobCompletion = exports.awaitRunningState = void 0;
|
|
3
|
+
exports.stopJob = exports.deleteJobAndWait = exports.awaitDeployRunningState = exports.deleteJob = exports.awaitJobCompletion = exports.awaitRunningState = void 0;
|
|
4
4
|
const abort_1 = require("../executer/abort");
|
|
5
5
|
const abort_event_1 = require("../utils/abort-event");
|
|
6
6
|
const apply_1 = require("./apply");
|
|
@@ -126,4 +126,36 @@ async function deleteJobAndWait(instance, env, name, abort, pollInterval = 500)
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
exports.deleteJobAndWait = deleteJobAndWait;
|
|
129
|
+
// Stops a Job at once: its pods go without a grace period, and this returns once they
|
|
130
|
+
// are gone, so what limited them (the item's NetworkPolicy) can go after them.
|
|
131
|
+
async function stopJob(instance, env, name, timeout = 30000, pollInterval = 500) {
|
|
132
|
+
await deleteJob(instance, env, name);
|
|
133
|
+
const deadline = Date.now() + timeout;
|
|
134
|
+
for (;;) {
|
|
135
|
+
const pods = await instance.coreApi.listNamespacedPod({
|
|
136
|
+
namespace: env.namespace,
|
|
137
|
+
labelSelector: `job-name=${name}`,
|
|
138
|
+
});
|
|
139
|
+
if (pods.items.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (Date.now() > deadline) {
|
|
143
|
+
throw new Error(`the pods of ${name} were still there after ${Math.round(timeout / 1000)}s`);
|
|
144
|
+
}
|
|
145
|
+
for (const pod of pods.items) {
|
|
146
|
+
// also one the Job's deletion already ends, with the default grace period
|
|
147
|
+
if (pod.metadata?.name) {
|
|
148
|
+
await instance.coreApi
|
|
149
|
+
.deleteNamespacedPod({ name: pod.metadata.name, namespace: env.namespace, gracePeriodSeconds: 0 })
|
|
150
|
+
.catch((e) => {
|
|
151
|
+
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await (0, sleep_1.sleep)(pollInterval);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
exports.stopJob = stopJob;
|
|
129
161
|
//# sourceMappingURL=await-running-state.js.map
|
|
@@ -1,32 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ensureKubernetesDeploymentExists = void 0;
|
|
4
|
-
const
|
|
3
|
+
exports.ensureKubernetesDeploymentExists = exports.DEFINITION_LABEL = void 0;
|
|
4
|
+
const service_definition_1 = require("../planner/service-definition");
|
|
5
5
|
const work_service_1 = require("../planner/work-service");
|
|
6
|
+
const volumes_1 = require("./volumes");
|
|
6
7
|
const service_hostname_1 = require("../planner/utils/service-hostname");
|
|
7
8
|
const apply_1 = require("./apply");
|
|
8
|
-
const get_service_ip_1 = require("./get-service-ip");
|
|
9
9
|
const version_1 = require("../version");
|
|
10
10
|
const replace_env_variables_1 = require("../environment/replace-env-variables");
|
|
11
11
|
const await_running_state_1 = require("./await-running-state");
|
|
12
12
|
const resources_1 = require("./resources");
|
|
13
13
|
const work_healthcheck_1 = require("../planner/work-healthcheck");
|
|
14
14
|
const container_resources_1 = require("./container-resources");
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
throw new Error(`unable to get service ip for ${need.name}`);
|
|
24
|
-
}
|
|
25
|
-
hostAliases.push({
|
|
26
|
-
ip,
|
|
27
|
-
hostnames: [need.name],
|
|
28
|
-
});
|
|
29
|
-
}
|
|
15
|
+
const job_placement_1 = require("./job-placement");
|
|
16
|
+
const network_policy_1 = require("./network-policy");
|
|
17
|
+
const egress_proxy_1 = require("../network/egress-proxy");
|
|
18
|
+
// what the service was created from, to tell a changed one
|
|
19
|
+
exports.DEFINITION_LABEL = 'hammerkit.dev/definition';
|
|
20
|
+
async function ensureKubernetesDeploymentExists(instance, env, service, persistence, secrets, stateKey,
|
|
21
|
+
// the needed services by name, and the proxy of a limited network
|
|
22
|
+
network) {
|
|
30
23
|
const envs = (0, replace_env_variables_1.getEnvironmentVariables)(service.data.envs);
|
|
31
24
|
const name = (0, resources_1.getResourceName)(service);
|
|
32
25
|
const healthcheckCommand = (0, work_service_1.getHealthcheckCommand)(service.data);
|
|
@@ -34,12 +27,16 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
|
|
|
34
27
|
const probe = healthcheckCommand
|
|
35
28
|
? {
|
|
36
29
|
exec: {
|
|
37
|
-
|
|
30
|
+
// the service itself, not through the proxy (see getProxylessEnvironment)
|
|
31
|
+
command: withoutProxy(healthcheckCommand, network.env),
|
|
38
32
|
},
|
|
39
33
|
periodSeconds: 5,
|
|
40
34
|
failureThreshold: 3,
|
|
41
35
|
}
|
|
42
36
|
: undefined;
|
|
37
|
+
const server = env.server;
|
|
38
|
+
const placement = server ? (0, job_placement_1.getPodPlacement)(server, undefined) : null;
|
|
39
|
+
const workspace = (0, volumes_1.getWorkspacePodSpec)(env, persistence);
|
|
43
40
|
const deployment = {
|
|
44
41
|
kind: 'Deployment',
|
|
45
42
|
apiVersion: 'apps/v1',
|
|
@@ -52,6 +49,8 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
|
|
|
52
49
|
labels: {
|
|
53
50
|
'hammerkit.dev/id': service.id(),
|
|
54
51
|
'hammerkit.dev/state': stateKey,
|
|
52
|
+
...(0, job_placement_1.getRunLabels)(env),
|
|
53
|
+
[exports.DEFINITION_LABEL]: (0, service_definition_1.getServiceDefinitionHash)(service),
|
|
55
54
|
},
|
|
56
55
|
},
|
|
57
56
|
spec: {
|
|
@@ -66,12 +65,26 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
|
|
|
66
65
|
labels: {
|
|
67
66
|
'hammerkit.dev/id': service.id(),
|
|
68
67
|
'hammerkit.dev/state': stateKey,
|
|
68
|
+
...(0, job_placement_1.getRunLabels)(env),
|
|
69
|
+
...workspace.labels,
|
|
70
|
+
...(0, network_policy_1.getNetworkLabels)(env, service),
|
|
69
71
|
},
|
|
70
72
|
},
|
|
71
73
|
spec: {
|
|
74
|
+
...(workspace.affinity ? { affinity: workspace.affinity } : {}),
|
|
72
75
|
// the pod resolves its own service name, as with Docker's Hostname
|
|
73
76
|
hostname: (0, service_hostname_1.getServiceHostname)(service.name),
|
|
74
|
-
hostAliases: hostAliases,
|
|
77
|
+
hostAliases: network.hostAliases,
|
|
78
|
+
initContainers: network.initContainers,
|
|
79
|
+
// on a server the pod is the build's: no token of the namespace, and the
|
|
80
|
+
// nodes of the first pool
|
|
81
|
+
...(placement
|
|
82
|
+
? {
|
|
83
|
+
automountServiceAccountToken: false,
|
|
84
|
+
nodeSelector: placement.nodeSelector,
|
|
85
|
+
tolerations: placement.tolerations,
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
75
88
|
containers: [
|
|
76
89
|
{
|
|
77
90
|
name: service.name.replace(/:/, '-'),
|
|
@@ -83,6 +96,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
|
|
|
83
96
|
value: value,
|
|
84
97
|
})),
|
|
85
98
|
...secrets.env,
|
|
99
|
+
...network.env,
|
|
86
100
|
],
|
|
87
101
|
...getKubernetesCommand(serviceCommand),
|
|
88
102
|
ports: service.data.ports.map((p) => ({
|
|
@@ -92,6 +106,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
|
|
|
92
106
|
livenessProbe: probe,
|
|
93
107
|
...(0, container_resources_1.getKubernetesResources)(service.data.resources),
|
|
94
108
|
volumeMounts: [...persistence.mounts.map((m) => m.mount), ...secrets.mounts],
|
|
109
|
+
...(0, job_placement_1.getContainerSecurity)(env),
|
|
95
110
|
},
|
|
96
111
|
{
|
|
97
112
|
name: 'debug',
|
|
@@ -99,9 +114,10 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
|
|
|
99
114
|
command: ['sleep'],
|
|
100
115
|
args: ['3600'],
|
|
101
116
|
volumeMounts: persistence.mounts.map((m) => m.mount),
|
|
117
|
+
...(0, job_placement_1.getContainerSecurity)(env),
|
|
102
118
|
},
|
|
103
119
|
],
|
|
104
|
-
volumes: [...persistence.volumes, ...secrets.volumes],
|
|
120
|
+
volumes: [...persistence.volumes, ...persistence.workspace, ...secrets.volumes],
|
|
105
121
|
},
|
|
106
122
|
},
|
|
107
123
|
},
|
|
@@ -131,4 +147,10 @@ function getKubernetesCommand(command) {
|
|
|
131
147
|
}
|
|
132
148
|
return {};
|
|
133
149
|
}
|
|
150
|
+
// A probe runs with the container's variables, so it empties the proxy ones
|
|
151
|
+
// through `env`, as a Docker healthcheck does with the exec's own variables.
|
|
152
|
+
function withoutProxy(command, env) {
|
|
153
|
+
const proxyless = (0, egress_proxy_1.getProxylessEnvironment)(Object.fromEntries(env.map((e) => [e.name, e.value ?? ''])));
|
|
154
|
+
return proxyless ? ['env', ...Object.keys(proxyless).map((key) => `${key}=`), ...command] : command;
|
|
155
|
+
}
|
|
134
156
|
//# sourceMappingURL=ensure-kubernetes-deployment-exists.js.map
|
|
@@ -1,9 +1,46 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ensureKubernetesPersistentVolumeClaimExists = void 0;
|
|
3
|
+
exports.ensureKubernetesPersistentVolumeClaimExists = exports.getClaimSize = exports.DEFAULT_VOLUME_SIZE = void 0;
|
|
4
4
|
const apply_1 = require("./apply");
|
|
5
5
|
const version_1 = require("../version");
|
|
6
|
-
|
|
6
|
+
const job_placement_1 = require("./job-placement");
|
|
7
|
+
const log_1 = require("../log");
|
|
8
|
+
const units_1 = require("../utils/units");
|
|
9
|
+
const sleep_1 = require("../utils/sleep");
|
|
10
|
+
const kept_claims_1 = require("./kept-claims");
|
|
11
|
+
// how long a claim that is being removed (a kept one that expired) gets to go before one
|
|
12
|
+
// of its name is made again, and the kept claims of other sources when the quota is full
|
|
13
|
+
const REMOVAL_TIMEOUT = 60000;
|
|
14
|
+
// what the claim of a task or service requests when neither it nor the environment says
|
|
15
|
+
exports.DEFAULT_VOLUME_SIZE = '1Gi';
|
|
16
|
+
// The size of a claim: the work's `volumeSize`, else the environment's, at most what the
|
|
17
|
+
// environment allows (a server's `maxVolumeSize`).
|
|
18
|
+
function getClaimSize(env, size, name) {
|
|
19
|
+
const requested = size ?? env.volumeSize ?? exports.DEFAULT_VOLUME_SIZE;
|
|
20
|
+
if (env.maxVolumeSize && (0, units_1.parseSize)(requested) > (0, units_1.parseSize)(env.maxVolumeSize)) {
|
|
21
|
+
throw new Error(`${name} asks for a claim of ${requested}, the server allows at most ${env.maxVolumeSize}`);
|
|
22
|
+
}
|
|
23
|
+
return requested;
|
|
24
|
+
}
|
|
25
|
+
exports.getClaimSize = getClaimSize;
|
|
26
|
+
// The size a claim requests, in bytes, as the cluster may write it back (`1Gi`, bytes)
|
|
27
|
+
function getRequestedBytes(claim) {
|
|
28
|
+
const storage = claim.spec?.resources?.requests?.storage;
|
|
29
|
+
try {
|
|
30
|
+
return storage === undefined ? null : (0, units_1.parseSize)(String(storage));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// The claim of a work item, of `size` (see getClaimSize). One a run before made smaller
|
|
37
|
+
// grows, where its storage class allows that; a larger one stays as it is, since a claim
|
|
38
|
+
// never shrinks. A `kept` claim (see kept-claims) carries the scope of the run instead of
|
|
39
|
+
// its label, and does not expire while the run mounts it.
|
|
40
|
+
async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume, service, size, kept = false) {
|
|
41
|
+
const storage = getClaimSize(env, size, service.name);
|
|
42
|
+
const scope = kept ? env.server?.volumeScope : undefined;
|
|
43
|
+
const runLabel = env.server ? (0, job_placement_1.getRunLabelValue)(env.server.run) : undefined;
|
|
7
44
|
const pvc = {
|
|
8
45
|
kind: 'PersistentVolumeClaim',
|
|
9
46
|
apiVersion: 'v1',
|
|
@@ -15,6 +52,7 @@ async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume
|
|
|
15
52
|
},
|
|
16
53
|
labels: {
|
|
17
54
|
'hammerkit.dev/id': service.id(),
|
|
55
|
+
...(scope && runLabel ? { [kept_claims_1.VOLUME_SCOPE_LABEL]: scope, [kept_claims_1.KEPT_BY_LABEL]: runLabel } : (0, job_placement_1.getRunLabels)(env)),
|
|
18
56
|
},
|
|
19
57
|
},
|
|
20
58
|
spec: {
|
|
@@ -22,7 +60,7 @@ async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume
|
|
|
22
60
|
storageClassName: env.storageClass,
|
|
23
61
|
resources: {
|
|
24
62
|
requests: {
|
|
25
|
-
storage
|
|
63
|
+
storage,
|
|
26
64
|
},
|
|
27
65
|
},
|
|
28
66
|
},
|
|
@@ -34,7 +72,96 @@ async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume
|
|
|
34
72
|
// mounts it — and hammerkit creates that pod (upload pod / deployment) only
|
|
35
73
|
// after this call. Waiting here deadlocks until the test times out. k8s binds
|
|
36
74
|
// the claim when the consuming pod is scheduled, so applying it is enough.
|
|
37
|
-
|
|
75
|
+
let existing = await readClaim(instance, pvc);
|
|
76
|
+
if (existing?.metadata?.deletionTimestamp) {
|
|
77
|
+
// a kept claim that expired: one of the same name is made once it is gone
|
|
78
|
+
existing = await untilRemoved(instance, pvc);
|
|
79
|
+
}
|
|
80
|
+
if (!existing) {
|
|
81
|
+
return await createClaim(instance, env, pvc);
|
|
82
|
+
}
|
|
83
|
+
if (scope) {
|
|
84
|
+
if (existing.metadata?.labels?.[kept_claims_1.VOLUME_SCOPE_LABEL] !== scope) {
|
|
85
|
+
throw new Error(`the claim ${volume.name} is not one this run may mount, it was made for another source`);
|
|
86
|
+
}
|
|
87
|
+
if (existing.metadata?.labels?.[kept_claims_1.KEPT_BY_LABEL] !== runLabel ||
|
|
88
|
+
existing.metadata?.annotations?.[kept_claims_1.EXPIRES_ANNOTATION]) {
|
|
89
|
+
// mounted by this run now: it expires at the run's end
|
|
90
|
+
existing = await instance.objectApi.patch({
|
|
91
|
+
apiVersion: pvc.apiVersion,
|
|
92
|
+
kind: pvc.kind,
|
|
93
|
+
metadata: {
|
|
94
|
+
name: pvc.metadata.name,
|
|
95
|
+
namespace: pvc.metadata.namespace,
|
|
96
|
+
labels: { [kept_claims_1.KEPT_BY_LABEL]: runLabel },
|
|
97
|
+
// null removes it
|
|
98
|
+
annotations: { [kept_claims_1.EXPIRES_ANNOTATION]: null },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const current = getRequestedBytes(existing);
|
|
104
|
+
if (current === null || current >= (0, units_1.parseSize)(storage)) {
|
|
105
|
+
return existing;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
// the requested size is all of a bound claim's spec that may change
|
|
109
|
+
return await instance.objectApi.patch({
|
|
110
|
+
...pvc,
|
|
111
|
+
spec: { resources: { requests: { storage } } },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
throw new Error(`the claim ${volume.name} has ${(0, units_1.formatSize)(current)} and ${service.name} needs ${storage}, ` +
|
|
116
|
+
`which its storage class could not grow it to (${(0, log_1.getErrorMessage)(e)}): ` +
|
|
117
|
+
'remove it with hammerkit clean and run again');
|
|
118
|
+
}
|
|
38
119
|
}
|
|
39
120
|
exports.ensureKubernetesPersistentVolumeClaimExists = ensureKubernetesPersistentVolumeClaimExists;
|
|
121
|
+
async function createClaim(instance, env, pvc) {
|
|
122
|
+
for (let evicted = false;; evicted = true) {
|
|
123
|
+
try {
|
|
124
|
+
return await instance.objectApi.create(pvc);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
// Two work items can make the same claim concurrently (e.g. a volume
|
|
128
|
+
// shared between a task and a service that inherits its generates)
|
|
129
|
+
if ((0, apply_1.statusCodeOf)(e) === 409) {
|
|
130
|
+
return await instance.objectApi.read(pvc);
|
|
131
|
+
}
|
|
132
|
+
// the claims a server keeps for other sources make way under the quota
|
|
133
|
+
if (evicted || (0, apply_1.statusCodeOf)(e) !== 403 || !/exceeded quota/i.test((0, log_1.getErrorMessage)(e))) {
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
const removed = await (0, kept_claims_1.evictKeptClaims)(instance, env, REMOVAL_TIMEOUT);
|
|
137
|
+
if (removed.length === 0) {
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function untilRemoved(instance, pvc) {
|
|
144
|
+
const deadline = Date.now() + REMOVAL_TIMEOUT;
|
|
145
|
+
for (;;) {
|
|
146
|
+
const claim = await readClaim(instance, pvc);
|
|
147
|
+
if (!claim) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (Date.now() > deadline) {
|
|
151
|
+
throw new Error(`the claim ${pvc.metadata.name} is being removed and still there after ${REMOVAL_TIMEOUT / 1000}s`);
|
|
152
|
+
}
|
|
153
|
+
await (0, sleep_1.sleep)(1000);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function readClaim(instance, pvc) {
|
|
157
|
+
try {
|
|
158
|
+
return await instance.objectApi.read(pvc);
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
if ((0, apply_1.statusCodeOf)(e) === 404) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
40
167
|
//# sourceMappingURL=ensure-kubernetes-persistent-volume-claim-exists.js.map
|
|
@@ -4,8 +4,12 @@ exports.ensureKubernetesServiceExists = void 0;
|
|
|
4
4
|
const apply_1 = require("./apply");
|
|
5
5
|
const resources_1 = require("./resources");
|
|
6
6
|
const version_1 = require("../version");
|
|
7
|
+
const job_placement_1 = require("./job-placement");
|
|
8
|
+
// A service without ports gets a headless Service: a ClusterIP one needs a port,
|
|
9
|
+
// and its tasks reach every port of the pod, as on docker.
|
|
7
10
|
async function ensureKubernetesServiceExists(instance, env, service) {
|
|
8
11
|
const name = (0, resources_1.getResourceName)(service);
|
|
12
|
+
const headless = service.data.ports.length === 0;
|
|
9
13
|
const svc = {
|
|
10
14
|
kind: 'Service',
|
|
11
15
|
apiVersion: 'v1',
|
|
@@ -17,19 +21,36 @@ async function ensureKubernetesServiceExists(instance, env, service) {
|
|
|
17
21
|
},
|
|
18
22
|
labels: {
|
|
19
23
|
'hammerkit.dev/id': service.id(),
|
|
24
|
+
...(0, job_placement_1.getRunLabels)(env),
|
|
20
25
|
},
|
|
21
26
|
},
|
|
22
27
|
spec: {
|
|
23
28
|
type: 'ClusterIP',
|
|
29
|
+
...(headless ? { clusterIP: 'None' } : {}),
|
|
24
30
|
selector: {
|
|
25
31
|
'hammerkit.dev/id': service.id(),
|
|
26
32
|
},
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
...(headless
|
|
34
|
+
? {}
|
|
35
|
+
: {
|
|
36
|
+
ports: service.data.ports.map((port) => ({
|
|
37
|
+
port: port.hostPort ?? port.containerPort,
|
|
38
|
+
targetPort: port.containerPort,
|
|
39
|
+
})),
|
|
40
|
+
}),
|
|
31
41
|
},
|
|
32
42
|
};
|
|
43
|
+
// the clusterIP of a Service is fixed, so one from before its ports changed goes first
|
|
44
|
+
const existing = await instance.coreApi
|
|
45
|
+
.readNamespacedService({ name, namespace: env.namespace })
|
|
46
|
+
.catch((e) => ((0, apply_1.statusCodeOf)(e) === 404 ? null : Promise.reject(e)));
|
|
47
|
+
if (existing && (existing.spec?.clusterIP === 'None') !== headless) {
|
|
48
|
+
await instance.coreApi.deleteNamespacedService({ name, namespace: env.namespace }).catch((e) => {
|
|
49
|
+
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
33
54
|
return await (0, apply_1.apply)(instance, svc);
|
|
34
55
|
}
|
|
35
56
|
exports.ensureKubernetesServiceExists = ensureKubernetesServiceExists;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ensurePersistentData = exports.getPodForPersistence = void 0;
|
|
3
|
+
exports.ensurePersistentData = exports.clearClaim = exports.getPodForPersistence = void 0;
|
|
4
4
|
const apply_1 = require("./apply");
|
|
5
5
|
const path_1 = require("path");
|
|
6
6
|
const tar_1 = require("tar");
|
|
@@ -11,6 +11,8 @@ const version_1 = require("../version");
|
|
|
11
11
|
const log_1 = require("../log");
|
|
12
12
|
const ensure_kubernetes_persistent_volume_claim_exists_1 = require("./ensure-kubernetes-persistent-volume-claim-exists");
|
|
13
13
|
const stream_1 = require("stream");
|
|
14
|
+
const job_placement_1 = require("./job-placement");
|
|
15
|
+
const assert_item_paths_1 = require("../planner/utils/assert-item-paths");
|
|
14
16
|
// A previous run that crashed between creating an upload/download pod and its
|
|
15
17
|
// `finally` cleanup leaves a pod behind under the same deterministic name. apply()
|
|
16
18
|
// would then patch that stale pod in place — and if it is stuck in a terminal
|
|
@@ -43,9 +45,12 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
43
45
|
if (persistence.volumes.length === 0) {
|
|
44
46
|
return;
|
|
45
47
|
}
|
|
48
|
+
// the claim of a sandbox is mounted by the pods of the work only: it holds no output
|
|
49
|
+
// and takes no upload
|
|
50
|
+
const own = persistence.mounts.filter((m) => !m.workspace);
|
|
46
51
|
const mounts = type === 'read'
|
|
47
|
-
?
|
|
48
|
-
:
|
|
52
|
+
? own.map((m) => m.mount)
|
|
53
|
+
: own.reduce((array, v) => {
|
|
49
54
|
if (!array.some((i) => i.name === v.mount.name)) {
|
|
50
55
|
array.push({
|
|
51
56
|
mountPath: v.uploadPath,
|
|
@@ -60,7 +65,7 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
60
65
|
// function directly, and a claim shared with another work item can be removed
|
|
61
66
|
// concurrently — without this the pod would hang Pending on "pvc not found".
|
|
62
67
|
for (const volume of persistence.volumes) {
|
|
63
|
-
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service);
|
|
68
|
+
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
|
|
64
69
|
}
|
|
65
70
|
const name = (0, resources_1.getResourceName)(service, type === 'write' ? '-upload' : '-download');
|
|
66
71
|
const podSpec = {
|
|
@@ -71,12 +76,21 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
71
76
|
name,
|
|
72
77
|
labels: {
|
|
73
78
|
'hammerkit.dev/id': service.id(),
|
|
79
|
+
...(0, job_placement_1.getRunLabels)(env),
|
|
74
80
|
},
|
|
75
81
|
annotations: {
|
|
76
82
|
'hammerkit.dev/version': (0, version_1.getVersion)(),
|
|
77
83
|
},
|
|
78
84
|
},
|
|
79
85
|
spec: {
|
|
86
|
+
// on a server the claims are bound where the pod of the task can go: the nodes
|
|
87
|
+
// of its pool
|
|
88
|
+
...(env.server
|
|
89
|
+
? {
|
|
90
|
+
automountServiceAccountToken: false,
|
|
91
|
+
...(0, job_placement_1.getPodPlacement)(env.server, 'pool' in service.data ? service.data.pool : undefined),
|
|
92
|
+
}
|
|
93
|
+
: {}),
|
|
80
94
|
containers: [
|
|
81
95
|
{
|
|
82
96
|
name: 'volume',
|
|
@@ -84,6 +98,7 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
84
98
|
command: ['sh'],
|
|
85
99
|
tty: true,
|
|
86
100
|
volumeMounts: mounts,
|
|
101
|
+
...(0, job_placement_1.getContainerSecurity)(env),
|
|
87
102
|
},
|
|
88
103
|
],
|
|
89
104
|
volumes: persistence.volumes,
|
|
@@ -105,18 +120,44 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
|
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
122
|
exports.getPodForPersistence = getPodForPersistence;
|
|
108
|
-
|
|
123
|
+
// Empties the claim of `work` in an upload pod: a kept claim an earlier run left, before
|
|
124
|
+
// the task runs on it or outputs are restored into it, so it holds what a new one would
|
|
125
|
+
async function clearClaim(instance, env, work, pod) {
|
|
126
|
+
const targetPath = `/dev/hammerkit/${work.id()}`;
|
|
127
|
+
work.status.console('stdout', `clear what an earlier run left in ${targetPath}`);
|
|
128
|
+
let failure = null;
|
|
129
|
+
const res = await instance.exec.exec(env.namespace, pod, 'volume', ['find', targetPath, '-mindepth', '1', '-maxdepth', '1', '-exec', 'rm', '-rf', '{}', '+'], process.stdout, process.stderr, null, false, (status) => {
|
|
130
|
+
if (status.status !== 'Success') {
|
|
131
|
+
failure = status.message ?? status.reason ?? 'unknown';
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
res.on('error', reject);
|
|
136
|
+
res.on('close', () => failure ? reject(new Error(`unable to clear ${targetPath} in ${pod}: ${failure}`)) : resolve());
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
exports.clearClaim = clearClaim;
|
|
140
|
+
async function ensurePersistentData(instance, env, environment, service, persistence,
|
|
141
|
+
// the kept claim of the work holds what an earlier run left, which goes first
|
|
142
|
+
options = {}) {
|
|
143
|
+
// what a run of a server uploads comes from its checkout and nowhere else
|
|
144
|
+
if (environment.hosted) {
|
|
145
|
+
await (0, assert_item_paths_1.assertItemPathsWithinRoot)(service, environment.cwd);
|
|
146
|
+
}
|
|
109
147
|
// Dedupe of already-uploaded state (skip the upload when the persisted state
|
|
110
148
|
// key still matches) needs an in-cluster state marker per volume — deferred
|
|
111
149
|
// post-1.6.0, tracked in FOLLOWUPS.md alongside the volumes.ts stateKey/matcher.
|
|
112
150
|
for (const volume of persistence.volumes) {
|
|
113
|
-
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service);
|
|
151
|
+
await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
|
|
114
152
|
}
|
|
115
|
-
if (persistence.sources.length === 0) {
|
|
153
|
+
if (persistence.sources.length === 0 && !options.reset) {
|
|
116
154
|
return;
|
|
117
155
|
}
|
|
118
156
|
service.status.console('stdout', 'start upload container');
|
|
119
157
|
await getPodForPersistence(instance, env, service, persistence, 'write', async (name) => {
|
|
158
|
+
if (options.reset) {
|
|
159
|
+
await clearClaim(instance, env, service, name);
|
|
160
|
+
}
|
|
120
161
|
for (const source of persistence.sources) {
|
|
121
162
|
// skip sources that don't exist on disk — nothing to upload
|
|
122
163
|
if (!(await environment.file.exists(source.localPath))) {
|
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getServiceIp = void 0;
|
|
4
4
|
const resources_1 = require("./resources");
|
|
5
|
+
// The address a task reaches the service on: its clusterIP, or for a headless
|
|
6
|
+
// Service (a service without ports) the address of its ready pod.
|
|
5
7
|
async function getServiceIp(instance, env, workService) {
|
|
6
8
|
const name = (0, resources_1.getResourceName)(workService);
|
|
7
9
|
const svc = await instance.coreApi.readNamespacedService({ name, namespace: env.namespace });
|
|
8
|
-
|
|
10
|
+
const clusterIP = svc.spec?.clusterIP ?? null;
|
|
11
|
+
if (clusterIP !== 'None') {
|
|
12
|
+
return clusterIP;
|
|
13
|
+
}
|
|
14
|
+
const pods = await instance.coreApi.listNamespacedPod({
|
|
15
|
+
namespace: env.namespace,
|
|
16
|
+
labelSelector: `hammerkit.dev/id=${workService.id()}`,
|
|
17
|
+
});
|
|
18
|
+
const ready = pods.items.find((pod) => !pod.metadata?.deletionTimestamp &&
|
|
19
|
+
pod.status?.podIP &&
|
|
20
|
+
pod.status.conditions?.some((c) => c.type === 'Ready' && c.status === 'True'));
|
|
21
|
+
const running = ready ?? pods.items.find((pod) => !pod.metadata?.deletionTimestamp && pod.status?.podIP);
|
|
22
|
+
return running?.status?.podIP ?? null;
|
|
9
23
|
}
|
|
10
24
|
exports.getServiceIp = getServiceIp;
|
|
11
25
|
//# sourceMappingURL=get-service-ip.js.map
|