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.
@@ -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, log?: Log) {
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
- log?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
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
- const log = createLog({
572
- ...options,
573
- onQuit: () => stop(),
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 : undefined,
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
@@ -146,7 +146,7 @@ export function describeFailure(error: unknown) {
146
146
 
147
147
  const DEPTH = 8;
148
148
 
149
- function causes(error: Error) {
149
+ export function causes(error: Error) {
150
150
  const chain: Error[] = [];
151
151
  let current: unknown = error;
152
152
 
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
- if (!(await checkAll(context, health))) {
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
- return await healthcheck(target.container, target.port, app.health, deps);
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
- return !results.includes(false);
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, appOf(topology, app.name), buildContext);
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
  };