hammerkit 1.27.0-next.9 → 2.0.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 (122) hide show
  1. package/build.schema.json +176 -6
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/cache-sync.js +1 -1
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +6 -3
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +49 -1
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +133 -9
  36. package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
  37. package/dist/kubernetes/ensure-persistent-data.js +211 -6
  38. package/dist/kubernetes/follow-pod-logs.js +182 -0
  39. package/dist/kubernetes/job-placement.js +36 -12
  40. package/dist/kubernetes/kubernetes-instance.js +1 -0
  41. package/dist/kubernetes/network-policy.js +2 -1
  42. package/dist/kubernetes/remove-stale-items.js +46 -0
  43. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  44. package/dist/kubernetes/sandbox-runner.js +628 -0
  45. package/dist/kubernetes/service-rollouts.js +74 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  47. package/dist/kubernetes/volumes.js +94 -9
  48. package/dist/main.js +2 -0
  49. package/dist/optimizer/calculate-checksum.js +3 -2
  50. package/dist/optimizer/check-key.js +94 -0
  51. package/dist/optimizer/check-keys.js +64 -0
  52. package/dist/optimizer/get-work-cache-stats.js +17 -3
  53. package/dist/optimizer/work-task-cache-description.js +49 -1
  54. package/dist/planner/service-definition.js +9 -4
  55. package/dist/planner/utils/append-work-dependencies.js +5 -3
  56. package/dist/planner/utils/append-work-service.js +18 -1
  57. package/dist/planner/utils/append-work-task.js +13 -3
  58. package/dist/planner/utils/get-work-runtime.js +6 -1
  59. package/dist/planner/utils/normalize-path.js +19 -1
  60. package/dist/planner/utils/parse-work-mount.js +7 -1
  61. package/dist/planner/utils/parse-work-port.js +4 -1
  62. package/dist/planner/validate.js +22 -0
  63. package/dist/planner/work-network.js +3 -1
  64. package/dist/planner/work-runtime-aggregate.js +32 -0
  65. package/dist/planner/work-runtime-kubernetes.js +136 -13
  66. package/dist/planner/work-task.js +6 -1
  67. package/dist/program.js +27 -9
  68. package/dist/remote/device-login.js +7 -2
  69. package/dist/remote/host-config.js +69 -3
  70. package/dist/remote/remote-commands.js +33 -7
  71. package/dist/remote/remote-status.js +112 -0
  72. package/dist/remote/remote-work-commands.js +562 -45
  73. package/dist/remote/server-client.js +15 -2
  74. package/dist/remote/token-source.js +64 -26
  75. package/dist/run-program.js +9 -0
  76. package/dist/schema/build-file-base-task-schema.js +3 -3
  77. package/dist/schema/build-file-container-service-schema.js +10 -0
  78. package/dist/schema/build-file-container-task-schema.js +16 -1
  79. package/dist/schema/build-file-include-schema.js +18 -4
  80. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  81. package/dist/schema/build-file-task-schema.js +5 -1
  82. package/dist/schema/capabilities-schema.js +56 -0
  83. package/dist/schema/reference-parser.js +119 -11
  84. package/dist/schema/removed-properties.js +20 -0
  85. package/dist/schema/schema-parser.js +94 -41
  86. package/dist/schema/work-scope-parser.js +33 -10
  87. package/dist/secrets/command-secret-provider.js +3 -1
  88. package/dist/secrets/github-app-token-command.js +3 -1
  89. package/dist/secrets/secret-catalog.js +43 -19
  90. package/dist/server/accounts-api.js +4 -1
  91. package/dist/server/approvals-api.js +1 -1
  92. package/dist/server/capacity-format.js +36 -0
  93. package/dist/server/capacity.js +300 -0
  94. package/dist/server/checks-api.js +83 -0
  95. package/dist/server/checks.js +116 -0
  96. package/dist/server/dashboard.js +196 -0
  97. package/dist/server/definitions-api.js +66 -3
  98. package/dist/server/endpoints.js +794 -0
  99. package/dist/server/executor.js +164 -9
  100. package/dist/server/kubernetes-backend.js +415 -48
  101. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  102. package/dist/server/overview.js +271 -0
  103. package/dist/server/plan.js +170 -121
  104. package/dist/server/policy.js +34 -2
  105. package/dist/server/proposals-api.js +17 -8
  106. package/dist/server/repo-access.js +5 -0
  107. package/dist/server/run-logs.js +4 -0
  108. package/dist/server/run-plan.js +38 -4
  109. package/dist/server/run-request.js +58 -3
  110. package/dist/server/run-worker.js +262 -13
  111. package/dist/server/runs.js +962 -36
  112. package/dist/server/sandbox-pod.js +28 -0
  113. package/dist/server/sandbox-storage.js +23 -0
  114. package/dist/server/sandboxes.js +8 -0
  115. package/dist/server/server-config.js +246 -10
  116. package/dist/server/server.js +429 -32
  117. package/dist/server/service-runs.js +82 -0
  118. package/dist/server/timings.js +132 -0
  119. package/dist/server/wait-reason.js +21 -0
  120. package/dist/utils/tar.js +28 -1
  121. package/dist/utils/terminal-prompt.js +53 -0
  122. package/package.json +8 -2
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readClusterState = exports.describeEvent = exports.describePod = exports.getPodWaiting = exports.describeLastExit = exports.getPodFailure = exports.CRASHES_TO_FAIL = void 0;
4
+ const log_1 = require("../log");
5
+ const apply_1 = require("./apply");
6
+ const job_placement_1 = require("./job-placement");
7
+ const network_gate_1 = require("./network-gate");
8
+ const resources_1 = require("./resources");
9
+ // what a pod's or event's message is cut to
10
+ const MAX_MESSAGE = 500;
11
+ // how many events of a run are said, the newest
12
+ const MAX_EVENTS = 20;
13
+ // events the result of a task says better: a Job of a failed command
14
+ const SAID_ELSEWHERE = ['BackoffLimitExceeded'];
15
+ // A container restarted this often (ended three times) is taken as one that keeps crashing,
16
+ // unless its service sets `restarts`: once may be a race with a dependency or a slow first start
17
+ exports.CRASHES_TO_FAIL = 2;
18
+ // what a pull error says when the image is not there to pull, or not for this cluster:
19
+ // waiting does not change it. Others (a registry that limits, a token not there yet) may pass.
20
+ const IMAGE_GONE = /not found|manifest unknown|unauthorized|denied|forbidden|invalid reference|no such host/i;
21
+ /**
22
+ * Why a pod of a service will not become ready without a change, as the cluster says it:
23
+ * an image that can not be pulled, a container that keeps crashing or can not be created,
24
+ * an init container that failed. Null while it may still get there. A container fails once
25
+ * it was restarted `restarts` times and crashed again.
26
+ *
27
+ * Only what the cluster wrote is quoted: the termination message of a container is its
28
+ * own output (and may hold a value it read), except for the network gate's, which says why
29
+ * the policy did not take hold.
30
+ */
31
+ function getPodFailure(pod, container, restarts = exports.CRASHES_TO_FAIL) {
32
+ for (const status of pod.status?.initContainerStatuses ?? []) {
33
+ const terminated = status.state?.terminated ?? status.lastState?.terminated;
34
+ if (!terminated || terminated.exitCode === 0) {
35
+ continue;
36
+ }
37
+ if (status.name === network_gate_1.NETWORK_GATE_CONTAINER) {
38
+ return {
39
+ text: `the network policy did not take hold: ${cut(terminated.message?.trim() || 'the gate failed')}`,
40
+ final: true,
41
+ };
42
+ }
43
+ if (status.restartCount >= exports.CRASHES_TO_FAIL) {
44
+ return { text: `its init container ${status.name} failed with exit code ${terminated.exitCode}`, final: true };
45
+ }
46
+ }
47
+ for (const status of mainStatuses(pod, container)) {
48
+ const failure = getContainerFailure(status, restartsEnded(pod, status), restarts);
49
+ if (failure) {
50
+ return failure;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ exports.getPodFailure = getPodFailure;
56
+ function getContainerFailure(status, restartsEnded, restarts) {
57
+ const waiting = status.state?.waiting;
58
+ const reason = waiting?.reason;
59
+ const said = waiting?.message ? `: ${cut(waiting.message)}` : '';
60
+ switch (reason) {
61
+ // the first pull: final only when the registry said the image is not there, as older
62
+ // kubelets leave that out of the ImagePullBackOff that follows
63
+ case 'ErrImagePull':
64
+ return IMAGE_GONE.test(waiting?.message ?? '')
65
+ ? { text: `its image ${status.image} could not be pulled (${reason})${said}`, final: true }
66
+ : null;
67
+ case 'ImagePullBackOff':
68
+ return {
69
+ text: `its image ${status.image} could not be pulled (${reason})${said}`,
70
+ final: IMAGE_GONE.test(waiting?.message ?? ''),
71
+ };
72
+ case 'ErrImageNeverPull':
73
+ case 'InvalidImageName':
74
+ return { text: `its image ${status.image} could not be pulled (${reason})${said}`, final: true };
75
+ case 'CreateContainerConfigError':
76
+ case 'CreateContainerError':
77
+ case 'RunContainerError':
78
+ return { text: `its container could not be started (${reason})${said}`, final: false };
79
+ case 'CrashLoopBackOff':
80
+ return status.restartCount >= restarts
81
+ ? { text: `it keeps crashing: ${describeLastExit(status)}`, final: true }
82
+ : null;
83
+ }
84
+ // a kubelet may leave an instance that ended as it is until its back-off is over, and say
85
+ // CrashLoopBackOff only when it looks at the pod again, a minute later: the same crash
86
+ if (restartsEnded && status.state?.terminated && status.restartCount >= restarts) {
87
+ return { text: `it keeps crashing: ${describeLastExit(status)}`, final: true };
88
+ }
89
+ return null;
90
+ }
91
+ // How the last instance of a container ended, as the cluster says it.
92
+ function describeLastExit(status) {
93
+ // the instance that ended now, else the one before the one that waits or runs
94
+ const last = status.state?.terminated ?? status.lastState?.terminated;
95
+ const restarts = `, restarted ${status.restartCount} time${status.restartCount === 1 ? '' : 's'}`;
96
+ if (!last) {
97
+ return `it ended${restarts}`;
98
+ }
99
+ if (last.reason === 'OOMKilled') {
100
+ return `it was killed for using more memory than its limit (OOMKilled)${restarts}`;
101
+ }
102
+ // the kubelet kills a container whose liveness probe (its healthcheck) keeps failing
103
+ const killed = last.exitCode === 137 || last.exitCode === 143 ? ', killed as when its healthcheck kept failing' : '';
104
+ return `it exited with code ${last.exitCode}${last.reason ? ` (${last.reason})` : ''}${killed}${restarts}`;
105
+ }
106
+ exports.describeLastExit = describeLastExit;
107
+ /**
108
+ * What a pod of a service waits for, in a few words, while it is not ready: a node, its
109
+ * image, its container. Undefined when it waits for nothing the cluster names.
110
+ */
111
+ function getPodWaiting(pod, container) {
112
+ const scheduled = pod.status?.conditions?.find((condition) => condition.type === 'PodScheduled');
113
+ if (scheduled?.status === 'False') {
114
+ return `waits for a node: ${cut(scheduled.message ?? scheduled.reason ?? 'not scheduled yet')}`;
115
+ }
116
+ for (const status of mainStatuses(pod, container)) {
117
+ const waiting = status.state?.waiting;
118
+ const said = waiting?.message ? `: ${cut(waiting.message)}` : '';
119
+ switch (waiting?.reason) {
120
+ case 'ErrImagePull':
121
+ case 'ImagePullBackOff':
122
+ return `can not pull its image ${status.image} yet${said}`;
123
+ case 'CreateContainerConfigError':
124
+ case 'CreateContainerError':
125
+ case 'RunContainerError':
126
+ return `can not start its container yet (${waiting.reason})${said}`;
127
+ case 'CrashLoopBackOff':
128
+ return `crashed and starts again: ${describeLastExit(status)}`;
129
+ }
130
+ const ended = status.state?.terminated;
131
+ if (ended && restartsEnded(pod, status)) {
132
+ return `${ended.exitCode === 0 ? 'ended' : 'crashed'} and starts again: ${describeLastExit(status)}`;
133
+ }
134
+ if (status.restartCount > 0 && status.state?.running && !status.ready) {
135
+ return `restarted: ${describeLastExit(status)}`;
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+ exports.getPodWaiting = getPodWaiting;
141
+ // Whether the kubelet starts a container that ended again: not one of a pod that finished (a
142
+ // task's Job, an evicted pod, which its ReplicaSet replaces) or that never restarts.
143
+ function restartsEnded(pod, status) {
144
+ const phase = pod.status?.phase;
145
+ if (phase === 'Succeeded' || phase === 'Failed') {
146
+ return false;
147
+ }
148
+ const policy = pod.spec?.restartPolicy ?? 'Always';
149
+ return policy === 'Always' || (policy === 'OnFailure' && status.state?.terminated?.exitCode !== 0);
150
+ }
151
+ function mainStatuses(pod, container) {
152
+ return (pod.status?.containerStatuses ?? []).filter((status) => !container || status.name === container);
153
+ }
154
+ // One line of a pod that is not as it should be, or null for one that runs, ready, or ended well.
155
+ function describePod(pod) {
156
+ const name = pod.metadata?.name ?? '?';
157
+ const phase = pod.status?.phase ?? 'Unknown';
158
+ const failure = getPodFailure(pod)?.text;
159
+ const waiting = getPodWaiting(pod);
160
+ const unready = (pod.status?.containerStatuses ?? [])
161
+ .filter((status) => !status.ready && !status.state?.terminated)
162
+ .map((status) => status.name);
163
+ const crashed = (pod.status?.containerStatuses ?? []).filter((status) => (status.state?.terminated && status.state.terminated.exitCode !== 0) || status.restartCount > 0);
164
+ if (!failure && !waiting && crashed.length === 0 && (phase === 'Succeeded' || unready.length === 0)) {
165
+ return null;
166
+ }
167
+ const said = failure ?? waiting;
168
+ const parts = [phase, ...(said ? [said] : [])];
169
+ for (const status of crashed) {
170
+ if (!said?.includes(describeLastExit(status))) {
171
+ parts.push(`${status.name}: ${describeLastExit(status)}`);
172
+ }
173
+ }
174
+ if (!said && unready.length > 0) {
175
+ parts.push(`not ready: ${unready.join(', ')}`);
176
+ }
177
+ return `pod ${name}: ${parts.join(', ')}`;
178
+ }
179
+ exports.describePod = describePod;
180
+ /**
181
+ * The message of a warning event, as far as it is the cluster's: a failed probe says the
182
+ * output of the command, which is the service's, and is left out.
183
+ */
184
+ function describeEvent(event) {
185
+ const object = event.involvedObject;
186
+ const message = (event.message ?? '').replace(/^((Readiness|Liveness|Startup) probe (failed|errored)).*$/s, '$1');
187
+ const count = (event.count ?? 1) > 1 ? ` (${event.count} times)` : '';
188
+ return `${event.reason ?? 'Warning'} ${object.kind?.toLowerCase() ?? 'object'}/${object.name ?? '?'}: ${cut(message.trim())}${count}`;
189
+ }
190
+ exports.describeEvent = describeEvent;
191
+ /**
192
+ * What the cluster said of a run that failed, into its log before the server removes its
193
+ * objects: its pods that are not as they should be, and the warning events of its objects.
194
+ * In the namespace of a service run everything is the run's; in a caller's namespace, which
195
+ * runs share, only the objects of the run's items (named by their key) are.
196
+ */
197
+ async function readClusterState(instance, kubernetes, workTree, ownNamespace) {
198
+ const namespace = kubernetes.namespace;
199
+ const keys = [...Object.values(workTree.tasks), ...Object.values(workTree.services)].map((item) => (0, resources_1.getItemKey)(item, kubernetes));
200
+ const ours = (name) => ownNamespace || keys.some((key) => name?.includes(key));
201
+ const lines = [];
202
+ try {
203
+ const labels = (0, job_placement_1.getRunLabels)(kubernetes);
204
+ const labelSelector = Object.entries(labels)
205
+ .map(([key, value]) => `${key}=${value}`)
206
+ .join(',');
207
+ const pods = await instance.coreApi.listNamespacedPod({
208
+ namespace,
209
+ ...(labelSelector && !ownNamespace ? { labelSelector } : {}),
210
+ });
211
+ for (const pod of pods.items) {
212
+ const line = ours(pod.metadata?.name) ? describePod(pod) : null;
213
+ if (line) {
214
+ lines.push(line);
215
+ }
216
+ }
217
+ }
218
+ catch (e) {
219
+ lines.push(`its pods could not be read: ${(0, log_1.getErrorMessage)(e)}`);
220
+ }
221
+ try {
222
+ const events = await instance.coreApi.listNamespacedEvent({ namespace, fieldSelector: 'type=Warning' });
223
+ const warnings = events.items
224
+ .filter((event) => ours(event.involvedObject.name) && !SAID_ELSEWHERE.includes(event.reason ?? ''))
225
+ .sort((a, b) => eventTime(a) - eventTime(b))
226
+ .slice(-MAX_EVENTS);
227
+ lines.push(...warnings.map((event) => `event ${describeEvent(event)}`));
228
+ }
229
+ catch (e) {
230
+ lines.push((0, apply_1.statusCodeOf)(e) === 403
231
+ ? `its events could not be read: the runRole of the server needs list on events (see the backend docs)`
232
+ : `its events could not be read: ${(0, log_1.getErrorMessage)(e)}`);
233
+ }
234
+ return lines;
235
+ }
236
+ exports.readClusterState = readClusterState;
237
+ function eventTime(event) {
238
+ const time = event.lastTimestamp ?? event.eventTime ?? event.firstTimestamp ?? event.metadata?.creationTimestamp;
239
+ return time ? new Date(time).getTime() : 0;
240
+ }
241
+ function cut(text) {
242
+ const line = text.replace(/\s+/g, ' ');
243
+ return line.length > MAX_MESSAGE ? `${line.slice(0, MAX_MESSAGE)}…` : line;
244
+ }
245
+ //# sourceMappingURL=cluster-state.js.map
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensureKubernetesDeploymentExists = exports.DEFINITION_LABEL = void 0;
3
+ exports.getServiceContainerName = exports.MIN_READY_SECONDS = exports.getPodTemplateHash = exports.POD_ANNOTATION = exports.ROLLOUT_DEADLINE_SECONDS = exports.ensureKubernetesDeploymentExists = exports.DEFINITION_LABEL = void 0;
4
4
  const service_definition_1 = require("../planner/service-definition");
5
5
  const work_service_1 = require("../planner/work-service");
6
6
  const volumes_1 = require("./volumes");
7
+ const crypto_1 = require("crypto");
7
8
  const service_hostname_1 = require("../planner/utils/service-hostname");
8
9
  const apply_1 = require("./apply");
9
10
  const version_1 = require("../version");
@@ -15,11 +16,17 @@ const container_resources_1 = require("./container-resources");
15
16
  const job_placement_1 = require("./job-placement");
16
17
  const network_policy_1 = require("./network-policy");
17
18
  const egress_proxy_1 = require("../network/egress-proxy");
19
+ const service_rollouts_1 = require("./service-rollouts");
18
20
  // what the service was created from, to tell a changed one
19
21
  exports.DEFINITION_LABEL = 'hammerkit.dev/definition';
22
+ // how often a starting container is checked: as the liveness probe
23
+ const STARTUP_PERIOD_SECONDS = 5;
20
24
  async function ensureKubernetesDeploymentExists(instance, env, service, persistence, secrets, stateKey,
21
25
  // the needed services by name, and the proxy of a limited network
22
- network) {
26
+ network,
27
+ // a service run (env.server.serviceRun): the Deployment the service has now, put back when
28
+ // the new one does not get ready, and what stops the wait for it
29
+ rollout = {}) {
23
30
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(service.data.envs);
24
31
  const name = (0, resources_1.getResourceName)(service, env);
25
32
  const healthcheckCommand = (0, work_service_1.getHealthcheckCommand)(service.data);
@@ -34,6 +41,16 @@ network) {
34
41
  failureThreshold: 3,
35
42
  }
36
43
  : undefined;
44
+ // the healthcheck's `timeout` for each start: until it passed once, the liveness probe
45
+ // doesn't kill a container that starts slowly, and the wait fails it with the timeout
46
+ const startupProbe = probe && service.data.healthcheck
47
+ ? {
48
+ ...probe,
49
+ // a check more than the timeout holds, and one more for the first check's offset: the
50
+ // wait fails it with the timeout before the kubelet restarts it
51
+ failureThreshold: Math.ceil(service.data.healthcheck.timeout / (STARTUP_PERIOD_SECONDS * 1000)) + 2,
52
+ }
53
+ : undefined;
37
54
  const server = env.server;
38
55
  const placement = server ? (0, job_placement_1.getPodPlacement)(server, undefined) : null;
39
56
  const workspace = (0, volumes_1.getWorkspacePodSpec)(env, persistence);
@@ -57,6 +74,9 @@ network) {
57
74
  matchLabels: (0, resources_1.getItemLabels)(service, env),
58
75
  },
59
76
  replicas: 1,
77
+ // without a healthcheck a pod is ready as soon as its container runs, also one that
78
+ // exits a second later: it counts once it ran this long without a restart
79
+ minReadySeconds: probe ? 0 : exports.MIN_READY_SECONDS,
60
80
  template: {
61
81
  metadata: {
62
82
  labels: {
@@ -83,7 +103,7 @@ network) {
83
103
  : {}),
84
104
  containers: [
85
105
  {
86
- name: service.name.replace(/:/, '-'),
106
+ name: getServiceContainerName(service),
87
107
  image: service.data.image,
88
108
  workingDir: (0, work_service_1.getServiceWorkingDir)(service.data),
89
109
  env: [
@@ -100,6 +120,7 @@ network) {
100
120
  })),
101
121
  readinessProbe: probe,
102
122
  livenessProbe: probe,
123
+ startupProbe,
103
124
  ...(0, container_resources_1.getKubernetesResources)(service.data.resources),
104
125
  volumeMounts: [...persistence.mounts.map((m) => m.mount), ...secrets.mounts],
105
126
  ...(0, job_placement_1.getContainerSecurity)(env),
@@ -118,19 +139,122 @@ network) {
118
139
  },
119
140
  },
120
141
  };
121
- const deploy = await (0, apply_1.apply)(instance, deployment);
122
- if (deploy.status?.readyReplicas ?? 0 > 0) {
123
- return;
124
- }
125
- await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, {
142
+ const readiness = {
126
143
  podSelector: (0, resources_1.getItemSelector)(service, env),
127
144
  timeout: service.data.healthcheck?.timeout ?? null,
128
145
  timeoutMessage: service.data.healthcheck
129
146
  ? (0, work_healthcheck_1.getHealthcheckTimeoutMessage)(service.name, service.data.healthcheck)
130
147
  : '',
131
- });
148
+ container: getServiceContainerName(service),
149
+ subject: `service ${service.name}`,
150
+ onWaiting: (text) => service.status.write('warn', `${service.name} ${text}`),
151
+ ...(service.data.restarts !== null ? { restarts: service.data.restarts } : {}),
152
+ };
153
+ if (env.server?.serviceRun) {
154
+ await rollOut(instance, env, service, deployment, readiness, rollout);
155
+ return;
156
+ }
157
+ const deploy = await (0, apply_1.apply)(instance, deployment);
158
+ if ((deploy.status?.availableReplicas ?? 0) > 0) {
159
+ return;
160
+ }
161
+ await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, readiness);
132
162
  }
133
163
  exports.ensureKubernetesDeploymentExists = ensureKubernetesDeploymentExists;
164
+ // how long the pods of a rolled service may take to get ready, as Kubernetes counts it
165
+ exports.ROLLOUT_DEADLINE_SECONDS = 600;
166
+ /**
167
+ * The Deployment of a service of a service run, which keeps it from one commit to the next:
168
+ * replaced as a whole (a merge would keep what the new one dropped, such as a variable) and
169
+ * rolled. Its requests go to the pods it has until a new one is ready. A service with
170
+ * volumes of its own stops first: two pods would write the same data. The new pod is asked
171
+ * to go where the old one runs (getClaimPodSpec), whose claims are usually attached to one
172
+ * node at a time. When the execution ends before all its services are up, the worker puts
173
+ * every Deployment it rolled back as it was (serviceRollouts).
174
+ */
175
+ async function rollOut(instance, env, service, deployment, readiness, rollout) {
176
+ const spec = deployment.spec;
177
+ const template = spec.template;
178
+ const definition = (0, service_definition_1.getServiceDefinitionHash)(service);
179
+ const stateKey = template.metadata?.labels?.['hammerkit.dev/state'] ?? '';
180
+ const ownVolumes = service.data.volumes.some((volume) => !volume.inherited);
181
+ spec.strategy = ownVolumes
182
+ ? { type: 'Recreate' }
183
+ : { type: 'RollingUpdate', rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } };
184
+ spec.progressDeadlineSeconds = exports.ROLLOUT_DEADLINE_SECONDS;
185
+ // the pods of this definition and state, apart from the ones it rolls from
186
+ template.metadata = {
187
+ ...template.metadata,
188
+ labels: { ...template.metadata?.labels, [exports.DEFINITION_LABEL]: definition },
189
+ };
190
+ const pod = getPodTemplateHash(template, service.data.projectRoot);
191
+ deployment.metadata.annotations = { ...deployment.metadata.annotations, [exports.POD_ANNOTATION]: pod };
192
+ const previous = rollout.previous;
193
+ if (previous &&
194
+ previous.metadata?.annotations?.[exports.POD_ANNOTATION] === pod &&
195
+ previous.metadata?.labels?.[exports.DEFINITION_LABEL] === definition &&
196
+ isAvailable(previous)) {
197
+ // its pod would be the same but for the state it runs at: a service that watches its
198
+ // files has them already (syncLiveFiles), and its pod stays
199
+ service_rollouts_1.serviceRollouts.add(env.namespace, deployment.metadata.name, previous);
200
+ await relabelDeployment(instance, env.namespace, deployment.metadata.name, stateKey);
201
+ service.status.write('info', `${service.name} watches its files, it got the changed ones and stays up`);
202
+ // it goes on running with them: else the move does not get up, and gets them back
203
+ await (0, await_running_state_1.watchKeptPods)(instance, env, deployment.metadata.name, { ...readiness, signal: rollout.signal });
204
+ return;
205
+ }
206
+ // put back, or removed, by the worker when the execution ends before its services are up
207
+ service_rollouts_1.serviceRollouts.add(env.namespace, deployment.metadata.name, rollout.previous);
208
+ const applied = await (0, apply_1.replaceDeployment)(instance, deployment);
209
+ if (rollout.previous) {
210
+ service.status.write('info', `${service.name} changed, its new pod takes over once it is ready`);
211
+ }
212
+ await (0, await_running_state_1.awaitDeployRollout)(instance, env, deployment.metadata.name, {
213
+ ...readiness,
214
+ podSelector: `${readiness.podSelector},hammerkit.dev/state=${stateKey},${exports.DEFINITION_LABEL}=${definition}`,
215
+ generation: applied.metadata?.generation ?? 0,
216
+ signal: rollout.signal,
217
+ });
218
+ }
219
+ // the pod a Deployment of a service run makes, but for the state it runs at
220
+ exports.POD_ANNOTATION = 'hammerkit.dev/pod';
221
+ // A move executes from a checkout of its own (`root`), which the pod's paths name: where the
222
+ // checkout is does not count, as for getServiceDefinitionHash
223
+ function getPodTemplateHash(template, root) {
224
+ const labels = { ...template.metadata?.labels };
225
+ delete labels['hammerkit.dev/state'];
226
+ const json = JSON.stringify({ ...template, metadata: { ...template.metadata, labels } });
227
+ const escaped = root ? JSON.stringify(root).slice(1, -1) : '';
228
+ return (0, crypto_1.createHash)('sha256')
229
+ .update(escaped ? json.split(escaped).join('<checkout>') : json)
230
+ .digest('hex')
231
+ .slice(0, 32);
232
+ }
233
+ exports.getPodTemplateHash = getPodTemplateHash;
234
+ // a Deployment whose pods are all of its template and ready
235
+ function isAvailable(deployment) {
236
+ const status = deployment.status;
237
+ return (!deployment.metadata?.deletionTimestamp &&
238
+ (status?.observedGeneration ?? 0) >= (deployment.metadata?.generation ?? 0) &&
239
+ (status?.updatedReplicas ?? 0) >= 1 &&
240
+ (status?.availableReplicas ?? 0) >= 1 &&
241
+ (status?.replicas ?? 0) === (status?.updatedReplicas ?? 0));
242
+ }
243
+ // The Deployment runs at the state `stateKey` now: its template, and so its pods, stay
244
+ async function relabelDeployment(instance, namespace, name, stateKey) {
245
+ await instance.appsApi.patchNamespacedDeployment({
246
+ name,
247
+ namespace,
248
+ body: [{ op: 'add', path: '/metadata/labels/hammerkit.dev~1state', value: stateKey }],
249
+ });
250
+ }
251
+ // how long the container of a service without a healthcheck has to run before it counts as up
252
+ exports.MIN_READY_SECONDS = 10;
253
+ // The container of a service's pod that runs its command, beside the `debug` one.
254
+ function getServiceContainerName(service) {
255
+ return service.name.replace(/:/, '-');
256
+ }
257
+ exports.getServiceContainerName = getServiceContainerName;
134
258
  // Kubernetes `command` replaces the image entrypoint and `args` its command.
135
259
  // A shell service sets both; an exec-form `cmd` keeps the established mapping
136
260
  // (first token as `command`, the rest as `args`).
@@ -28,7 +28,9 @@ async function ensureKubernetesServiceExists(instance, env, service) {
28
28
  ...(headless
29
29
  ? {}
30
30
  : {
31
+ // a Service of more than one port needs a name for each
31
32
  ports: service.data.ports.map((port) => ({
33
+ name: `tcp-${port.hostPort ?? port.containerPort}`,
32
34
  port: port.hostPort ?? port.containerPort,
33
35
  targetPort: port.containerPort,
34
36
  })),