hammerkit 1.27.0-next.24 → 1.27.0-next.26

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
@@ -459,6 +459,91 @@
459
459
  },
460
460
  "additionalProperties": false,
461
461
  "description": "local task"
462
+ },
463
+ {
464
+ "type": "object",
465
+ "properties": {
466
+ "deps": {
467
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/deps"
468
+ },
469
+ "needs": {
470
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/needs"
471
+ },
472
+ "description": {
473
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/description"
474
+ },
475
+ "extend": {
476
+ "type": "string"
477
+ },
478
+ "reset": {
479
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/reset"
480
+ },
481
+ "envs": {
482
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/envs"
483
+ },
484
+ "labels": {
485
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/labels"
486
+ },
487
+ "cache": {
488
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/cache"
489
+ },
490
+ "cmds": {
491
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/cmds"
492
+ },
493
+ "src": {
494
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/src"
495
+ },
496
+ "generates": {
497
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/generates"
498
+ },
499
+ "shell": {
500
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/shell"
501
+ },
502
+ "continuous": {
503
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/continuous"
504
+ },
505
+ "timeout": {
506
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/timeout"
507
+ },
508
+ "secrets": {
509
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/secrets"
510
+ },
511
+ "account": {
512
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/account"
513
+ },
514
+ "resources": {
515
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources"
516
+ },
517
+ "network": {
518
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network"
519
+ },
520
+ "args": {
521
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/args"
522
+ },
523
+ "runner": {
524
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
525
+ },
526
+ "publish": {
527
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/publish"
528
+ },
529
+ "image": {
530
+ "type": "string"
531
+ },
532
+ "mounts": {
533
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/mounts"
534
+ },
535
+ "volumeSize": {
536
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/volumeSize"
537
+ },
538
+ "capAdd": {
539
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/capAdd"
540
+ }
541
+ },
542
+ "required": [
543
+ "extend"
544
+ ],
545
+ "additionalProperties": false,
546
+ "description": "container task taking its image from the task it extends"
462
547
  }
463
548
  ]
464
549
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.listPublishedTasks = exports.runCheck = exports.CHECK_DESCRIPTION = exports.SandboxClientError = void 0;
3
+ exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.readRunLog = exports.getRun = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.listPublishedTasks = exports.runCheck = exports.CHECK_DESCRIPTION = exports.SandboxClientError = void 0;
4
4
  const path_1 = require("path");
5
5
  const token_source_1 = require("../remote/token-source");
6
6
  // a write carries up to 32 MiB
@@ -323,7 +323,9 @@ async function followRun(connection, submitted, options = {}) {
323
323
  const cancel = () => {
324
324
  cancelling ?? (cancelling = call(connection, 'POST', `${path}/cancel`).catch(() => undefined));
325
325
  };
326
- options.signal?.addEventListener('abort', cancel, { once: true });
326
+ if (!options.detach) {
327
+ options.signal?.addEventListener('abort', cancel, { once: true });
328
+ }
327
329
  const lines = [];
328
330
  let from = 1;
329
331
  let truncated = false;
@@ -348,6 +350,9 @@ async function followRun(connection, submitted, options = {}) {
348
350
  let run = submitted;
349
351
  let waitingSaid = false;
350
352
  for (;;) {
353
+ if (options.signal?.aborted && options.detach) {
354
+ break;
355
+ }
351
356
  if (options.signal?.aborted) {
352
357
  // the process may exit after this, so the cancel has to reach the server first
353
358
  cancel();
@@ -367,18 +372,7 @@ async function followRun(connection, submitted, options = {}) {
367
372
  run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
368
373
  }
369
374
  if (truncated) {
370
- // the end of a long log is kept in the results
371
- const tasks = options.task !== undefined ? [options.task] : Object.keys(run.results ?? {});
372
- for (const task of tasks) {
373
- const result = run.results?.[task];
374
- if (!result) {
375
- continue;
376
- }
377
- const said = options.task !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
378
- for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
379
- add({ line: 0, task, stream: 'stdout', text });
380
- }
381
- }
375
+ truncatedEnd(run, options.task).forEach(add);
382
376
  }
383
377
  return { run, lines, truncated };
384
378
  }
@@ -387,6 +381,59 @@ async function followRun(connection, submitted, options = {}) {
387
381
  }
388
382
  }
389
383
  exports.followRun = followRun;
384
+ // The end of a log the server cut off at its limit: the last lines of each task, which its
385
+ // results keep.
386
+ function truncatedEnd(run, only) {
387
+ const lines = [];
388
+ const tasks = only !== undefined ? [only] : Object.keys(run.results ?? {});
389
+ for (const task of tasks) {
390
+ const result = run.results?.[task];
391
+ if (!result) {
392
+ continue;
393
+ }
394
+ const said = only !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
395
+ for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
396
+ lines.push({ line: 0, task, stream: 'stdout', text });
397
+ }
398
+ }
399
+ return lines;
400
+ }
401
+ // A run as the server keeps it, or null when it keeps none the caller may see.
402
+ async function getRun(connection, id) {
403
+ const response = await call(connection, 'GET', `/v1/runs/${encodeURIComponent(id)}`, { missing: true });
404
+ return response.status === 404 ? null : json(response);
405
+ }
406
+ exports.getRun = getRun;
407
+ /**
408
+ * Reads the log of a run as it is now, every line or the last `tail` of them, of one task or
409
+ * of all: also of a run that ended, as long as the server keeps it.
410
+ */
411
+ async function readRunLog(connection, run, query, onLine) {
412
+ const path = `/v1/runs/${encodeURIComponent(run.id)}/logs`;
413
+ if (query.tail !== undefined) {
414
+ const page = await json(await call(connection, 'GET', path, { query: { task: query.task, tail: query.tail } }));
415
+ page.lines.forEach(onLine);
416
+ return { truncated: page.truncated };
417
+ }
418
+ let from = 1;
419
+ let truncated = false;
420
+ for (;;) {
421
+ const page = await json(await call(connection, 'GET', path, { query: { task: query.task, from, limit: 1000 } }));
422
+ truncated = page.truncated;
423
+ for (const line of page.lines) {
424
+ onLine(line);
425
+ from = line.line + 1;
426
+ }
427
+ if (!page.next) {
428
+ break;
429
+ }
430
+ }
431
+ if (truncated) {
432
+ truncatedEnd(run, query.task).forEach(onLine);
433
+ }
434
+ return { truncated };
435
+ }
436
+ exports.readRunLog = readRunLog;
390
437
  function exitCodeOf(result) {
391
438
  if (!result) {
392
439
  return null;
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.stopJob = exports.deleteJobAndWait = exports.awaitDeployRunningState = exports.deleteJob = exports.awaitJobCompletion = exports.awaitRunningState = void 0;
3
+ exports.stopJob = exports.deleteJobAndWait = exports.awaitDeployRunningState = exports.PASSING_FAILURE_MS = 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");
7
7
  const sleep_1 = require("../utils/sleep");
8
+ const cluster_state_1 = require("./cluster-state");
8
9
  // The waits below poll instead of using the client's Watch: Watch aborts every
9
10
  // request after 30s, which a pod or deployment still pulling its image outlasts.
10
11
  async function awaitRunningState(instance, env, name, phase, pollInterval = 1000) {
@@ -76,23 +77,56 @@ async function deleteJob(instance, env, name) {
76
77
  }
77
78
  }
78
79
  exports.deleteJob = deleteJob;
79
- // Waits for a ready replica. The deadline only starts once a pod runs, so an
80
+ // how long a reason that may still pass (an image pull a registry refused for now, a
81
+ // container the runtime could not create) may last before it fails the service
82
+ exports.PASSING_FAILURE_MS = 60000;
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
80
85
  // image pull doesn't count against it (as on docker, where it starts after
81
- // the container started).
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
88
+ // the reason the cluster gives; one that may still pass fails once it lasted a minute.
89
+ // Only the pods of this Deployment count: not the ones of one deleted before it was
90
+ // created again, which may still be there, terminating or not yet.
82
91
  async function awaitDeployRunningState(instance, env, name, readiness, pollInterval = 1000, now = Date.now) {
83
92
  let runningSince = null;
93
+ let said;
94
+ // since when a pod has had a failure that may still pass, by pod
95
+ const failingSince = new Map();
84
96
  for (;;) {
85
97
  const deployment = await instance.appsApi.readNamespacedDeployment({ name, namespace: env.namespace });
86
- if ((deployment.status?.readyReplicas ?? 0) > 0) {
98
+ // available: ready for the Deployment's minReadySeconds
99
+ if ((deployment.status?.availableReplicas ?? 0) > 0) {
87
100
  return;
88
101
  }
102
+ const created = deployment.metadata?.creationTimestamp;
103
+ const listed = await instance.coreApi.listNamespacedPod({
104
+ namespace: env.namespace,
105
+ labelSelector: readiness.podSelector,
106
+ });
107
+ const pods = listed.items.filter((pod) => !pod.metadata?.deletionTimestamp &&
108
+ !(created && pod.metadata?.creationTimestamp && pod.metadata.creationTimestamp < created));
109
+ for (const [index, pod] of pods.entries()) {
110
+ const key = pod.metadata?.name ?? `${index}`;
111
+ const failure = (0, cluster_state_1.getPodFailure)(pod, readiness.container);
112
+ if (!failure) {
113
+ failingSince.delete(key);
114
+ continue;
115
+ }
116
+ const since = failingSince.get(key) ?? now();
117
+ failingSince.set(key, since);
118
+ if (failure.final || now() - since >= exports.PASSING_FAILURE_MS) {
119
+ throw new Error(`${readiness.subject ?? name} can not become ready, ${failure.text}`);
120
+ }
121
+ }
122
+ const waiting = pods.map((pod) => (0, cluster_state_1.getPodWaiting)(pod, readiness.container)).find((text) => !!text);
123
+ if (waiting && waiting !== said) {
124
+ readiness.onWaiting?.(waiting);
125
+ }
126
+ said = waiting;
89
127
  if (readiness.timeout !== null) {
90
128
  if (runningSince === null) {
91
- const pods = await instance.coreApi.listNamespacedPod({
92
- namespace: env.namespace,
93
- labelSelector: readiness.podSelector,
94
- });
95
- if (pods.items.some((pod) => pod.status?.phase === 'Running')) {
129
+ if (pods.some((pod) => pod.status?.phase === 'Running')) {
96
130
  runningSince = now();
97
131
  }
98
132
  }
@@ -0,0 +1,224 @@
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 in a row is taken as one that keeps crashing: once may be
16
+ // 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.
25
+ *
26
+ * Only what the cluster wrote is quoted: the termination message of a container is its
27
+ * own output (and may hold a value it read), except for the network gate's, which says why
28
+ * the policy did not take hold.
29
+ */
30
+ function getPodFailure(pod, container) {
31
+ for (const status of pod.status?.initContainerStatuses ?? []) {
32
+ const terminated = status.state?.terminated ?? status.lastState?.terminated;
33
+ if (!terminated || terminated.exitCode === 0) {
34
+ continue;
35
+ }
36
+ if (status.name === network_gate_1.NETWORK_GATE_CONTAINER) {
37
+ return {
38
+ text: `the network policy did not take hold: ${cut(terminated.message?.trim() || 'the gate failed')}`,
39
+ final: true,
40
+ };
41
+ }
42
+ if (status.restartCount >= exports.CRASHES_TO_FAIL) {
43
+ return { text: `its init container ${status.name} failed with exit code ${terminated.exitCode}`, final: true };
44
+ }
45
+ }
46
+ for (const status of mainStatuses(pod, container)) {
47
+ const failure = getContainerFailure(status);
48
+ if (failure) {
49
+ return failure;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ exports.getPodFailure = getPodFailure;
55
+ function getContainerFailure(status) {
56
+ const waiting = status.state?.waiting;
57
+ const reason = waiting?.reason;
58
+ const said = waiting?.message ? `: ${cut(waiting.message)}` : '';
59
+ switch (reason) {
60
+ // the first pull: final only when the registry said the image is not there, as older
61
+ // kubelets leave that out of the ImagePullBackOff that follows
62
+ case 'ErrImagePull':
63
+ return IMAGE_GONE.test(waiting?.message ?? '')
64
+ ? { text: `its image ${status.image} could not be pulled (${reason})${said}`, final: true }
65
+ : null;
66
+ case 'ImagePullBackOff':
67
+ return {
68
+ text: `its image ${status.image} could not be pulled (${reason})${said}`,
69
+ final: IMAGE_GONE.test(waiting?.message ?? ''),
70
+ };
71
+ case 'ErrImageNeverPull':
72
+ case 'InvalidImageName':
73
+ return { text: `its image ${status.image} could not be pulled (${reason})${said}`, final: true };
74
+ case 'CreateContainerConfigError':
75
+ case 'CreateContainerError':
76
+ case 'RunContainerError':
77
+ return { text: `its container could not be started (${reason})${said}`, final: false };
78
+ case 'CrashLoopBackOff':
79
+ return status.restartCount >= exports.CRASHES_TO_FAIL
80
+ ? { text: `it keeps crashing: ${describeLastExit(status)}`, final: true }
81
+ : null;
82
+ }
83
+ return null;
84
+ }
85
+ // How the last instance of a container ended, as the cluster says it.
86
+ function describeLastExit(status) {
87
+ const last = status.lastState?.terminated ?? status.state?.terminated;
88
+ const restarts = `, restarted ${status.restartCount} time${status.restartCount === 1 ? '' : 's'}`;
89
+ if (!last) {
90
+ return `it ended${restarts}`;
91
+ }
92
+ if (last.reason === 'OOMKilled') {
93
+ return `it was killed for using more memory than its limit (OOMKilled)${restarts}`;
94
+ }
95
+ // the kubelet kills a container whose liveness probe (its healthcheck) keeps failing
96
+ const killed = last.exitCode === 137 || last.exitCode === 143 ? ', killed as when its healthcheck kept failing' : '';
97
+ return `it exited with code ${last.exitCode}${last.reason ? ` (${last.reason})` : ''}${killed}${restarts}`;
98
+ }
99
+ exports.describeLastExit = describeLastExit;
100
+ /**
101
+ * What a pod of a service waits for, in a few words, while it is not ready: a node, its
102
+ * image, its container. Undefined when it waits for nothing the cluster names.
103
+ */
104
+ function getPodWaiting(pod, container) {
105
+ const scheduled = pod.status?.conditions?.find((condition) => condition.type === 'PodScheduled');
106
+ if (scheduled?.status === 'False') {
107
+ return `waits for a node: ${cut(scheduled.message ?? scheduled.reason ?? 'not scheduled yet')}`;
108
+ }
109
+ for (const status of mainStatuses(pod, container)) {
110
+ const waiting = status.state?.waiting;
111
+ const said = waiting?.message ? `: ${cut(waiting.message)}` : '';
112
+ switch (waiting?.reason) {
113
+ case 'ErrImagePull':
114
+ case 'ImagePullBackOff':
115
+ return `can not pull its image ${status.image} yet${said}`;
116
+ case 'CreateContainerConfigError':
117
+ case 'CreateContainerError':
118
+ case 'RunContainerError':
119
+ return `can not start its container yet (${waiting.reason})${said}`;
120
+ case 'CrashLoopBackOff':
121
+ return `crashed and starts again: ${describeLastExit(status)}`;
122
+ }
123
+ if (status.restartCount > 0 && status.state?.running && !status.ready) {
124
+ return `restarted: ${describeLastExit(status)}`;
125
+ }
126
+ }
127
+ return undefined;
128
+ }
129
+ exports.getPodWaiting = getPodWaiting;
130
+ function mainStatuses(pod, container) {
131
+ return (pod.status?.containerStatuses ?? []).filter((status) => !container || status.name === container);
132
+ }
133
+ // One line of a pod that is not as it should be, or null for one that runs, ready, or ended well.
134
+ function describePod(pod) {
135
+ const name = pod.metadata?.name ?? '?';
136
+ const phase = pod.status?.phase ?? 'Unknown';
137
+ const failure = getPodFailure(pod)?.text;
138
+ const waiting = getPodWaiting(pod);
139
+ const unready = (pod.status?.containerStatuses ?? [])
140
+ .filter((status) => !status.ready && !status.state?.terminated)
141
+ .map((status) => status.name);
142
+ const crashed = (pod.status?.containerStatuses ?? []).filter((status) => (status.state?.terminated && status.state.terminated.exitCode !== 0) || status.restartCount > 0);
143
+ if (!failure && !waiting && crashed.length === 0 && (phase === 'Succeeded' || unready.length === 0)) {
144
+ return null;
145
+ }
146
+ const said = failure ?? waiting;
147
+ const parts = [phase, ...(said ? [said] : [])];
148
+ for (const status of crashed) {
149
+ if (!said?.includes(describeLastExit(status))) {
150
+ parts.push(`${status.name}: ${describeLastExit(status)}`);
151
+ }
152
+ }
153
+ if (!said && unready.length > 0) {
154
+ parts.push(`not ready: ${unready.join(', ')}`);
155
+ }
156
+ return `pod ${name}: ${parts.join(', ')}`;
157
+ }
158
+ exports.describePod = describePod;
159
+ /**
160
+ * The message of a warning event, as far as it is the cluster's: a failed probe says the
161
+ * output of the command, which is the service's, and is left out.
162
+ */
163
+ function describeEvent(event) {
164
+ const object = event.involvedObject;
165
+ const message = (event.message ?? '').replace(/^((Readiness|Liveness|Startup) probe (failed|errored)).*$/s, '$1');
166
+ const count = (event.count ?? 1) > 1 ? ` (${event.count} times)` : '';
167
+ return `${event.reason ?? 'Warning'} ${object.kind?.toLowerCase() ?? 'object'}/${object.name ?? '?'}: ${cut(message.trim())}${count}`;
168
+ }
169
+ exports.describeEvent = describeEvent;
170
+ /**
171
+ * What the cluster said of a run that failed, into its log before the server removes its
172
+ * objects: its pods that are not as they should be, and the warning events of its objects.
173
+ * In the namespace of a service run everything is the run's; in a caller's namespace, which
174
+ * runs share, only the objects of the run's items (named by their key) are.
175
+ */
176
+ async function readClusterState(instance, kubernetes, workTree, ownNamespace) {
177
+ const namespace = kubernetes.namespace;
178
+ const keys = [...Object.values(workTree.tasks), ...Object.values(workTree.services)].map((item) => (0, resources_1.getItemKey)(item, kubernetes));
179
+ const ours = (name) => ownNamespace || keys.some((key) => name?.includes(key));
180
+ const lines = [];
181
+ try {
182
+ const labels = (0, job_placement_1.getRunLabels)(kubernetes);
183
+ const labelSelector = Object.entries(labels)
184
+ .map(([key, value]) => `${key}=${value}`)
185
+ .join(',');
186
+ const pods = await instance.coreApi.listNamespacedPod({
187
+ namespace,
188
+ ...(labelSelector && !ownNamespace ? { labelSelector } : {}),
189
+ });
190
+ for (const pod of pods.items) {
191
+ const line = ours(pod.metadata?.name) ? describePod(pod) : null;
192
+ if (line) {
193
+ lines.push(line);
194
+ }
195
+ }
196
+ }
197
+ catch (e) {
198
+ lines.push(`its pods could not be read: ${(0, log_1.getErrorMessage)(e)}`);
199
+ }
200
+ try {
201
+ const events = await instance.coreApi.listNamespacedEvent({ namespace, fieldSelector: 'type=Warning' });
202
+ const warnings = events.items
203
+ .filter((event) => ours(event.involvedObject.name) && !SAID_ELSEWHERE.includes(event.reason ?? ''))
204
+ .sort((a, b) => eventTime(a) - eventTime(b))
205
+ .slice(-MAX_EVENTS);
206
+ lines.push(...warnings.map((event) => `event ${describeEvent(event)}`));
207
+ }
208
+ catch (e) {
209
+ lines.push((0, apply_1.statusCodeOf)(e) === 403
210
+ ? `its events could not be read: the runRole of the server needs list on events (see the backend docs)`
211
+ : `its events could not be read: ${(0, log_1.getErrorMessage)(e)}`);
212
+ }
213
+ return lines;
214
+ }
215
+ exports.readClusterState = readClusterState;
216
+ function eventTime(event) {
217
+ const time = event.lastTimestamp ?? event.eventTime ?? event.firstTimestamp ?? event.metadata?.creationTimestamp;
218
+ return time ? new Date(time).getTime() : 0;
219
+ }
220
+ function cut(text) {
221
+ const line = text.replace(/\s+/g, ' ');
222
+ return line.length > MAX_MESSAGE ? `${line.slice(0, MAX_MESSAGE)}…` : line;
223
+ }
224
+ //# sourceMappingURL=cluster-state.js.map
@@ -1,6 +1,6 @@
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.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");
@@ -57,6 +57,9 @@ network) {
57
57
  matchLabels: (0, resources_1.getItemLabels)(service, env),
58
58
  },
59
59
  replicas: 1,
60
+ // without a healthcheck a pod is ready as soon as its container runs, also one that
61
+ // exits a second later: it counts once it ran this long without a restart
62
+ minReadySeconds: probe ? 0 : exports.MIN_READY_SECONDS,
60
63
  template: {
61
64
  metadata: {
62
65
  labels: {
@@ -83,7 +86,7 @@ network) {
83
86
  : {}),
84
87
  containers: [
85
88
  {
86
- name: service.name.replace(/:/, '-'),
89
+ name: getServiceContainerName(service),
87
90
  image: service.data.image,
88
91
  workingDir: (0, work_service_1.getServiceWorkingDir)(service.data),
89
92
  env: [
@@ -119,7 +122,7 @@ network) {
119
122
  },
120
123
  };
121
124
  const deploy = await (0, apply_1.apply)(instance, deployment);
122
- if (deploy.status?.readyReplicas ?? 0 > 0) {
125
+ if ((deploy.status?.availableReplicas ?? 0) > 0) {
123
126
  return;
124
127
  }
125
128
  await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, {
@@ -128,9 +131,19 @@ network) {
128
131
  timeoutMessage: service.data.healthcheck
129
132
  ? (0, work_healthcheck_1.getHealthcheckTimeoutMessage)(service.name, service.data.healthcheck)
130
133
  : '',
134
+ container: getServiceContainerName(service),
135
+ subject: `service ${service.name}`,
136
+ onWaiting: (text) => service.status.write('warn', `${service.name} ${text}`),
131
137
  });
132
138
  }
133
139
  exports.ensureKubernetesDeploymentExists = ensureKubernetesDeploymentExists;
140
+ // how long the container of a service without a healthcheck has to run before it counts as up
141
+ exports.MIN_READY_SECONDS = 10;
142
+ // The container of a service's pod that runs its command, beside the `debug` one.
143
+ function getServiceContainerName(service) {
144
+ return service.name.replace(/:/, '-');
145
+ }
146
+ exports.getServiceContainerName = getServiceContainerName;
134
147
  // Kubernetes `command` replaces the image entrypoint and `args` its command.
135
148
  // A shell service sets both; an exec-form `cmd` keeps the established mapping
136
149
  // (first token as `command`, the rest as `args`).
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.followPodLogs = void 0;
4
+ const stream_1 = require("stream");
5
+ const readline_1 = require("readline");
6
+ const log_1 = require("../log");
7
+ // what is read of an instance of a container that already ended
8
+ const MAX_ENDED_LOG_BYTES = 1024 * 1024;
9
+ /**
10
+ * Follows the log of a container in the pods of a selector, as `docker attach` does for a
11
+ * service on Docker: the instance that runs as it writes, and an instance that ended before
12
+ * it was seen (one that crashed and was restarted) once, from its start. Each instance is
13
+ * read once, by its container id.
14
+ */
15
+ function followPodLogs(instance, namespace, labelSelector, container, onLine, options = {}) {
16
+ const seen = new Set();
17
+ const following = [];
18
+ const reading = new Set();
19
+ let stopped = false;
20
+ let failed = false;
21
+ let timer;
22
+ let polling = Promise.resolve();
23
+ let busy = false;
24
+ let again = false;
25
+ // the logs followed now, which end when their instance does
26
+ let streaming = 0;
27
+ const fail = (e) => {
28
+ if (!failed && !stopped) {
29
+ failed = true;
30
+ options.onError?.(`the log of ${container} could not be read: ${(0, log_1.getErrorMessage)(e)}`);
31
+ }
32
+ };
33
+ const track = (promise) => {
34
+ const tracked = promise.catch(fail).finally(() => reading.delete(tracked));
35
+ reading.add(tracked);
36
+ };
37
+ const readEnded = async (pod, previous) => {
38
+ const log = await instance.coreApi.readNamespacedPodLog({
39
+ name: pod,
40
+ namespace,
41
+ container,
42
+ previous,
43
+ limitBytes: MAX_ENDED_LOG_BYTES,
44
+ });
45
+ for (const line of String(log ?? '').split(/\r?\n/)) {
46
+ if (line.length > 0) {
47
+ onLine(line);
48
+ }
49
+ }
50
+ };
51
+ const follow = async (pod) => {
52
+ const output = new stream_1.PassThrough();
53
+ let ended = false;
54
+ // the instance ended or the request broke: a restarted one is looked for at once
55
+ const end = () => {
56
+ if (!ended) {
57
+ ended = true;
58
+ streaming--;
59
+ wake();
60
+ }
61
+ };
62
+ // stop() aborts the request, which errors the stream the client pipes in here
63
+ output.on('pipe', (source) => source.on('error', () => {
64
+ end();
65
+ }));
66
+ output.on('end', end);
67
+ (0, readline_1.createInterface)({ input: output }).on('line', (line) => onLine(line));
68
+ streaming++;
69
+ let controller;
70
+ try {
71
+ controller = await instance.log.log(namespace, pod, container, output, { follow: true });
72
+ }
73
+ catch (e) {
74
+ end();
75
+ throw e;
76
+ }
77
+ if (stopped) {
78
+ controller.abort();
79
+ }
80
+ else {
81
+ following.push(controller);
82
+ }
83
+ };
84
+ const look = (pod) => {
85
+ const name = pod.metadata?.name;
86
+ const status = pod.status?.containerStatuses?.find((s) => s.name === container);
87
+ // a pod that goes is of a Deployment before this one, or of a rollout that replaced it
88
+ if (!name || !status || pod.metadata?.deletionTimestamp) {
89
+ return;
90
+ }
91
+ // an instance that ended before this one, not seen while it ran: its lines go first
92
+ let before = Promise.resolve();
93
+ const last = status.lastState?.terminated?.containerID;
94
+ if (last && !seen.has(last)) {
95
+ seen.add(last);
96
+ before = readEnded(name, true).catch(fail);
97
+ track(before);
98
+ }
99
+ const current = status.containerID;
100
+ if (!current || seen.has(current)) {
101
+ return;
102
+ }
103
+ if (status.state?.running) {
104
+ seen.add(current);
105
+ track(before.then(() => follow(name)));
106
+ }
107
+ else if (status.state?.terminated) {
108
+ seen.add(current);
109
+ track(before.then(() => readEnded(name, false)));
110
+ }
111
+ };
112
+ const poll = async () => {
113
+ try {
114
+ const pods = await instance.coreApi.listNamespacedPod({ namespace, labelSelector });
115
+ if (!stopped) {
116
+ pods.items.forEach(look);
117
+ }
118
+ }
119
+ catch (e) {
120
+ fail(e);
121
+ }
122
+ };
123
+ const loop = () => {
124
+ if (stopped) {
125
+ return;
126
+ }
127
+ busy = true;
128
+ polling = poll().finally(() => {
129
+ busy = false;
130
+ if (stopped) {
131
+ return;
132
+ }
133
+ if (again) {
134
+ again = false;
135
+ loop();
136
+ return;
137
+ }
138
+ const interval = streaming > 0 ? options.followingInterval ?? 15000 : options.pollInterval ?? 1000;
139
+ timer = setTimeout(loop, interval);
140
+ timer.unref?.();
141
+ });
142
+ };
143
+ // looks now, not at the next interval
144
+ function wake() {
145
+ if (stopped) {
146
+ return;
147
+ }
148
+ if (busy) {
149
+ again = true;
150
+ return;
151
+ }
152
+ clearTimeout(timer);
153
+ loop();
154
+ }
155
+ loop();
156
+ return {
157
+ async settle() {
158
+ await polling;
159
+ await poll();
160
+ // what follows a running instance never ends by itself
161
+ const ended = [...reading];
162
+ let wait;
163
+ await Promise.race([
164
+ Promise.all(ended),
165
+ new Promise((resolve) => {
166
+ wait = setTimeout(resolve, 5000);
167
+ wait.unref?.();
168
+ }),
169
+ ]);
170
+ clearTimeout(wait);
171
+ },
172
+ stop() {
173
+ stopped = true;
174
+ clearTimeout(timer);
175
+ for (const controller of following.splice(0)) {
176
+ controller.abort();
177
+ }
178
+ },
179
+ };
180
+ }
181
+ exports.followPodLogs = followPodLogs;
182
+ //# sourceMappingURL=follow-pod-logs.js.map
@@ -143,7 +143,11 @@ function appendServiceInit(workTree, service, item, environment, context) {
143
143
  task.needs[selfNeed] = { name: task.needs[selfNeed].name, service: view };
144
144
  }
145
145
  else {
146
- task.needs.push({ name: item.name, service: view });
146
+ const name = getInitNeedName(service);
147
+ if (task.needs.some((need) => need.name === name)) {
148
+ throw new Error(`${task.name} needs two services as ${name}, give one another name with name:`);
149
+ }
150
+ task.needs.push({ name, service: view });
147
151
  }
148
152
  removeRequirer(item, task.name);
149
153
  // explicit, so neither the default method nor `--cache` turns it back on
@@ -153,6 +157,18 @@ function appendServiceInit(workTree, service, item, environment, context) {
153
157
  }
154
158
  item.data.init = { task, state };
155
159
  }
160
+ // The host name the init reaches its service by: the name its task declares the
161
+ // need by, else the service's name in its own build file. Planning the task
162
+ // skips that need (it is the init's), and the full name of a service of an
163
+ // include, `services:db`, is no host name: Docker and DNS take no colon.
164
+ function getInitNeedName(service) {
165
+ const declared = service.init?.task.needs.find((need) => need.service.relativeName === service.relativeName);
166
+ if (declared) {
167
+ return declared.relativeName;
168
+ }
169
+ const prefix = service.scope.namePrefix;
170
+ return prefix.length > 0 ? service.relativeName.slice(prefix.length + 1) : service.relativeName;
171
+ }
156
172
  const initViews = new WeakMap();
157
173
  // The service itself for the view its init task needs, else the item.
158
174
  function getPlannedService(item) {
@@ -11,6 +11,7 @@ const replace_env_variables_1 = require("../environment/replace-env-variables");
11
11
  const apply_1 = require("../kubernetes/apply");
12
12
  const ensure_kubernetes_service_exists_1 = require("../kubernetes/ensure-kubernetes-service-exists");
13
13
  const ensure_kubernetes_deployment_exists_1 = require("../kubernetes/ensure-kubernetes-deployment-exists");
14
+ const follow_pod_logs_1 = require("../kubernetes/follow-pod-logs");
14
15
  const service_definition_1 = require("./service-definition");
15
16
  const ensure_namespace_1 = require("../kubernetes/ensure-namespace");
16
17
  const ensure_persistent_data_1 = require("../kubernetes/ensure-persistent-data");
@@ -544,7 +545,31 @@ function kubernetesServiceRuntime(service, kubernetes) {
544
545
  followLog: false,
545
546
  run: kubernetes.server ? (0, job_placement_1.getRunLabelValue)(kubernetes.server.run) : null,
546
547
  });
547
- await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, kubernetes, service.data.secrets), options.stateKey, network);
548
+ // its output, as on Docker: while it starts, and while it runs unless it runs on
549
+ // without hammerkit (`up --daemon`)
550
+ const logs = (0, follow_pod_logs_1.followPodLogs)(instance, kubernetes.namespace, (0, resources_1.getItemSelector)(service, kubernetes), (0, ensure_kubernetes_deployment_exists_1.getServiceContainerName)(service), (line) => service.status.console('stdout', line), { onError: (text) => service.status.write('debug', text) });
551
+ try {
552
+ await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, kubernetes, service.data.secrets), options.stateKey, network);
553
+ }
554
+ catch (e) {
555
+ // the last words of a container that crashed, before why it failed
556
+ await logs.settle().catch(() => undefined);
557
+ logs.stop();
558
+ throw e;
559
+ }
560
+ if (options.daemon || options.abort.aborted || environment.abortCtrl.signal.aborted) {
561
+ logs.stop();
562
+ }
563
+ else {
564
+ // either ends it, and takes the other listener with it
565
+ const listening = new AbortController();
566
+ const stop = () => {
567
+ logs.stop();
568
+ listening.abort();
569
+ };
570
+ options.abort.addEventListener('abort', stop, { once: true, signal: listening.signal });
571
+ environment.abortCtrl.signal.addEventListener('abort', stop, { once: true, signal: listening.signal });
572
+ }
548
573
  const name = (0, resources_1.getResourceName)(service, kubernetes);
549
574
  options.state.set({
550
575
  type: 'running',
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.registerWorkCommands = exports.describePlan = exports.describeApproval = void 0;
6
+ exports.registerWorkCommands = exports.describePlan = exports.describeApproval = exports.describeListedRun = exports.describeRunEnd = void 0;
7
7
  const commander_1 = require("commander");
8
8
  const colors_1 = __importDefault(require("colors"));
9
9
  const token_source_1 = require("./token-source");
@@ -67,6 +67,31 @@ function printableLine(text) {
67
67
  return text.split('\t').map(server_client_1.printableWhole).join('\t');
68
68
  }
69
69
  const collectOption = (value, previous = []) => [...previous, value];
70
+ /**
71
+ * Why a run that did not succeed ended: its error, then each task that failed with its own
72
+ * error or exit code, where the error of the run does not say it already.
73
+ */
74
+ function describeRunEnd(run) {
75
+ const lines = [`${(0, server_client_1.printable)(run.id)} ${(0, server_client_1.printable)(run.state)}${run.error ? `: ${(0, server_client_1.printableWhole)(run.error)}` : ''}`];
76
+ for (const [task, result] of Object.entries(run.results ?? {})) {
77
+ if (result.status !== 'failed') {
78
+ continue;
79
+ }
80
+ const why = result.error ?? (result.exitCode !== undefined ? `exit code ${result.exitCode}` : undefined);
81
+ if (why && !run.error?.includes(`${task}: ${why}`)) {
82
+ lines.push(` ${(0, server_client_1.printable)(task)}: ${(0, server_client_1.printableWhole)(why)}`);
83
+ }
84
+ }
85
+ return lines;
86
+ }
87
+ exports.describeRunEnd = describeRunEnd;
88
+ // What `remote runs` shows of a run, on one line.
89
+ function describeListedRun(run) {
90
+ const what = run.up ? `up ${run.up.services.join(', ')}` : run.tasks.join(', ') || 'by labels';
91
+ const until = run.up && (run.state === 'up' || run.state === 'asleep') && run.expiresAt ? ` until ${run.expiresAt}` : '';
92
+ return `${(0, server_client_1.printable)(run.id)} ${(0, server_client_1.printable)(run.createdAt)} ${(0, server_client_1.printable)(run.state)}${(0, server_client_1.printable)(until)} ${(0, server_client_1.printable)(what)}${run.error ? `: ${(0, server_client_1.printable)(run.error)}` : ''}`;
93
+ }
94
+ exports.describeListedRun = describeListedRun;
70
95
  // What a person who approves is shown of a run: everything the decision rests on.
71
96
  function describeApproval(run) {
72
97
  const source = run.source.type === 'ref'
@@ -241,7 +266,7 @@ function registerWorkCommands(remote, context) {
241
266
  const at = submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : '';
242
267
  environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${tasks.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(ref)}${at}\n`);
243
268
  if (options.detach) {
244
- environment.stdout.write(`follow it with: hammerkit remote show ${(0, server_client_1.printable)(submitted.id)}\n`);
269
+ environment.stdout.write(`follow it with: hammerkit remote logs ${(0, server_client_1.printable)(submitted.id)} --follow\n`);
245
270
  return;
246
271
  }
247
272
  const prefixed = tasks.length > 1;
@@ -267,7 +292,12 @@ function registerWorkCommands(remote, context) {
267
292
  .concat(Object.values(run.results ?? {}))
268
293
  .find((result) => result?.status === 'failed');
269
294
  const exitCode = failed?.exitCode && failed.exitCode > 0 ? failed.exitCode : 1;
270
- fail(`${(0, server_client_1.printable)(run.id)} ${run.state}${run.error ? `: ${(0, server_client_1.printable)(run.error)}` : ''}`, exitCode);
295
+ const [said, ...why] = describeRunEnd(run);
296
+ for (const line of why) {
297
+ environment.stdout.write(`${line}\n`);
298
+ }
299
+ environment.stdout.write(`read its log again with: hammerkit remote logs ${(0, server_client_1.printable)(run.id)}\n`);
300
+ fail(said, exitCode);
271
301
  }));
272
302
  remote
273
303
  .command('up <services...>')
@@ -317,7 +347,12 @@ function registerWorkCommands(remote, context) {
317
347
  });
318
348
  const run = followed.run;
319
349
  if (run.state !== 'up') {
320
- fail(`${(0, server_client_1.printable)(run.id)} ${run.state}${run.error ? `: ${(0, server_client_1.printable)(run.error)}` : ''}`, 1);
350
+ const [said, ...why] = describeRunEnd(run);
351
+ for (const line of why) {
352
+ environment.stdout.write(`${line}\n`);
353
+ }
354
+ environment.stdout.write(`read its log again with: hammerkit remote logs ${(0, server_client_1.printable)(run.id)}\n`);
355
+ fail(said, 1);
321
356
  }
322
357
  environment.stdout.write(`${(0, server_client_1.printable)(run.id)} is up until ${(0, server_client_1.printable)(run.expiresAt ?? '-')}${run.up?.sleepTtl ? `, asleep after ${(0, units_1.formatDuration)(run.up.sleepTtl)} without a request` : ''}\n`);
323
358
  for (const endpoint of run.up?.endpoints ?? []) {
@@ -325,6 +360,78 @@ function registerWorkCommands(remote, context) {
325
360
  }
326
361
  environment.stdout.write(`extend it with: hammerkit remote extend ${(0, server_client_1.printable)(run.id)} 8h, stop it with: hammerkit remote stop ${(0, server_client_1.printable)(run.id)}\n`);
327
362
  }));
363
+ remote
364
+ .command('runs [server]')
365
+ .description('list your runs on a server, newest first: their state, and why one failed')
366
+ .addOption(new commander_1.Option('--limit <count>', 'show at most this many').default('20'))
367
+ .action(guarded('Remote runs was not successful', async (name, options) => {
368
+ const limit = Number(options.limit);
369
+ if (!Number.isInteger(limit) || limit < 1) {
370
+ throw new Error('--limit is a number of runs, 1 or more');
371
+ }
372
+ const { name: serverName, server, token } = await connect(name);
373
+ const { runs } = (await (0, server_client_1.callServer)(server, token, 'GET', '/v1/runs')).body;
374
+ if (runs.length === 0) {
375
+ environment.stdout.write(`${serverName}: you have no runs it still keeps\n`);
376
+ return;
377
+ }
378
+ for (const run of runs.slice(0, limit)) {
379
+ environment.stdout.write(`${describeListedRun(run)}\n`);
380
+ }
381
+ if (runs.length > limit) {
382
+ environment.stdout.write(`… and ${runs.length - limit} older, see them with --limit ${runs.length}\n`);
383
+ }
384
+ }));
385
+ remote
386
+ .command('logs <run> [server]')
387
+ .description('print the log of a run, also of one that ended: what its tasks and services printed, and what the server and the cluster said')
388
+ .addOption(new commander_1.Option('--task <name>', 'only the lines of this task or service'))
389
+ .addOption(new commander_1.Option('--tail <lines>', 'only the last lines, at most 1000').argParser((value) => {
390
+ if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 1000) {
391
+ throw new commander_1.InvalidArgumentError('a number of lines from 1 to 1000');
392
+ }
393
+ return Number(value);
394
+ }))
395
+ .addOption(new commander_1.Option('-f, --follow', 'keep printing what comes until the run ends; ctrl-c stops following, not the run'))
396
+ .action(guarded('Remote logs was not successful', async (id, name, options) => {
397
+ if (!RUN_ID.test(id)) {
398
+ throw new Error(`${(0, server_client_1.printable)(id)} is not a run id (run_...), list them with: hammerkit remote runs`);
399
+ }
400
+ if (options.follow && options.tail !== undefined) {
401
+ throw new Error('--follow prints the whole log, leave --tail out');
402
+ }
403
+ const { name: serverName, server } = await getServer(name);
404
+ const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, serverName, server) };
405
+ const found = await (0, sandbox_client_1.getRun)(connection, id);
406
+ if (!found) {
407
+ throw new Error(`${serverName} keeps no run ${(0, server_client_1.printable)(id)} you may see, list them with: hammerkit remote runs`);
408
+ }
409
+ let run = found;
410
+ const task = options.task;
411
+ const print = (line) => environment.stdout.write(`${task ? '' : `[${(0, server_client_1.printable)(line.task)}] `}${printableLine(line.text)}\n`);
412
+ if (options.follow) {
413
+ run = (await (0, sandbox_client_1.followRun)(connection, run, {
414
+ task,
415
+ signal: environment.abortCtrl.signal,
416
+ detach: true,
417
+ onLine: print,
418
+ })).run;
419
+ }
420
+ else {
421
+ await (0, sandbox_client_1.readRunLog)(connection, run, { task, tail: options.tail }, print);
422
+ }
423
+ if (run.state === 'succeeded' || run.state === 'up' || run.state === 'asleep') {
424
+ environment.stdout.write(`${(0, server_client_1.printable)(run.id)} ${(0, server_client_1.printable)(run.state)}\n`);
425
+ }
426
+ else if (sandbox_client_1.FINAL_RUN_STATES.includes(run.state)) {
427
+ for (const line of describeRunEnd(run)) {
428
+ environment.stdout.write(`${line}\n`);
429
+ }
430
+ }
431
+ else {
432
+ environment.stdout.write(`${(0, server_client_1.printable)(run.id)} is ${(0, server_client_1.printable)(run.state)}, follow it with --follow\n`);
433
+ }
434
+ }));
328
435
  remote
329
436
  .command('extend <run> <ttl> [server]')
330
437
  .description('keep a service run that is up for this long from now, such as 8h, at most the maxTtl of the server')
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildFileContainerTaskSchema = void 0;
3
+ exports.CONTAINER_TASK_PROPERTIES = exports.buildFileExtendingContainerTaskSchema = exports.buildFileContainerTaskSchema = void 0;
4
4
  const build_file_base_task_schema_1 = require("./build-file-base-task-schema");
5
5
  const zod_1 = require("zod");
6
6
  const size_schema_1 = require("./size-schema");
@@ -17,4 +17,15 @@ exports.buildFileContainerTaskSchema = build_file_base_task_schema_1.buildFileBa
17
17
  })
18
18
  .strict()
19
19
  .describe('container task');
20
+ // A task that extends another takes the image of its base, so it may set the
21
+ // container properties without an image of its own; once extended, a task whose
22
+ // base has no image either is refused them (applySchemaExtension).
23
+ exports.buildFileExtendingContainerTaskSchema = exports.buildFileContainerTaskSchema
24
+ .extend({
25
+ image: (0, zod_1.string)().optional(),
26
+ extend: (0, zod_1.string)(),
27
+ })
28
+ .strict()
29
+ .describe('container task taking its image from the task it extends');
30
+ exports.CONTAINER_TASK_PROPERTIES = ['mounts', 'volumeSize', 'capAdd'];
20
31
  //# sourceMappingURL=build-file-container-task-schema.js.map
@@ -4,7 +4,11 @@ exports.isBuildFileLocalTaskSchema = exports.isBuildFileContainerTaskSchema = ex
4
4
  const zod_1 = require("zod");
5
5
  const build_file_container_task_schema_1 = require("./build-file-container-task-schema");
6
6
  const build_file_local_task_schema_1 = require("./build-file-local-task-schema");
7
- exports.buildFileTaskSchema = (0, zod_1.union)([build_file_container_task_schema_1.buildFileContainerTaskSchema, build_file_local_task_schema_1.buildFileLocalTaskSchema]);
7
+ exports.buildFileTaskSchema = (0, zod_1.union)([
8
+ build_file_container_task_schema_1.buildFileContainerTaskSchema,
9
+ build_file_local_task_schema_1.buildFileLocalTaskSchema,
10
+ build_file_container_task_schema_1.buildFileExtendingContainerTaskSchema,
11
+ ]);
8
12
  const isBuildFileContainerTaskSchema = (val) => 'image' in val;
9
13
  exports.isBuildFileContainerTaskSchema = isBuildFileContainerTaskSchema;
10
14
  const isBuildFileLocalTaskSchema = (val) => !('image' in val);
@@ -7,6 +7,7 @@ const split_name_1 = require("../planner/utils/split-name");
7
7
  const read_env_file_1 = require("../parser/read-env-file");
8
8
  const label_values_1 = require("../executer/label-values");
9
9
  const merge_environment_variables_1 = require("../environment/merge-environment-variables");
10
+ const build_file_container_task_schema_1 = require("./build-file-container-task-schema");
10
11
  const resolve_cache_1 = require("../cache/resolve-cache");
11
12
  const project_root_1 = require("../planner/project-root");
12
13
  const secret_catalog_1 = require("../secrets/secret-catalog");
@@ -222,6 +223,7 @@ function applySchemaExtension(reference, task) {
222
223
  if (!reset.has('mounts'))
223
224
  taskSchema.mounts = extendArray(taskSchema.mounts, extend.schema.mounts);
224
225
  }
226
+ rejectContainerPropertiesWithoutImage(task);
225
227
  // deps and needs resolve in the file that declares them, so every base up the
226
228
  // chain contributes its own, not only the nearest; what a task resets is not
227
229
  // passed on to the tasks extending it either
@@ -236,6 +238,16 @@ function applySchemaExtension(reference, task) {
236
238
  }
237
239
  task.extension = 'applied';
238
240
  }
241
+ function rejectContainerPropertiesWithoutImage(task) {
242
+ if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
243
+ return;
244
+ }
245
+ const schema = task.schema;
246
+ const property = build_file_container_task_schema_1.CONTAINER_TASK_PROPERTIES.find((key) => schema[key] !== undefined);
247
+ if (property) {
248
+ throw new Error(`task ${task.relativeName} sets ${property}, but neither it nor its base has an image`);
249
+ }
250
+ }
239
251
  function extendArray(base, extend) {
240
252
  if (!extend) {
241
253
  return base;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.workerMain = exports.readInput = exports.taskCauses = exports.runTasks = exports.getEndpointTargets = exports.publishEndpoints = exports.getRunNetwork = exports.workerInputSchema = void 0;
3
+ exports.workerMain = exports.readInput = exports.taskCauses = exports.describeFailures = exports.runTasks = exports.getEndpointTargets = exports.publishEndpoints = exports.getRunNetwork = exports.workerInputSchema = void 0;
4
4
  const readline_1 = require("readline");
5
5
  const zod_1 = require("zod");
6
6
  const cli_1 = require("../cli");
@@ -22,6 +22,8 @@ const work_network_1 = require("../planner/work-network");
22
22
  const run_plan_1 = require("./run-plan");
23
23
  const runs_1 = require("./runs");
24
24
  const resources_1 = require("../kubernetes/resources");
25
+ const cluster_state_1 = require("../kubernetes/cluster-state");
26
+ const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
25
27
  const check_keys_1 = require("../optimizer/check-keys");
26
28
  // one log line of a task is cut here, the registry caps how many events a run keeps
27
29
  const MAX_LOG_TEXT = 8 * 1024;
@@ -234,9 +236,11 @@ up) {
234
236
  ? cli.up({ ...execOptions, workers: cli.defaultWorkers(), daemon: false, wait: 'ready' })
235
237
  : cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
236
238
  const seen = new Map();
239
+ const failedServices = new Set();
237
240
  let ready = false;
238
241
  const report = () => {
239
242
  reportTasks(emit, execution.state.current, seen, (text) => secrets.redact(text));
243
+ reportServiceFailures(emit, execution.state.current, failedServices, (text) => secrets.redact(text));
240
244
  if (input.up && up && !ready) {
241
245
  const targets = getEndpointTargets(execution.state.current, input.up, input.kubernetes);
242
246
  if (targets) {
@@ -262,7 +266,16 @@ up) {
262
266
  return;
263
267
  }
264
268
  if (!result.success) {
265
- throw new Error(cancelled() ? 'the run was cancelled' : `${name} failed`);
269
+ if (cancelled()) {
270
+ throw new Error('the run was cancelled');
271
+ }
272
+ if (input.kubernetes) {
273
+ // what the cluster said, before the server removes what the run made
274
+ await reportClusterState(input.kubernetes, execution.state.current, !!input.up, emit, (text) => secrets.redact(text));
275
+ }
276
+ // a service run names what failed and why: its services have no results of their own
277
+ const failures = input.up ? describeFailures(execution.state.current, (text) => secrets.redact(text)) : [];
278
+ throw new Error(failures.length > 0 ? failures.join('; ').slice(0, runs_1.MAX_TASK_ERROR) : `${name} failed`);
266
279
  }
267
280
  }
268
281
  finally {
@@ -275,6 +288,82 @@ up) {
275
288
  }
276
289
  }
277
290
  exports.runTasks = runTasks;
291
+ // What the cluster said of a run that failed, into its log: the server removes its pods
292
+ // once it ended.
293
+ async function reportClusterState(kubernetes, workTree, ownNamespace, emit, redact) {
294
+ let lines;
295
+ try {
296
+ lines = await (0, cluster_state_1.readClusterState)((0, kubernetes_instance_1.createKubernetesInstances)(kubernetes), kubernetes, workTree, ownNamespace);
297
+ }
298
+ catch (e) {
299
+ lines = [`it could not be read: ${(0, log_1.getErrorMessage)(e)}`];
300
+ }
301
+ if (lines.length === 0) {
302
+ return;
303
+ }
304
+ for (const text of [`what the cluster said in ${kubernetes.namespace}:`, ...lines.map((line) => ` ${line}`)]) {
305
+ emit({ type: 'log', task: 'hammerkit', stream: 'stderr', text: redact(text).slice(0, MAX_LOG_TEXT) });
306
+ }
307
+ }
308
+ // A service has no result of its own in the run: why it failed goes into the log, under
309
+ // its name, once.
310
+ function reportServiceFailures(emit, workTree, said, redact) {
311
+ for (const service of (0, plan_work_tasks_1.iterateWorkServices)(workTree)) {
312
+ const state = service.state.current;
313
+ if (state.type === 'error' && !said.has(service.name)) {
314
+ said.add(service.name);
315
+ if (lastError(service) === state.errorMessage) {
316
+ // the runtime said it already
317
+ continue;
318
+ }
319
+ emit({
320
+ type: 'log',
321
+ task: service.name,
322
+ stream: 'stderr',
323
+ text: redact(state.errorMessage).slice(0, MAX_LOG_TEXT),
324
+ });
325
+ }
326
+ }
327
+ }
328
+ // The last error the runtime of an item wrote to its status.
329
+ function lastError(item) {
330
+ let last;
331
+ for (const message of item.status.read()) {
332
+ if (message.level === 'error') {
333
+ last = message.message;
334
+ }
335
+ }
336
+ return last;
337
+ }
338
+ /**
339
+ * What failed in a run, one line each, for its error: the services and tasks that failed,
340
+ * with their reason. Those that only stopped because another failed are left out.
341
+ */
342
+ function describeFailures(workTree, redact) {
343
+ const failures = [];
344
+ for (const service of (0, plan_work_tasks_1.iterateWorkServices)(workTree)) {
345
+ const state = service.state.current;
346
+ if (state.type === 'error') {
347
+ failures.push(`${service.name}: ${redact(state.errorMessage)}`);
348
+ }
349
+ else if (state.type === 'end' && state.reason === 'crash') {
350
+ // on Docker a service that does not get ready ends, and its runtime says why
351
+ const why = lastError(service);
352
+ failures.push(`${service.name}: ${why ? redact(why) : 'it stopped on its own'}`);
353
+ }
354
+ }
355
+ for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
356
+ const state = task.state.current;
357
+ if (state.type === 'error') {
358
+ failures.push(`${task.name}: ${redact(state.errorMessage)}`);
359
+ }
360
+ else if (state.type === 'crash') {
361
+ failures.push(`${task.name}: exit code ${state.exitCode}`);
362
+ }
363
+ }
364
+ return failures;
365
+ }
366
+ exports.describeFailures = describeFailures;
278
367
  // Why a task ran, as a task event carries it: the first causes, masked and cut short.
279
368
  function taskCauses(causes, redact) {
280
369
  return causes?.length
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.24",
4
+ "version": "1.27.0-next.26",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",