redkite 0.1.11 → 0.1.12
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/README.md +69 -1
- package/dist/build.d.ts +3 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +14 -7
- package/dist/build.js.map +1 -1
- package/dist/cli/crash.d.ts +46 -0
- package/dist/cli/crash.d.ts.map +1 -0
- package/dist/cli/crash.js +202 -0
- package/dist/cli/crash.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +33 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/log.d.ts +1 -0
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +1 -1
- package/dist/cli/log.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +68 -5
- package/dist/deploy.js.map +1 -1
- package/dist/docker.d.ts +1 -0
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +5 -0
- package/dist/docker.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +23 -11
- package/src/cli/crash.ts +286 -0
- package/src/cli/index.ts +38 -8
- package/src/cli/log.ts +1 -1
- package/src/deploy.ts +84 -5
- package/src/docker.ts +6 -0
- package/src/types.ts +9 -0
package/src/cli/crash.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { Task } from "../log.js";
|
|
5
|
+
import type { Deployment } from "../types.js";
|
|
6
|
+
import { elapsed } from "./screen.js";
|
|
7
|
+
import { causes } from "./log.js";
|
|
8
|
+
import type { Viewer } from "./viewer.js";
|
|
9
|
+
|
|
10
|
+
// What a failed run said, kept whole. The view trims a step to its last lines
|
|
11
|
+
// and the alternate screen takes the rest with it, so the only complete record
|
|
12
|
+
// of a run that went wrong is one written down while it happened.
|
|
13
|
+
|
|
14
|
+
// Named rather than read from TMPDIR: the path is the one a person is told to
|
|
15
|
+
// look in, and a runner that points TMPDIR somewhere else would hide it
|
|
16
|
+
const ROOT = "/tmp";
|
|
17
|
+
|
|
18
|
+
type Kind = "info" | "warn" | "fail" | "done" | "plan" | "step" | "command";
|
|
19
|
+
|
|
20
|
+
type Entry = { at: number; kind: Kind; text: string };
|
|
21
|
+
|
|
22
|
+
type Said = { at: number; kind: "detail" | "line"; text: string };
|
|
23
|
+
|
|
24
|
+
type StepRecord = {
|
|
25
|
+
label: string;
|
|
26
|
+
started: number;
|
|
27
|
+
ended?: number;
|
|
28
|
+
state: "running" | "done" | "failed";
|
|
29
|
+
note?: string;
|
|
30
|
+
said: Said[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type Transcript = {
|
|
34
|
+
started: number;
|
|
35
|
+
entries: Entry[];
|
|
36
|
+
steps: StepRecord[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type Crash = {
|
|
40
|
+
project: string;
|
|
41
|
+
environment: string;
|
|
42
|
+
command: string;
|
|
43
|
+
version: string;
|
|
44
|
+
argv: string[];
|
|
45
|
+
outcome: string;
|
|
46
|
+
at: number;
|
|
47
|
+
error?: unknown;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Every call reaches the wrapped log unchanged, so the view behaves as it did.
|
|
51
|
+
// The transcript is a copy kept beside it, never a replacement for it
|
|
52
|
+
export function recording(log: Viewer, now: () => number = Date.now) {
|
|
53
|
+
const transcript: Transcript = { started: now(), entries: [], steps: [] };
|
|
54
|
+
const note = (kind: Kind, text: string) => transcript.entries.push({ at: now(), kind, text });
|
|
55
|
+
|
|
56
|
+
const step = (label: string): Task => {
|
|
57
|
+
const record: StepRecord = { label, started: now(), state: "running", said: [] };
|
|
58
|
+
const task = log.step(label);
|
|
59
|
+
|
|
60
|
+
transcript.steps.push(record);
|
|
61
|
+
note("step", `${label} started`);
|
|
62
|
+
|
|
63
|
+
const settle = (state: "done" | "failed", message?: string) => {
|
|
64
|
+
record.state = state;
|
|
65
|
+
record.ended = now();
|
|
66
|
+
record.note = message;
|
|
67
|
+
|
|
68
|
+
const said = message ? `: ${message}` : "";
|
|
69
|
+
note("step", `${label} ${state}${said} (${elapsed(record.ended, record.started)})`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
detail: (message) => {
|
|
74
|
+
record.said.push({ at: now(), kind: "detail", text: message });
|
|
75
|
+
task.detail(message);
|
|
76
|
+
},
|
|
77
|
+
line: (message) => {
|
|
78
|
+
record.said.push({ at: now(), kind: "line", text: message });
|
|
79
|
+
task.line(message);
|
|
80
|
+
},
|
|
81
|
+
done: (message) => {
|
|
82
|
+
settle("done", message);
|
|
83
|
+
task.done(message);
|
|
84
|
+
},
|
|
85
|
+
fail: (message) => {
|
|
86
|
+
settle("failed", message);
|
|
87
|
+
task.fail(message);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const recorded: Viewer = Object.assign(
|
|
93
|
+
(message: string) => {
|
|
94
|
+
note("info", message);
|
|
95
|
+
log(message);
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
plan: (points: string[]) => {
|
|
99
|
+
note("plan", points.join(", "));
|
|
100
|
+
log.plan?.(points);
|
|
101
|
+
},
|
|
102
|
+
warn: (message: string) => {
|
|
103
|
+
note("warn", message);
|
|
104
|
+
log.warn(message);
|
|
105
|
+
},
|
|
106
|
+
fail: (message: string) => {
|
|
107
|
+
note("fail", message);
|
|
108
|
+
log.fail(message);
|
|
109
|
+
},
|
|
110
|
+
done: (message: string) => {
|
|
111
|
+
note("done", message);
|
|
112
|
+
log.done(message);
|
|
113
|
+
},
|
|
114
|
+
step,
|
|
115
|
+
close: () => log.close(),
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// A host command is only shown with --verbose, and is exactly what a crash log
|
|
120
|
+
// is read for, so it is kept whether it was shown or not
|
|
121
|
+
const command = (text: string) => note("command", text);
|
|
122
|
+
|
|
123
|
+
return { log: recorded, transcript, command };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// One directory per failed run, under the environment it failed in. Named from
|
|
127
|
+
// the command line, so both segments are made safe to be a path first
|
|
128
|
+
export function crashDirectory(project: string, environment: string, at: Date, root = ROOT) {
|
|
129
|
+
const moment = at.toISOString().replaceAll(":", "-");
|
|
130
|
+
return join(root, slug(project), slug(environment), `crash-${moment}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The run's own log, and one file per step. A build's thousands of lines would
|
|
134
|
+
// bury everything else in a single file, and a step's file is what gets opened
|
|
135
|
+
// first. Numbered in the order they started, which also keeps two steps that
|
|
136
|
+
// share a label from sharing a file
|
|
137
|
+
export function crashFiles(transcript: Transcript, crash: Crash): Record<string, string> {
|
|
138
|
+
const width = Math.max(2, String(transcript.steps.length).length);
|
|
139
|
+
|
|
140
|
+
const named = transcript.steps.map((step, index) => ({
|
|
141
|
+
step,
|
|
142
|
+
file: `${pad(index + 1, width)}-${slug(step.label)}.log`,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"run.log": runLog(transcript, crash, named),
|
|
147
|
+
...Object.fromEntries(
|
|
148
|
+
named.map(({ step, file }) => [file, stepLog(transcript, crash, step)]),
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Nothing is written for a deployment that turned it off. Otherwise private to
|
|
154
|
+
// whoever ran it: /tmp is shared by everyone on the machine, and a build's output
|
|
155
|
+
// can say more than it meant to
|
|
156
|
+
export async function dumpCrash(
|
|
157
|
+
config: Deployment,
|
|
158
|
+
transcript: Transcript,
|
|
159
|
+
crash: Crash,
|
|
160
|
+
root = ROOT,
|
|
161
|
+
) {
|
|
162
|
+
if (config.options?.crashLog === false) return undefined;
|
|
163
|
+
|
|
164
|
+
const directory = crashDirectory(config.project, crash.environment, new Date(crash.at), root);
|
|
165
|
+
await mkdir(dirname(directory), { recursive: true, mode: 0o700 });
|
|
166
|
+
|
|
167
|
+
// Not recursive: a directory already there belongs to another run, and nothing
|
|
168
|
+
// of this one is written into it
|
|
169
|
+
await mkdir(directory, { mode: 0o700 });
|
|
170
|
+
|
|
171
|
+
for (const [name, contents] of Object.entries(crashFiles(transcript, crash))) {
|
|
172
|
+
await writeFile(join(directory, name), contents, { mode: 0o600, flag: "wx" });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return directory;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const MARK: Record<Kind, string> = {
|
|
179
|
+
info: "",
|
|
180
|
+
warn: "warn ",
|
|
181
|
+
fail: "fail ",
|
|
182
|
+
done: "done ",
|
|
183
|
+
plan: "plan ",
|
|
184
|
+
step: "step ",
|
|
185
|
+
command: "",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
type Named = { step: StepRecord; file: string };
|
|
189
|
+
|
|
190
|
+
function runLog(transcript: Transcript, crash: Crash, named: Named[]) {
|
|
191
|
+
const since = sinceStart(transcript);
|
|
192
|
+
|
|
193
|
+
const index = named.map(({ step, file }) => {
|
|
194
|
+
const took = elapsed(step.ended ?? crash.at, step.started);
|
|
195
|
+
return `${file.padEnd(40)} ${step.state.padEnd(8)} ${took}`;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return lines([
|
|
199
|
+
"redkite crash log",
|
|
200
|
+
"",
|
|
201
|
+
`project ${crash.project}`,
|
|
202
|
+
`environment ${crash.environment}`,
|
|
203
|
+
`command ${crash.command}`,
|
|
204
|
+
`outcome ${crash.outcome}`,
|
|
205
|
+
`version ${crash.version}`,
|
|
206
|
+
`argv ${crash.argv.join(" ")}`,
|
|
207
|
+
`started ${new Date(transcript.started).toISOString()}`,
|
|
208
|
+
`ended ${new Date(crash.at).toISOString()}`,
|
|
209
|
+
"",
|
|
210
|
+
"== error",
|
|
211
|
+
...errorLines(crash.error),
|
|
212
|
+
"",
|
|
213
|
+
"== steps, each in its own file beside this one",
|
|
214
|
+
...(index.length > 0 ? index : ["none started"]),
|
|
215
|
+
"",
|
|
216
|
+
"== log",
|
|
217
|
+
...transcript.entries.map(
|
|
218
|
+
(entry) => `${since(entry.at)} ${MARK[entry.kind]}${indent(entry.text)}`,
|
|
219
|
+
),
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Stamped from the start of the run, the same as run.log, so a line here can be
|
|
224
|
+
// found among what the run said around it
|
|
225
|
+
function stepLog(transcript: Transcript, crash: Crash, step: StepRecord) {
|
|
226
|
+
const since = sinceStart(transcript);
|
|
227
|
+
const note = step.note ? `: ${step.note}` : "";
|
|
228
|
+
|
|
229
|
+
return lines([
|
|
230
|
+
`step ${step.label}`,
|
|
231
|
+
`state ${step.state}${indent(note)}`,
|
|
232
|
+
`started ${new Date(step.started).toISOString()}`,
|
|
233
|
+
`ended ${step.ended ? new Date(step.ended).toISOString() : "still running when the run ended"}`,
|
|
234
|
+
`took ${elapsed(step.ended ?? crash.at, step.started)}`,
|
|
235
|
+
"",
|
|
236
|
+
...step.said.map(
|
|
237
|
+
(said) => `${since(said.at)} ${said.kind === "detail" ? "· " : "| "}${indent(said.text)}`,
|
|
238
|
+
),
|
|
239
|
+
]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Whole, where the terminal gets the tail: the stack of every error in the
|
|
243
|
+
// chain, since the step that threw is usually two causes down
|
|
244
|
+
function errorLines(error: unknown) {
|
|
245
|
+
if (error === undefined) return ["none thrown"];
|
|
246
|
+
if (!(error instanceof Error)) return [String(error)];
|
|
247
|
+
|
|
248
|
+
return causes(error).flatMap((link, index) => [
|
|
249
|
+
...(index > 0 ? ["", "caused by"] : []),
|
|
250
|
+
link.stack ?? link.message,
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sinceStart(transcript: Transcript) {
|
|
255
|
+
return (at: number) => `+${clock(at - transcript.started)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function lines(rows: string[]) {
|
|
259
|
+
return `${rows.join("\n")}\n`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Continuation lines line up under the text rather than under the timestamp
|
|
263
|
+
function indent(text: string) {
|
|
264
|
+
return text.replaceAll("\n", "\n ");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function slug(text: string) {
|
|
268
|
+
const slugged = text
|
|
269
|
+
.toLowerCase()
|
|
270
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
271
|
+
.replace(/^-+|-+$/g, "");
|
|
272
|
+
|
|
273
|
+
return slugged || "unnamed";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function clock(ms: number) {
|
|
277
|
+
const minutes = Math.floor(ms / 60_000);
|
|
278
|
+
const seconds = Math.floor(ms / 1000) % 60;
|
|
279
|
+
const millis = ms % 1000;
|
|
280
|
+
|
|
281
|
+
return `${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function pad(value: number, width: number) {
|
|
285
|
+
return String(value).padStart(width, "0");
|
|
286
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { Deployment, DeployHost } from "../types.js";
|
|
|
17
17
|
import { needsAgent, requireAgent } from "./agent.js";
|
|
18
18
|
import { loadConfig, projectRoot } from "./config.js";
|
|
19
19
|
import { loadDotenv } from "./dotenv.js";
|
|
20
|
+
import { dumpCrash, recording } from "./crash.js";
|
|
20
21
|
import { createLog, describeFailure } from "./log.js";
|
|
21
22
|
|
|
22
23
|
// One command that reads the config and does everything under it: the agent,
|
|
@@ -42,7 +43,7 @@ of the project, and everything below it is derived.
|
|
|
42
43
|
|
|
43
44
|
// Wraps a host rather than living inside one, so both implementations are
|
|
44
45
|
// measured the same way and neither knows it is being timed
|
|
45
|
-
function measured(host: Host,
|
|
46
|
+
function measured(host: Host, say?: (line: string) => void) {
|
|
46
47
|
const totals = { commands: 0, commandMs: 0, files: 0 };
|
|
47
48
|
|
|
48
49
|
const wrapped: Host = {
|
|
@@ -61,7 +62,7 @@ function measured(host: Host, log?: Log) {
|
|
|
61
62
|
|
|
62
63
|
// The exit code matters: several of these are allowed to fail, and a
|
|
63
64
|
// deploy reading verbose output is one where somebody wants to know which
|
|
64
|
-
|
|
65
|
+
say?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
|
|
65
66
|
return result;
|
|
66
67
|
},
|
|
67
68
|
|
|
@@ -568,10 +569,37 @@ async function run(
|
|
|
568
569
|
// failure path, and the finally below removes the scratch directory
|
|
569
570
|
const stopping = new AbortController();
|
|
570
571
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
572
|
+
// Everything the run says still reaches the view. The recording is what a
|
|
573
|
+
// crash log is written from, since the view keeps only each step's tail
|
|
574
|
+
const recorder = recording(
|
|
575
|
+
createLog({
|
|
576
|
+
...options,
|
|
577
|
+
onQuit: () => stop(),
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const log = recorder.log;
|
|
582
|
+
|
|
583
|
+
// Before the view closes, so where the log went is among what it writes out.
|
|
584
|
+
// A log that cannot be written must not replace the failure it was recording
|
|
585
|
+
const dumped = async (outcome: string, error?: unknown) => {
|
|
586
|
+
try {
|
|
587
|
+
const path = await dumpCrash(config, recorder.transcript, {
|
|
588
|
+
project: config.project,
|
|
589
|
+
environment,
|
|
590
|
+
command: kind,
|
|
591
|
+
version: await version(),
|
|
592
|
+
argv: process.argv.slice(2),
|
|
593
|
+
outcome,
|
|
594
|
+
at: Date.now(),
|
|
595
|
+
error,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
if (path) log.warn(`Crash logs written to ${path}`);
|
|
599
|
+
} catch (failure) {
|
|
600
|
+
log.warn(`Could not write the crash log: ${describeFailure(failure).split("\n")[0]}`);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
575
603
|
|
|
576
604
|
// Read by the wait below, so a press during it hardens what that is sending
|
|
577
605
|
let hardest: "TERM" | "KILL" = "TERM";
|
|
@@ -602,7 +630,7 @@ async function run(
|
|
|
602
630
|
try {
|
|
603
631
|
const meter = measured(
|
|
604
632
|
await hostFor(deployHost, log, stopping.signal),
|
|
605
|
-
options.verbose ? log :
|
|
633
|
+
options.verbose ? log : recorder.command,
|
|
606
634
|
);
|
|
607
635
|
|
|
608
636
|
host = meter.host;
|
|
@@ -633,15 +661,17 @@ async function run(
|
|
|
633
661
|
|
|
634
662
|
log.fail(`Reverted ${result.reverted.join(", ")}`);
|
|
635
663
|
process.exitCode = 1;
|
|
664
|
+
await dumped("reverted");
|
|
636
665
|
} catch (error) {
|
|
637
666
|
// Whatever the command in flight said about being killed is noise: what
|
|
638
|
-
// happened is that somebody asked for it to stop
|
|
667
|
+
// happened is that somebody asked for it to stop, which is not a crash
|
|
639
668
|
if (stopping.signal.aborted) {
|
|
640
669
|
log.fail("Stopped");
|
|
641
670
|
process.exitCode = 130;
|
|
642
671
|
} else {
|
|
643
672
|
log.fail(describeFailure(error));
|
|
644
673
|
process.exitCode = 1;
|
|
674
|
+
await dumped("failed", error);
|
|
645
675
|
}
|
|
646
676
|
} finally {
|
|
647
677
|
// Nothing leaves while the build is still running. The signal a press
|
package/src/cli/log.ts
CHANGED
package/src/deploy.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { build, type BuildContext, type BuildResult } from "./build.js";
|
|
1
|
+
import { build, refOf, sourceOf, type BuildContext, type BuildResult } from "./build.js";
|
|
2
2
|
import { assertCheckable, runChecks } from "./checks.js";
|
|
3
3
|
import { environmentOf, withEnvironment } from "./config.js";
|
|
4
4
|
import { Docker } from "./docker.js";
|
|
@@ -26,6 +26,7 @@ import { pluginSteps } from "./plugin.js";
|
|
|
26
26
|
import { readEnv, readRef, type SecretStores } from "./secrets/refs.js";
|
|
27
27
|
import { ensureService } from "./services/ensure.js";
|
|
28
28
|
import { plannedServices } from "./services/planned.js";
|
|
29
|
+
import type { Source } from "./source.js";
|
|
29
30
|
import { topologyFor, type AppTopology, type Topology } from "./topology.js";
|
|
30
31
|
import type { AppSpec, Deployment } from "./types.js";
|
|
31
32
|
|
|
@@ -215,8 +216,14 @@ async function release(
|
|
|
215
216
|
// Inside, because the swap is not over until this says so. A stop lands
|
|
216
217
|
// here more often than anywhere else: it is the longest part, and by now
|
|
217
218
|
// every address has already moved
|
|
218
|
-
|
|
219
|
+
const unhealthy = await checkAll(context, health);
|
|
220
|
+
|
|
221
|
+
if (unhealthy.size > 0) {
|
|
219
222
|
context.log.fail("Health checks failed, reverting");
|
|
223
|
+
|
|
224
|
+
// Before the revert, which gives the live name back to the container it
|
|
225
|
+
// retired. Asked after it, the logs would be the previous release's
|
|
226
|
+
await dumpLogs(context, apps, unhealthy);
|
|
220
227
|
await Promise.all(apps.map((app) => revert(docker, topology, app)));
|
|
221
228
|
|
|
222
229
|
return {
|
|
@@ -307,11 +314,46 @@ async function checkAll(context: Context, health: Omit<HealthDeps, "probe">) {
|
|
|
307
314
|
log,
|
|
308
315
|
};
|
|
309
316
|
|
|
310
|
-
|
|
317
|
+
const healthy = await healthcheck(target.container, target.port, app.health, deps);
|
|
318
|
+
return healthy ? undefined : target.container;
|
|
311
319
|
}),
|
|
312
320
|
);
|
|
313
321
|
|
|
314
|
-
|
|
322
|
+
// The containers that failed, since what gets written out for each depends on
|
|
323
|
+
// whether it was one of them
|
|
324
|
+
return new Set(results.filter((container): container is string => container !== undefined));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Enough to hold a stack trace and what led up to it, not a day of access logs
|
|
328
|
+
const LOG_TAIL = 200;
|
|
329
|
+
|
|
330
|
+
// Every new container's, not only the ones that failed: a backend that never came
|
|
331
|
+
// up is often explained by what the frontend says it could not reach. Each is a
|
|
332
|
+
// step of its own, so the crash log gives it a file, and the one that failed its
|
|
333
|
+
// check is marked failed, which is what puts its tail on the screen at the end
|
|
334
|
+
async function dumpLogs(context: Context, apps: AppTopology[], unhealthy: Set<string>) {
|
|
335
|
+
await Promise.all(
|
|
336
|
+
apps.map(async (app) => {
|
|
337
|
+
const task = context.log.step(`Logs of ${app.name}`);
|
|
338
|
+
task.detail(`the last ${LOG_TAIL} lines of ${app.container}`);
|
|
339
|
+
|
|
340
|
+
const result = await context.docker.container.logs(app.container, LOG_TAIL);
|
|
341
|
+
|
|
342
|
+
// Said, and left there. The revert still has to run, and logs that could
|
|
343
|
+
// not be read are no reason to leave a failed release serving
|
|
344
|
+
if (result.code !== 0) {
|
|
345
|
+
task.fail(`could not read the logs of ${app.container}: ${result.stdout || result.stderr}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const lines = result.stdout.split("\n").filter((line) => line.length > 0);
|
|
350
|
+
for (const line of lines) task.line(line);
|
|
351
|
+
|
|
352
|
+
const said = `${lines.length} lines from ${app.container}`;
|
|
353
|
+
if (unhealthy.has(app.container)) task.fail(`${said}, which failed its health check`);
|
|
354
|
+
else task.done(said);
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
315
357
|
}
|
|
316
358
|
|
|
317
359
|
// The proxy is one of these, derived rather than listed. What each should be
|
|
@@ -347,6 +389,8 @@ async function buildAll(
|
|
|
347
389
|
|
|
348
390
|
return await Promise.all(
|
|
349
391
|
config.apps.map(async (app) => {
|
|
392
|
+
const placed = appOf(topology, app.name);
|
|
393
|
+
const source = await clone(app, placed, here ?? context.host, environment.branch, log);
|
|
350
394
|
const task = log.step(`Building ${app.name}`);
|
|
351
395
|
|
|
352
396
|
try {
|
|
@@ -374,7 +418,7 @@ async function buildAll(
|
|
|
374
418
|
output: task.line,
|
|
375
419
|
};
|
|
376
420
|
|
|
377
|
-
const result = await build(app,
|
|
421
|
+
const result = await build(app, placed, buildContext, source);
|
|
378
422
|
task.done(`${result.release.slice(0, 7)}${result.cached ? " (held)" : ""}`);
|
|
379
423
|
|
|
380
424
|
return { app, result };
|
|
@@ -386,6 +430,41 @@ async function buildAll(
|
|
|
386
430
|
);
|
|
387
431
|
}
|
|
388
432
|
|
|
433
|
+
// A step of its own, ahead of the build that reads it. A wrong branch or an
|
|
434
|
+
// unreachable repository shows here, and folded into the build it was a detail
|
|
435
|
+
// that scrolled past without ever saying what it fetched
|
|
436
|
+
async function clone(
|
|
437
|
+
app: AppSpec,
|
|
438
|
+
placed: AppTopology,
|
|
439
|
+
host: Host,
|
|
440
|
+
branch: string,
|
|
441
|
+
log: Log,
|
|
442
|
+
): Promise<Source | undefined> {
|
|
443
|
+
// A directory is read where it is, not cloned, and the build still does that
|
|
444
|
+
if (!app.repo) return undefined;
|
|
445
|
+
|
|
446
|
+
const ref = refOf(app, branch);
|
|
447
|
+
const what = `${app.repo}, ${ref.kind} ${ref.name}`;
|
|
448
|
+
const task = log.step(`Cloning ${app.name}`);
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
task.detail(what);
|
|
452
|
+
const source = await sourceOf(app, placed, {
|
|
453
|
+
host,
|
|
454
|
+
branch,
|
|
455
|
+
detail: task.detail,
|
|
456
|
+
output: task.line,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Said again at the end, where it outlasts the details that replaced it
|
|
460
|
+
task.done(`${what} at ${source.release.slice(0, 7)}`);
|
|
461
|
+
return source;
|
|
462
|
+
} catch (error) {
|
|
463
|
+
task.fail(`${app.name} could not clone ${what}`);
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
389
468
|
async function resolveFiles(app: AppSpec, stores: SecretStores) {
|
|
390
469
|
const entries = await Promise.all(
|
|
391
470
|
Object.entries(app.files ?? {}).map(
|
package/src/docker.ts
CHANGED
|
@@ -233,6 +233,12 @@ class DockerContainer {
|
|
|
233
233
|
return RUNNING.has(await this.status(name));
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// Both streams, since a process that dies as it starts says why on stderr, and
|
|
237
|
+
// stamped so a line can be set against when the health check gave up
|
|
238
|
+
async logs(name: string, tail: number) {
|
|
239
|
+
return await this.docker.run(`container logs --tail ${tail} --timestamps ${name} 2>&1`);
|
|
240
|
+
}
|
|
241
|
+
|
|
236
242
|
async start(name: string) {
|
|
237
243
|
if (!(await this.exists(name))) {
|
|
238
244
|
throw new Error(`Cannot start ${name}, it does not exist`);
|
package/src/types.ts
CHANGED
|
@@ -282,4 +282,13 @@ export type Deployment = {
|
|
|
282
282
|
// refs, and whatever else brings steps of its own. Nothing a plugin carries
|
|
283
283
|
// happens until it is listed here, redkite's own vault included
|
|
284
284
|
plugins?: Plugin[];
|
|
285
|
+
// How redkite itself behaves, rather than anything it deploys
|
|
286
|
+
options?: DeploymentOptions;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export type DeploymentOptions = {
|
|
290
|
+
// A run that fails writes everything it said to
|
|
291
|
+
// /tmp/<project>/<environment>/crash-<time>/: the run's own log, and one file
|
|
292
|
+
// per step with its output in full. On unless this says false
|
|
293
|
+
crashLog?: boolean;
|
|
285
294
|
};
|