redkite 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +76 -1
  2. package/dist/build.d.ts +3 -2
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +14 -7
  5. package/dist/build.js.map +1 -1
  6. package/dist/cli/agent.d.ts +5 -1
  7. package/dist/cli/agent.d.ts.map +1 -1
  8. package/dist/cli/agent.js +7 -7
  9. package/dist/cli/agent.js.map +1 -1
  10. package/dist/cli/crash.d.ts +46 -0
  11. package/dist/cli/crash.d.ts.map +1 -0
  12. package/dist/cli/crash.js +204 -0
  13. package/dist/cli/crash.js.map +1 -0
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +50 -15
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/cli/log.d.ts +1 -0
  18. package/dist/cli/log.d.ts.map +1 -1
  19. package/dist/cli/log.js +4 -3
  20. package/dist/cli/log.js.map +1 -1
  21. package/dist/cli/screen.d.ts +3 -0
  22. package/dist/cli/screen.d.ts.map +1 -1
  23. package/dist/cli/screen.js +32 -6
  24. package/dist/cli/screen.js.map +1 -1
  25. package/dist/cli/viewer.d.ts.map +1 -1
  26. package/dist/cli/viewer.js +5 -5
  27. package/dist/cli/viewer.js.map +1 -1
  28. package/dist/deploy.d.ts.map +1 -1
  29. package/dist/deploy.js +72 -6
  30. package/dist/deploy.js.map +1 -1
  31. package/dist/docker.d.ts +1 -0
  32. package/dist/docker.d.ts.map +1 -1
  33. package/dist/docker.js +5 -0
  34. package/dist/docker.js.map +1 -1
  35. package/dist/health.d.ts +2 -2
  36. package/dist/health.d.ts.map +1 -1
  37. package/dist/health.js +17 -9
  38. package/dist/health.js.map +1 -1
  39. package/dist/log.d.ts +3 -0
  40. package/dist/log.d.ts.map +1 -1
  41. package/dist/log.js +13 -0
  42. package/dist/log.js.map +1 -1
  43. package/dist/source.d.ts +2 -0
  44. package/dist/source.d.ts.map +1 -1
  45. package/dist/source.js +20 -0
  46. package/dist/source.js.map +1 -1
  47. package/dist/types.d.ts +4 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/build.ts +23 -11
  51. package/src/cli/agent.ts +8 -8
  52. package/src/cli/crash.ts +287 -0
  53. package/src/cli/index.ts +59 -17
  54. package/src/cli/log.ts +4 -3
  55. package/src/cli/screen.ts +45 -4
  56. package/src/cli/viewer.ts +5 -4
  57. package/src/deploy.ts +88 -6
  58. package/src/docker.ts +6 -0
  59. package/src/health.ts +23 -16
  60. package/src/log.ts +19 -1
  61. package/src/source.ts +24 -0
  62. package/src/types.ts +9 -0
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 { describeRef, describeRepo, 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 {
@@ -298,20 +305,58 @@ async function checkAll(context: Context, health: Omit<HealthDeps, "probe">) {
298
305
  const results = await Promise.all(
299
306
  context.config.apps.map(async (app) => {
300
307
  const target = appOf(topology, app.name);
308
+
309
+ // A step of its own for each app, rather than lines said beside the swap:
310
+ // every attempt lands on it, and a crash log gives it a file
301
311
  const deps: HealthDeps = {
302
312
  ...health,
303
313
  probe: async (container, url) => {
304
314
  const result = await docker.run(`exec ${container} curl -s ${url}`);
305
315
  return { code: result.code, output: result.stdout };
306
316
  },
307
- log,
317
+ task: log.step(`Health check of ${app.name}`),
308
318
  };
309
319
 
310
- return await healthcheck(target.container, target.port, app.health, deps);
320
+ const healthy = await healthcheck(target.container, target.port, app.health, deps);
321
+ return healthy ? undefined : target.container;
311
322
  }),
312
323
  );
313
324
 
314
- return !results.includes(false);
325
+ // The containers that failed, since what gets written out for each depends on
326
+ // whether it was one of them
327
+ return new Set(results.filter((container): container is string => container !== undefined));
328
+ }
329
+
330
+ // Enough to hold a stack trace and what led up to it, not a day of access logs
331
+ const LOG_TAIL = 200;
332
+
333
+ // Every new container's, not only the ones that failed: a backend that never came
334
+ // up is often explained by what the frontend says it could not reach. Each is a
335
+ // step of its own, so the crash log gives it a file, and the one that failed its
336
+ // check is marked failed, which is what puts its tail on the screen at the end
337
+ async function dumpLogs(context: Context, apps: AppTopology[], unhealthy: Set<string>) {
338
+ await Promise.all(
339
+ apps.map(async (app) => {
340
+ const task = context.log.step(`Logs of ${app.name}`);
341
+ task.detail(`the last ${LOG_TAIL} lines of ${app.container}`);
342
+
343
+ const result = await context.docker.container.logs(app.container, LOG_TAIL);
344
+
345
+ // Said, and left there. The revert still has to run, and logs that could
346
+ // not be read are no reason to leave a failed release serving
347
+ if (result.code !== 0) {
348
+ task.fail(`could not read the logs of ${app.container}: ${result.stdout || result.stderr}`);
349
+ return;
350
+ }
351
+
352
+ const lines = result.stdout.split("\n").filter((line) => line.length > 0);
353
+ for (const line of lines) task.line(line);
354
+
355
+ const said = `${lines.length} lines from ${app.container}`;
356
+ if (unhealthy.has(app.container)) task.fail(`${said}, which failed its health check`);
357
+ else task.done(said);
358
+ }),
359
+ );
315
360
  }
316
361
 
317
362
  // The proxy is one of these, derived rather than listed. What each should be
@@ -347,6 +392,8 @@ async function buildAll(
347
392
 
348
393
  return await Promise.all(
349
394
  config.apps.map(async (app) => {
395
+ const placed = appOf(topology, app.name);
396
+ const source = await clone(app, placed, here ?? context.host, environment.branch, log);
350
397
  const task = log.step(`Building ${app.name}`);
351
398
 
352
399
  try {
@@ -374,7 +421,7 @@ async function buildAll(
374
421
  output: task.line,
375
422
  };
376
423
 
377
- const result = await build(app, appOf(topology, app.name), buildContext);
424
+ const result = await build(app, placed, buildContext, source);
378
425
  task.done(`${result.release.slice(0, 7)}${result.cached ? " (held)" : ""}`);
379
426
 
380
427
  return { app, result };
@@ -386,6 +433,41 @@ async function buildAll(
386
433
  );
387
434
  }
388
435
 
436
+ // A step of its own, ahead of the build that reads it. A wrong branch or an
437
+ // unreachable repository shows here, and folded into the build it was a detail
438
+ // that scrolled past without ever saying what it fetched
439
+ async function clone(
440
+ app: AppSpec,
441
+ placed: AppTopology,
442
+ host: Host,
443
+ branch: string,
444
+ log: Log,
445
+ ): Promise<Source | undefined> {
446
+ // A directory is read where it is, not cloned, and the build still does that
447
+ if (!app.repo) return undefined;
448
+
449
+ const ref = refOf(app, branch);
450
+ const what = `${describeRepo(app.repo)} ${describeRef(ref)}`;
451
+ const task = log.step(`Cloning ${app.name}`);
452
+
453
+ try {
454
+ task.detail(what);
455
+ const source = await sourceOf(app, placed, {
456
+ host,
457
+ branch,
458
+ detail: task.detail,
459
+ output: task.line,
460
+ });
461
+
462
+ // Said again at the end, where it outlasts the details that replaced it
463
+ task.done(`${what} -> ${source.release.slice(0, 7)}`);
464
+ return source;
465
+ } catch (error) {
466
+ task.fail(`${app.name} could not clone ${what}`);
467
+ throw error;
468
+ }
469
+ }
470
+
389
471
  async function resolveFiles(app: AppSpec, stores: SecretStores) {
390
472
  const entries = await Promise.all(
391
473
  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/health.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { silent, type Log } from "./log.js";
1
+ import { silent, type Task } from "./log.js";
2
2
  import type { HealthSpec } from "./types.js";
3
3
 
4
4
  const RETRIES = 10;
@@ -16,7 +16,9 @@ export type Probe = (
16
16
  export type HealthDeps = {
17
17
  probe: Probe;
18
18
  sleep: (ms: number) => Promise<void>;
19
- log?: Log;
19
+ // The step the check reports on. Every attempt is said on it, so a check that
20
+ // gave up shows what the container answered each time rather than only that
21
+ task?: Task;
20
22
  };
21
23
 
22
24
  // One loop for every app. The two copies it replaces had already drifted: the
@@ -30,22 +32,28 @@ export async function healthcheck(
30
32
  ): Promise<boolean> {
31
33
  const retries = spec.retries ?? RETRIES;
32
34
  const ceiling = spec.intervalMs ?? INTERVAL_MS;
33
- const log = deps.log ?? silent;
35
+ const task = deps.task ?? silent.step("");
34
36
  const delay = spec.delayMs ?? DELAY_MS;
37
+ const url = `localhost:${port}${spec.path}`;
35
38
 
39
+ task.detail(`probing ${container} at ${url}`);
36
40
  if (delay > 0) await deps.sleep(delay);
37
41
 
38
42
  let backoff = FIRST_BACKOFF_MS;
39
43
 
40
44
  for (let attempt = 1; attempt <= retries; attempt += 1) {
41
- const url = `localhost:${port}${spec.path}`;
42
45
  const { code, output } = await deps.probe(container, url);
46
+ const said = (message: string) => task.detail(`attempt ${attempt} of ${retries}: ${message}`);
43
47
 
44
- if (code === 0 && passes(spec, output, log, container)) {
45
- log.done(`${container} is healthy`);
48
+ if (code === 0 && passes(spec, output, said)) {
49
+ task.done(`${container} healthy after ${attempt} ${attempt === 1 ? "attempt" : "attempts"}`);
46
50
  return true;
47
51
  }
48
52
 
53
+ // Nothing answered at all, which is a container still starting or one that
54
+ // is listening somewhere other than where the check asks
55
+ if (code !== 0) said(`no answer on ${url}`);
56
+
49
57
  if (attempt === retries) break;
50
58
 
51
59
  // Doubling from a quarter second means a container that starts quickly
@@ -54,30 +62,29 @@ export async function healthcheck(
54
62
  backoff = Math.min(backoff * 2, ceiling);
55
63
  }
56
64
 
57
- log.fail(`${container} failed its health check after ${retries} attempts`);
65
+ task.fail(`${container} unhealthy after ${retries} attempts`);
58
66
  return false;
59
67
  }
60
68
 
61
69
  // A body that parses but fails the predicate is a retry, not a verdict. The
62
70
  // container may still be warming up, and the caller has a retry budget for it
63
- function passes(
64
- spec: HealthSpec,
65
- output: string,
66
- log: Log,
67
- container: string,
68
- ) {
71
+ function passes(spec: HealthSpec, output: string, said: (message: string) => void) {
69
72
  let body: unknown;
70
73
 
71
74
  try {
72
75
  body = JSON.parse(output);
73
76
  } catch {
74
- log.warn(`${container} returned a body that is not JSON: ${output}`);
77
+ said(`answered with something that is not JSON: ${output}`);
78
+ return false;
79
+ }
80
+
81
+ if (typeof body !== "object" || body === null) {
82
+ said(`answered with JSON that is not an object: ${output}`);
75
83
  return false;
76
84
  }
77
85
 
78
- if (typeof body !== "object" || body === null) return false;
79
86
  if (spec.expect(body as Record<string, unknown>)) return true;
80
87
 
81
- log.warn(`${container} is not healthy yet: ${output}`);
88
+ said(`not healthy yet: ${output}`);
82
89
  return false;
83
90
  }
package/src/log.ts CHANGED
@@ -1,8 +1,26 @@
1
1
  // The shape progress is reported through. The library only knows this much, so
2
2
  // a deploy driven from somewhere without a terminal supplies its own.
3
3
 
4
+ // A short label a terminal can draw as a tag, such as the host a repository
5
+ // lives on. Marked with private-use characters, which nothing a command prints
6
+ // uses, so a log finds one without guessing at brackets somebody typed
7
+ const TAG_OPEN = "\uE000";
8
+ const TAG_CLOSE = "\uE001";
9
+
10
+ export const TAGS = /\uE000([^\uE000\uE001]*)\uE001/g;
11
+
12
+ export function tag(label: string) {
13
+ return `${TAG_OPEN}${label}${TAG_CLOSE}`;
14
+ }
15
+
16
+ // For a log that has no way to draw one: a file, a pipe, a CI log
17
+ export function untagged(text: string) {
18
+ return text.replaceAll(TAGS, "[$1]");
19
+ }
20
+
4
21
  // Work that is still running. Announced when it starts, because the thing worth
5
- // knowing during a four minute build is which step is the four minutes
22
+ // knowing during a four minute build is which step is the four minutes. What it
23
+ // says may carry a tag, which a log that draws plain text passes through untagged
6
24
  export type Task = {
7
25
  // What the step is doing right now, replacing whatever it said before
8
26
  detail(message: string): void;
package/src/source.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Host } from "./host.js";
2
+ import { tag } from "./log.js";
2
3
 
3
4
  // Getting the source onto the machine that builds it. A repository the host
4
5
  // clones for itself over the forwarded agent, so no tree crosses the wire: a
@@ -45,6 +46,29 @@ const REVISION: Record<RefKind, (name: string) => string> = {
45
46
  commit: (name) => `${name}^{commit}`,
46
47
  };
47
48
 
49
+ // Hosts common enough to be worth a tag in place of their name
50
+ const HOSTS: Record<string, string> = { "github.com": "GH" };
51
+
52
+ // user@host:path, or scheme://user@host/path, with the .git every clone URL ends
53
+ // in. Anything that is not one of those, a directory for instance, has no host
54
+ const REMOTE = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/]+@)?([^:/]+)[:/](.+?)(?:\.git)?\/?$/;
55
+
56
+ // How a repository is named on screen: the host as a tag where it is a known
57
+ // one, and the path without the .git that says nothing a person needs
58
+ export function describeRepo(repo: string) {
59
+ const match = REMOTE.exec(repo);
60
+ if (!match?.[1] || !match[2]) return repo.replace(/\.git$/, "");
61
+
62
+ const known = HOSTS[match[1]];
63
+ return known ? `${tag(known)} ${match[2]}` : `${match[1]}:${match[2]}`;
64
+ }
65
+
66
+ // A branch is what is tracked nearly always, so it goes by its name alone. A tag
67
+ // or a commit is a pin, and says which kind it is
68
+ export function describeRef(ref: Ref) {
69
+ return ref.kind === "branch" ? ref.name : `${ref.kind} ${ref.name}`;
70
+ }
71
+
48
72
  // accept-new rather than no: a host key that changes is still worth refusing
49
73
  const GIT_SSH = "ssh -o StrictHostKeyChecking=accept-new";
50
74
 
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
  };