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.
Files changed (123) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/container-task-state.js +8 -6
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +4 -0
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +373 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +31 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
  34. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  35. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  36. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  37. package/dist/kubernetes/get-service-ip.js +15 -1
  38. package/dist/kubernetes/job-placement.js +99 -0
  39. package/dist/kubernetes/job-secrets.js +306 -0
  40. package/dist/kubernetes/kept-claims.js +191 -0
  41. package/dist/kubernetes/kubernetes-instance.js +10 -2
  42. package/dist/kubernetes/network-gate.js +71 -0
  43. package/dist/kubernetes/network-policy.js +526 -0
  44. package/dist/kubernetes/resources.js +13 -2
  45. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  46. package/dist/kubernetes/secrets.js +2 -0
  47. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  48. package/dist/kubernetes/volumes.js +100 -11
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +8 -2
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/utils/task-arguments.js +155 -0
  61. package/dist/planner/work-cache-id.js +15 -1
  62. package/dist/planner/work-network.js +193 -0
  63. package/dist/planner/work-runtime-docker.js +43 -0
  64. package/dist/planner/work-runtime-kubernetes.js +336 -102
  65. package/dist/planner/work-runtime-local.js +16 -8
  66. package/dist/planner/work-service.js +7 -3
  67. package/dist/planner/work-state-record.js +30 -0
  68. package/dist/program.js +80 -7
  69. package/dist/remote/annotations.js +103 -0
  70. package/dist/remote/bundle-text.js +24 -0
  71. package/dist/remote/bundle.js +9 -1
  72. package/dist/remote/remote-commands.js +4 -1
  73. package/dist/remote/remote-work-commands.js +182 -69
  74. package/dist/remote/server-client.js +18 -5
  75. package/dist/remote/source-manifest.js +15 -2
  76. package/dist/remote/token-source.js +67 -0
  77. package/dist/schema/build-file-base-task-schema.js +3 -0
  78. package/dist/schema/build-file-container-service-schema.js +6 -0
  79. package/dist/schema/build-file-container-task-schema.js +3 -0
  80. package/dist/schema/build-file-environment-schema.js +9 -0
  81. package/dist/schema/network-schema.js +78 -0
  82. package/dist/schema/reference-parser.js +1 -0
  83. package/dist/schema/schema-parser.js +16 -3
  84. package/dist/schema/work-scope-parser.js +13 -0
  85. package/dist/secrets/command-secret-provider.js +73 -10
  86. package/dist/secrets/github-app-token-command.js +23 -0
  87. package/dist/secrets/github-app-token.js +191 -0
  88. package/dist/server/accounts-api.js +9 -2
  89. package/dist/server/definitions-api.js +69 -47
  90. package/dist/server/definitions.js +76 -51
  91. package/dist/server/executor.js +156 -41
  92. package/dist/server/git-checkout.js +72 -8
  93. package/dist/server/kubernetes-backend.js +554 -0
  94. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  95. package/dist/server/oauth.js +1 -0
  96. package/dist/server/plan.js +51 -1
  97. package/dist/server/policy.js +104 -15
  98. package/dist/server/proposals-api.js +24 -40
  99. package/dist/server/proposals.js +12 -25
  100. package/dist/server/repo-access.js +37 -6
  101. package/dist/server/run-logs.js +217 -0
  102. package/dist/server/run-plan.js +51 -2
  103. package/dist/server/run-request.js +23 -10
  104. package/dist/server/run-store.js +5 -0
  105. package/dist/server/run-worker-command.js +13 -1
  106. package/dist/server/run-worker.js +121 -24
  107. package/dist/server/runs.js +184 -10
  108. package/dist/server/sandbox-docker.js +62 -0
  109. package/dist/server/sandbox-files-api.js +269 -0
  110. package/dist/server/sandbox-files.js +1054 -0
  111. package/dist/server/sandbox-patch.js +481 -0
  112. package/dist/server/sandbox-pod-command.js +15 -0
  113. package/dist/server/sandbox-pod.js +146 -0
  114. package/dist/server/sandbox-storage.js +462 -0
  115. package/dist/server/sandboxes-api.js +210 -0
  116. package/dist/server/sandboxes.js +519 -0
  117. package/dist/server/server-config.js +412 -22
  118. package/dist/server/server.js +204 -39
  119. package/dist/utils/run-id.js +12 -0
  120. package/harness/opencode.ts +11 -0
  121. package/harness/pi.ts +7 -0
  122. package/package.json +8 -2
  123. package/dist/remote/text-diff.js +0 -89
@@ -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,45 +1,42 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensureKubernetesDeploymentExists = void 0;
4
- const work_item_1 = require("../planner/work-item");
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
- async function ensureKubernetesDeploymentExists(instance, env, service, persistence, secrets, stateKey) {
16
- const hostAliases = [];
17
- for (const need of service.needs) {
18
- if (!(0, work_item_1.isContainerWorkServiceItem)(need.service)) {
19
- continue;
20
- }
21
- const ip = await (0, get_service_ip_1.getServiceIp)(instance, env, need.service);
22
- if (!ip) {
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);
33
- const serviceCommand = (0, work_service_1.getServiceCommand)(service.data);
26
+ const serviceCommand = (0, work_service_1.getServiceCommand)(service.data, { kubernetes: true });
34
27
  const probe = healthcheckCommand
35
28
  ? {
36
29
  exec: {
37
- command: healthcheckCommand,
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
- async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume, service) {
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: '1Gi',
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
- return await (0, apply_1.apply)(instance, pvc);
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
- ports: service.data.ports.map((port) => ({
28
- port: port.hostPort ?? port.containerPort,
29
- targetPort: port.containerPort,
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
- ? persistence.mounts.map((m) => m.mount)
48
- : persistence.mounts.reduce((array, v) => {
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
- async function ensurePersistentData(instance, env, environment, service, persistence) {
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
- return svc.spec?.clusterIP ?? null;
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