hammerkit 1.27.0-next.28 → 1.27.0-next.29

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 CHANGED
@@ -651,6 +651,12 @@
651
651
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources",
652
652
  "description": "cpu and memory requests and limits"
653
653
  },
654
+ "restarts": {
655
+ "type": "integer",
656
+ "minimum": 0,
657
+ "maximum": 10,
658
+ "description": "crashes restarted before it fails on Kubernetes, 0 to 10, 2 by default"
659
+ },
654
660
  "volumeSize": {
655
661
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/volumeSize",
656
662
  "description": "size of its claim on Kubernetes, e.g. 10Gi"
@@ -81,15 +81,18 @@ exports.deleteJob = deleteJob;
81
81
  // container the runtime could not create) may last before it fails the service
82
82
  exports.PASSING_FAILURE_MS = 60000;
83
83
  // Waits for an available replica: a ready one, for a service without a healthcheck one that
84
- // stayed ready for a while. The deadline only starts once a pod runs, so an
85
- // image pull doesn't count against it (as on docker, where it starts after
86
- // the container started). A pod that won't get there without a change (an image
87
- // that isn't there, a container that crashed twice in a row) fails it at once, with
84
+ // stayed ready for a while. The deadline only starts once the container runs, and again
85
+ // at each start of it, so an image pull or a back-off doesn't count against it (as on
86
+ // docker, where it starts after the container started). A pod that won't get there without a change (an image
87
+ // that isn't there, a container that crashed once more than its
88
+ // service's `restarts`, 2 by default) fails it at once, with
88
89
  // the reason the cluster gives; one that may still pass fails once it lasted a minute.
89
90
  // Only the pods of this Deployment count: not the ones of one deleted before it was
90
91
  // created again, which may still be there, terminating or not yet.
91
92
  async function awaitDeployRunningState(instance, env, name, readiness, pollInterval = 1000, now = Date.now) {
92
93
  let runningSince = null;
94
+ // how often the container was restarted when last looked at
95
+ let restarts = 0;
93
96
  let said;
94
97
  // since when a pod has had a failure that may still pass, by pod
95
98
  const failingSince = new Map();
@@ -108,7 +111,7 @@ async function awaitDeployRunningState(instance, env, name, readiness, pollInter
108
111
  !(created && pod.metadata?.creationTimestamp && pod.metadata.creationTimestamp < created));
109
112
  for (const [index, pod] of pods.entries()) {
110
113
  const key = pod.metadata?.name ?? `${index}`;
111
- const failure = (0, cluster_state_1.getPodFailure)(pod, readiness.container);
114
+ const failure = (0, cluster_state_1.getPodFailure)(pod, readiness.container, readiness.restarts);
112
115
  if (!failure) {
113
116
  failingSince.delete(key);
114
117
  continue;
@@ -125,14 +128,23 @@ async function awaitDeployRunningState(instance, env, name, readiness, pollInter
125
128
  }
126
129
  said = waiting;
127
130
  if (readiness.timeout !== null) {
128
- if (runningSince === null) {
129
- if (pods.some((pod) => pod.status?.phase === 'Running')) {
130
- runningSince = now();
131
- }
131
+ // the deadline bounds each start of the container, as on Docker: a crash is counted by
132
+ // `restarts`, and the back-off before the next start doesn't count against it
133
+ const statuses = pods.flatMap((pod) => (pod.status?.containerStatuses ?? []).filter((status) => !readiness.container || status.name === readiness.container));
134
+ const running = statuses.length > 0
135
+ ? statuses.some((status) => status.state?.running)
136
+ : pods.some((pod) => pod.status?.phase === 'Running');
137
+ const restarted = Math.max(0, ...statuses.map((status) => status.restartCount ?? 0));
138
+ if (!running) {
139
+ runningSince = null;
140
+ }
141
+ else if (runningSince === null || restarted !== restarts) {
142
+ runningSince = now();
132
143
  }
133
144
  else if (now() - runningSince >= readiness.timeout) {
134
145
  throw new Error(readiness.timeoutMessage);
135
146
  }
147
+ restarts = restarted;
136
148
  }
137
149
  await (0, sleep_1.sleep)(pollInterval);
138
150
  }
@@ -12,8 +12,8 @@ const MAX_MESSAGE = 500;
12
12
  const MAX_EVENTS = 20;
13
13
  // events the result of a task says better: a Job of a failed command
14
14
  const SAID_ELSEWHERE = ['BackoffLimitExceeded'];
15
- // A container restarted this often in a row is taken as one that keeps crashing: once may be
16
- // a race with a dependency or a slow first start
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
17
  exports.CRASHES_TO_FAIL = 2;
18
18
  // what a pull error says when the image is not there to pull, or not for this cluster:
19
19
  // waiting does not change it. Others (a registry that limits, a token not there yet) may pass.
@@ -21,13 +21,14 @@ const IMAGE_GONE = /not found|manifest unknown|unauthorized|denied|forbidden|inv
21
21
  /**
22
22
  * Why a pod of a service will not become ready without a change, as the cluster says it:
23
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.
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.
25
26
  *
26
27
  * Only what the cluster wrote is quoted: the termination message of a container is its
27
28
  * own output (and may hold a value it read), except for the network gate's, which says why
28
29
  * the policy did not take hold.
29
30
  */
30
- function getPodFailure(pod, container) {
31
+ function getPodFailure(pod, container, restarts = exports.CRASHES_TO_FAIL) {
31
32
  for (const status of pod.status?.initContainerStatuses ?? []) {
32
33
  const terminated = status.state?.terminated ?? status.lastState?.terminated;
33
34
  if (!terminated || terminated.exitCode === 0) {
@@ -44,7 +45,7 @@ function getPodFailure(pod, container) {
44
45
  }
45
46
  }
46
47
  for (const status of mainStatuses(pod, container)) {
47
- const failure = getContainerFailure(status);
48
+ const failure = getContainerFailure(status, restartsEnded(pod, status), restarts);
48
49
  if (failure) {
49
50
  return failure;
50
51
  }
@@ -52,7 +53,7 @@ function getPodFailure(pod, container) {
52
53
  return null;
53
54
  }
54
55
  exports.getPodFailure = getPodFailure;
55
- function getContainerFailure(status) {
56
+ function getContainerFailure(status, restartsEnded, restarts) {
56
57
  const waiting = status.state?.waiting;
57
58
  const reason = waiting?.reason;
58
59
  const said = waiting?.message ? `: ${cut(waiting.message)}` : '';
@@ -76,15 +77,21 @@ function getContainerFailure(status) {
76
77
  case 'RunContainerError':
77
78
  return { text: `its container could not be started (${reason})${said}`, final: false };
78
79
  case 'CrashLoopBackOff':
79
- return status.restartCount >= exports.CRASHES_TO_FAIL
80
+ return status.restartCount >= restarts
80
81
  ? { text: `it keeps crashing: ${describeLastExit(status)}`, final: true }
81
82
  : null;
82
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
+ }
83
89
  return null;
84
90
  }
85
91
  // How the last instance of a container ended, as the cluster says it.
86
92
  function describeLastExit(status) {
87
- const last = status.lastState?.terminated ?? status.state?.terminated;
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;
88
95
  const restarts = `, restarted ${status.restartCount} time${status.restartCount === 1 ? '' : 's'}`;
89
96
  if (!last) {
90
97
  return `it ended${restarts}`;
@@ -120,6 +127,10 @@ function getPodWaiting(pod, container) {
120
127
  case 'CrashLoopBackOff':
121
128
  return `crashed and starts again: ${describeLastExit(status)}`;
122
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
+ }
123
134
  if (status.restartCount > 0 && status.state?.running && !status.ready) {
124
135
  return `restarted: ${describeLastExit(status)}`;
125
136
  }
@@ -127,6 +138,16 @@ function getPodWaiting(pod, container) {
127
138
  return undefined;
128
139
  }
129
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
+ }
130
151
  function mainStatuses(pod, container) {
131
152
  return (pod.status?.containerStatuses ?? []).filter((status) => !container || status.name === container);
132
153
  }
@@ -17,6 +17,8 @@ const network_policy_1 = require("./network-policy");
17
17
  const egress_proxy_1 = require("../network/egress-proxy");
18
18
  // what the service was created from, to tell a changed one
19
19
  exports.DEFINITION_LABEL = 'hammerkit.dev/definition';
20
+ // how often a starting container is checked: as the liveness probe
21
+ const STARTUP_PERIOD_SECONDS = 5;
20
22
  async function ensureKubernetesDeploymentExists(instance, env, service, persistence, secrets, stateKey,
21
23
  // the needed services by name, and the proxy of a limited network
22
24
  network) {
@@ -34,6 +36,16 @@ network) {
34
36
  failureThreshold: 3,
35
37
  }
36
38
  : undefined;
39
+ // the healthcheck's `timeout` for each start: until it passed once, the liveness probe
40
+ // doesn't kill a container that starts slowly, and the wait fails it with the timeout
41
+ const startupProbe = probe && service.data.healthcheck
42
+ ? {
43
+ ...probe,
44
+ // a check more than the timeout holds, and one more for the first check's offset: the
45
+ // wait fails it with the timeout before the kubelet restarts it
46
+ failureThreshold: Math.ceil(service.data.healthcheck.timeout / (STARTUP_PERIOD_SECONDS * 1000)) + 2,
47
+ }
48
+ : undefined;
37
49
  const server = env.server;
38
50
  const placement = server ? (0, job_placement_1.getPodPlacement)(server, undefined) : null;
39
51
  const workspace = (0, volumes_1.getWorkspacePodSpec)(env, persistence);
@@ -103,6 +115,7 @@ network) {
103
115
  })),
104
116
  readinessProbe: probe,
105
117
  livenessProbe: probe,
118
+ startupProbe,
106
119
  ...(0, container_resources_1.getKubernetesResources)(service.data.resources),
107
120
  volumeMounts: [...persistence.mounts.map((m) => m.mount), ...secrets.mounts],
108
121
  ...(0, job_placement_1.getContainerSecurity)(env),
@@ -134,6 +147,7 @@ network) {
134
147
  container: getServiceContainerName(service),
135
148
  subject: `service ${service.name}`,
136
149
  onWaiting: (text) => service.status.write('warn', `${service.name} ${text}`),
150
+ ...(service.data.restarts !== null ? { restarts: service.data.restarts } : {}),
137
151
  });
138
152
  }
139
153
  exports.ensureKubernetesDeploymentExists = ensureKubernetesDeploymentExists;
@@ -118,6 +118,7 @@ function parseService(workTree, service, environment, context) {
118
118
  secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment, context.secrets, service.schema.account),
119
119
  resources: (0, work_resources_1.parseWorkResources)(service.schema.resources),
120
120
  volumeSize: service.schema.volumeSize ?? null,
121
+ restarts: service.schema.restarts ?? null,
121
122
  network: (0, work_network_1.parseWorkNetwork)(service.schema.network),
122
123
  networkCeiling: null,
123
124
  args: service.schema.args ?? null,
@@ -41,6 +41,14 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
41
41
  // the service account its provider secrets are read as, unless a secret names one
42
42
  account: (0, zod_1.string)().optional(),
43
43
  resources: resources_schema_1.resourcesSchema.optional(),
44
+ // on Kubernetes, how often its container may crash and be restarted before the
45
+ // service fails (2 by default): it fails when it crashes once more
46
+ restarts: (0, zod_1.number)()
47
+ .int()
48
+ .min(0)
49
+ .max(10)
50
+ .describe('crashes restarted before it fails on Kubernetes, 0 to 10, 2 by default')
51
+ .optional(),
44
52
  // on Kubernetes, the size of the claim that holds its sources, mounts and volumes
45
53
  volumeSize: size_schema_1.sizeSchema.describe('size of its claim on Kubernetes, e.g. 10Gi').optional(),
46
54
  // what the service reaches besides its needs; tasks needing it always reach it
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.27.0-next.28",
4
+ "version": "1.27.0-next.29",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",