redkite 0.1.12 → 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 (46) hide show
  1. package/README.md +12 -5
  2. package/dist/cli/agent.d.ts +5 -1
  3. package/dist/cli/agent.d.ts.map +1 -1
  4. package/dist/cli/agent.js +7 -7
  5. package/dist/cli/agent.js.map +1 -1
  6. package/dist/cli/crash.js +4 -2
  7. package/dist/cli/crash.js.map +1 -1
  8. package/dist/cli/index.d.ts.map +1 -1
  9. package/dist/cli/index.js +18 -10
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/cli/log.d.ts.map +1 -1
  12. package/dist/cli/log.js +3 -2
  13. package/dist/cli/log.js.map +1 -1
  14. package/dist/cli/screen.d.ts +3 -0
  15. package/dist/cli/screen.d.ts.map +1 -1
  16. package/dist/cli/screen.js +32 -6
  17. package/dist/cli/screen.js.map +1 -1
  18. package/dist/cli/viewer.d.ts.map +1 -1
  19. package/dist/cli/viewer.js +5 -5
  20. package/dist/cli/viewer.js.map +1 -1
  21. package/dist/deploy.d.ts.map +1 -1
  22. package/dist/deploy.js +6 -3
  23. package/dist/deploy.js.map +1 -1
  24. package/dist/health.d.ts +2 -2
  25. package/dist/health.d.ts.map +1 -1
  26. package/dist/health.js +17 -9
  27. package/dist/health.js.map +1 -1
  28. package/dist/log.d.ts +3 -0
  29. package/dist/log.d.ts.map +1 -1
  30. package/dist/log.js +13 -0
  31. package/dist/log.js.map +1 -1
  32. package/dist/source.d.ts +2 -0
  33. package/dist/source.d.ts.map +1 -1
  34. package/dist/source.js +20 -0
  35. package/dist/source.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/cli/agent.ts +8 -8
  38. package/src/cli/crash.ts +4 -3
  39. package/src/cli/index.ts +21 -9
  40. package/src/cli/log.ts +3 -2
  41. package/src/cli/screen.ts +45 -4
  42. package/src/cli/viewer.ts +5 -4
  43. package/src/deploy.ts +7 -4
  44. package/src/health.ts +23 -16
  45. package/src/log.ts +19 -1
  46. package/src/source.ts +24 -0
package/src/cli/viewer.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  elapsed,
6
6
  emptyModel,
7
7
  keysOf,
8
+ paintTags,
8
9
  render,
9
10
  type Model,
10
11
  type Step,
@@ -113,7 +114,7 @@ export function createViewer(
113
114
  stream.write(ALTERNATE_OFF);
114
115
  // The alternate screen takes every frame with it, so the run has to be
115
116
  // written again on the screen the person keeps
116
- for (const line of summary(model)) stream.write(`${line}\n`);
117
+ for (const line of summary(model, colour)) stream.write(`${line}\n`);
117
118
  };
118
119
 
119
120
  // What a second press means is not the view's to decide: the same key
@@ -191,18 +192,18 @@ export function createViewer(
191
192
 
192
193
  // One line per step and whatever was said outside them, which is the record a
193
194
  // person scrolls back to after the deploy is over
194
- function summary(model: Model): string[] {
195
+ function summary(model: Model, colour: boolean): string[] {
195
196
  const rows = model.steps.map((step) => {
196
197
  const glyph = step.state === "done" ? "✔" : step.state === "failed" ? "✘" : "·";
197
198
  const said = step.note ? `: ${step.note}` : "";
198
199
  const took = elapsed(step.ended ?? model.now, step.started);
199
200
 
200
- return `${glyph} ${step.label}${said} (${took})`;
201
+ return paintTags(`${glyph} ${step.label}${said} (${took})`, colour);
201
202
  });
202
203
 
203
204
  return [
204
205
  ...rows,
205
- ...model.messages.map((message) => message.text),
206
+ ...model.messages.map((message) => paintTags(message.text, colour)),
206
207
  ...whatFailed(model),
207
208
  ];
208
209
  }
package/src/deploy.ts CHANGED
@@ -26,7 +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
+ import { describeRef, describeRepo, type Source } from "./source.js";
30
30
  import { topologyFor, type AppTopology, type Topology } from "./topology.js";
31
31
  import type { AppSpec, Deployment } from "./types.js";
32
32
 
@@ -305,13 +305,16 @@ async function checkAll(context: Context, health: Omit<HealthDeps, "probe">) {
305
305
  const results = await Promise.all(
306
306
  context.config.apps.map(async (app) => {
307
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
308
311
  const deps: HealthDeps = {
309
312
  ...health,
310
313
  probe: async (container, url) => {
311
314
  const result = await docker.run(`exec ${container} curl -s ${url}`);
312
315
  return { code: result.code, output: result.stdout };
313
316
  },
314
- log,
317
+ task: log.step(`Health check of ${app.name}`),
315
318
  };
316
319
 
317
320
  const healthy = await healthcheck(target.container, target.port, app.health, deps);
@@ -444,7 +447,7 @@ async function clone(
444
447
  if (!app.repo) return undefined;
445
448
 
446
449
  const ref = refOf(app, branch);
447
- const what = `${app.repo}, ${ref.kind} ${ref.name}`;
450
+ const what = `${describeRepo(app.repo)} ${describeRef(ref)}`;
448
451
  const task = log.step(`Cloning ${app.name}`);
449
452
 
450
453
  try {
@@ -457,7 +460,7 @@ async function clone(
457
460
  });
458
461
 
459
462
  // Said again at the end, where it outlasts the details that replaced it
460
- task.done(`${what} at ${source.release.slice(0, 7)}`);
463
+ task.done(`${what} -> ${source.release.slice(0, 7)}`);
461
464
  return source;
462
465
  } catch (error) {
463
466
  task.fail(`${app.name} could not clone ${what}`);
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