redkite 0.1.6

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 (179) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +819 -0
  3. package/bin/redkite.mjs +12 -0
  4. package/dist/build.d.ts +27 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +132 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/checks.d.ts +4 -0
  9. package/dist/checks.d.ts.map +1 -0
  10. package/dist/checks.js +49 -0
  11. package/dist/checks.js.map +1 -0
  12. package/dist/cli/agent.d.ts +2 -0
  13. package/dist/cli/agent.d.ts.map +1 -0
  14. package/dist/cli/agent.js +24 -0
  15. package/dist/cli/agent.js.map +1 -0
  16. package/dist/cli/config.d.ts +5 -0
  17. package/dist/cli/config.d.ts.map +1 -0
  18. package/dist/cli/config.js +217 -0
  19. package/dist/cli/config.js.map +1 -0
  20. package/dist/cli/index.d.ts +11 -0
  21. package/dist/cli/index.d.ts.map +1 -0
  22. package/dist/cli/index.js +438 -0
  23. package/dist/cli/index.js.map +1 -0
  24. package/dist/cli/log.d.ts +9 -0
  25. package/dist/cli/log.d.ts.map +1 -0
  26. package/dist/cli/log.js +128 -0
  27. package/dist/cli/log.js.map +1 -0
  28. package/dist/cli/screen.d.ts +34 -0
  29. package/dist/cli/screen.d.ts.map +1 -0
  30. package/dist/cli/screen.js +298 -0
  31. package/dist/cli/screen.js.map +1 -0
  32. package/dist/cli/viewer.d.ts +9 -0
  33. package/dist/cli/viewer.d.ts.map +1 -0
  34. package/dist/cli/viewer.js +139 -0
  35. package/dist/cli/viewer.js.map +1 -0
  36. package/dist/config.d.ts +7 -0
  37. package/dist/config.d.ts.map +1 -0
  38. package/dist/config.js +79 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/deploy.d.ts +19 -0
  41. package/dist/deploy.d.ts.map +1 -0
  42. package/dist/deploy.js +275 -0
  43. package/dist/deploy.js.map +1 -0
  44. package/dist/docker.d.ts +96 -0
  45. package/dist/docker.d.ts.map +1 -0
  46. package/dist/docker.js +325 -0
  47. package/dist/docker.js.map +1 -0
  48. package/dist/dockerfile.d.ts +14 -0
  49. package/dist/dockerfile.d.ts.map +1 -0
  50. package/dist/dockerfile.js +160 -0
  51. package/dist/dockerfile.js.map +1 -0
  52. package/dist/health.d.ts +13 -0
  53. package/dist/health.d.ts.map +1 -0
  54. package/dist/health.js +54 -0
  55. package/dist/health.js.map +1 -0
  56. package/dist/host.d.ts +21 -0
  57. package/dist/host.d.ts.map +1 -0
  58. package/dist/host.js +27 -0
  59. package/dist/host.js.map +1 -0
  60. package/dist/index.d.ts +26 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +26 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/layout.d.ts +5 -0
  65. package/dist/layout.d.ts.map +1 -0
  66. package/dist/layout.js +45 -0
  67. package/dist/layout.js.map +1 -0
  68. package/dist/localHost.d.ts +7 -0
  69. package/dist/localHost.d.ts.map +1 -0
  70. package/dist/localHost.js +32 -0
  71. package/dist/localHost.js.map +1 -0
  72. package/dist/log.d.ts +14 -0
  73. package/dist/log.d.ts.map +1 -0
  74. package/dist/log.js +15 -0
  75. package/dist/log.js.map +1 -0
  76. package/dist/nginx.d.ts +4 -0
  77. package/dist/nginx.d.ts.map +1 -0
  78. package/dist/nginx.js +53 -0
  79. package/dist/nginx.js.map +1 -0
  80. package/dist/pipeline.d.ts +103 -0
  81. package/dist/pipeline.d.ts.map +1 -0
  82. package/dist/pipeline.js +142 -0
  83. package/dist/pipeline.js.map +1 -0
  84. package/dist/presets/index.d.ts +3 -0
  85. package/dist/presets/index.d.ts.map +1 -0
  86. package/dist/presets/index.js +3 -0
  87. package/dist/presets/index.js.map +1 -0
  88. package/dist/presets/nextApp.d.ts +15 -0
  89. package/dist/presets/nextApp.d.ts.map +1 -0
  90. package/dist/presets/nextApp.js +53 -0
  91. package/dist/presets/nextApp.js.map +1 -0
  92. package/dist/presets/nodeApp.d.ts +19 -0
  93. package/dist/presets/nodeApp.d.ts.map +1 -0
  94. package/dist/presets/nodeApp.js +29 -0
  95. package/dist/presets/nodeApp.js.map +1 -0
  96. package/dist/secrets/bitwarden.d.ts +3 -0
  97. package/dist/secrets/bitwarden.d.ts.map +1 -0
  98. package/dist/secrets/bitwarden.js +6 -0
  99. package/dist/secrets/bitwarden.js.map +1 -0
  100. package/dist/secrets/refs.d.ts +9 -0
  101. package/dist/secrets/refs.d.ts.map +1 -0
  102. package/dist/secrets/refs.js +20 -0
  103. package/dist/secrets/refs.js.map +1 -0
  104. package/dist/secrets/store.d.ts +9 -0
  105. package/dist/secrets/store.d.ts.map +1 -0
  106. package/dist/secrets/store.js +104 -0
  107. package/dist/secrets/store.js.map +1 -0
  108. package/dist/services/ensure.d.ts +18 -0
  109. package/dist/services/ensure.d.ts.map +1 -0
  110. package/dist/services/ensure.js +93 -0
  111. package/dist/services/ensure.js.map +1 -0
  112. package/dist/services/index.d.ts +19 -0
  113. package/dist/services/index.d.ts.map +1 -0
  114. package/dist/services/index.js +27 -0
  115. package/dist/services/index.js.map +1 -0
  116. package/dist/services/planned.d.ts +20 -0
  117. package/dist/services/planned.d.ts.map +1 -0
  118. package/dist/services/planned.js +77 -0
  119. package/dist/services/planned.js.map +1 -0
  120. package/dist/shell.d.ts +11 -0
  121. package/dist/shell.d.ts.map +1 -0
  122. package/dist/shell.js +65 -0
  123. package/dist/shell.js.map +1 -0
  124. package/dist/source.d.ts +16 -0
  125. package/dist/source.d.ts.map +1 -0
  126. package/dist/source.js +108 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/sshHost.d.ts +15 -0
  129. package/dist/sshHost.d.ts.map +1 -0
  130. package/dist/sshHost.js +91 -0
  131. package/dist/sshHost.js.map +1 -0
  132. package/dist/steps.d.ts +27 -0
  133. package/dist/steps.d.ts.map +1 -0
  134. package/dist/steps.js +69 -0
  135. package/dist/steps.js.map +1 -0
  136. package/dist/topology.d.ts +40 -0
  137. package/dist/topology.d.ts.map +1 -0
  138. package/dist/topology.js +113 -0
  139. package/dist/topology.js.map +1 -0
  140. package/dist/types.d.ts +100 -0
  141. package/dist/types.d.ts.map +1 -0
  142. package/dist/types.js +2 -0
  143. package/dist/types.js.map +1 -0
  144. package/package.json +64 -0
  145. package/src/build.ts +236 -0
  146. package/src/checks.ts +65 -0
  147. package/src/cli/agent.ts +28 -0
  148. package/src/cli/config.ts +275 -0
  149. package/src/cli/index.ts +559 -0
  150. package/src/cli/log.ts +168 -0
  151. package/src/cli/screen.ts +409 -0
  152. package/src/cli/viewer.ts +192 -0
  153. package/src/config.ts +90 -0
  154. package/src/deploy.ts +409 -0
  155. package/src/docker.ts +433 -0
  156. package/src/dockerfile.ts +228 -0
  157. package/src/health.ts +83 -0
  158. package/src/host.ts +54 -0
  159. package/src/index.ts +25 -0
  160. package/src/layout.ts +41 -0
  161. package/src/localHost.ts +50 -0
  162. package/src/log.ts +38 -0
  163. package/src/nginx.ts +63 -0
  164. package/src/pipeline.ts +335 -0
  165. package/src/presets/index.ts +2 -0
  166. package/src/presets/nextApp.ts +70 -0
  167. package/src/presets/nodeApp.ts +46 -0
  168. package/src/secrets/bitwarden.ts +7 -0
  169. package/src/secrets/refs.ts +32 -0
  170. package/src/secrets/store.ts +147 -0
  171. package/src/services/ensure.ts +160 -0
  172. package/src/services/index.ts +48 -0
  173. package/src/services/planned.ts +119 -0
  174. package/src/shell.ts +95 -0
  175. package/src/source.ts +171 -0
  176. package/src/sshHost.ts +145 -0
  177. package/src/steps.ts +101 -0
  178. package/src/topology.ts +199 -0
  179. package/src/types.ts +243 -0
package/src/health.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { silent, type Log } from "./log.js";
2
+ import type { HealthSpec } from "./types.js";
3
+
4
+ const RETRIES = 10;
5
+ // Ceiling for the backoff, not a fixed wait
6
+ const INTERVAL_MS = 5000;
7
+ // The first probe is free, a container that is already up answers it
8
+ const DELAY_MS = 0;
9
+ const FIRST_BACKOFF_MS = 250;
10
+
11
+ export type Probe = (
12
+ container: string,
13
+ url: string,
14
+ ) => Promise<{ code: number; output: string }>;
15
+
16
+ export type HealthDeps = {
17
+ probe: Probe;
18
+ sleep: (ms: number) => Promise<void>;
19
+ log?: Log;
20
+ };
21
+
22
+ // One loop for every app. The two copies it replaces had already drifted: the
23
+ // frontend never incremented its counter on a failed predicate, so a container
24
+ // that answered but never became healthy span until the deploy was killed.
25
+ export async function healthcheck(
26
+ container: string,
27
+ port: number,
28
+ spec: HealthSpec,
29
+ deps: HealthDeps,
30
+ ): Promise<boolean> {
31
+ const retries = spec.retries ?? RETRIES;
32
+ const ceiling = spec.intervalMs ?? INTERVAL_MS;
33
+ const log = deps.log ?? silent;
34
+ const delay = spec.delayMs ?? DELAY_MS;
35
+
36
+ if (delay > 0) await deps.sleep(delay);
37
+
38
+ let backoff = FIRST_BACKOFF_MS;
39
+
40
+ for (let attempt = 1; attempt <= retries; attempt += 1) {
41
+ const url = `localhost:${port}${spec.path}`;
42
+ const { code, output } = await deps.probe(container, url);
43
+
44
+ if (code === 0 && passes(spec, output, log, container)) {
45
+ log.done(`${container} is healthy`);
46
+ return true;
47
+ }
48
+
49
+ if (attempt === retries) break;
50
+
51
+ // Doubling from a quarter second means a container that starts quickly
52
+ // costs almost nothing, and one that does not still gets the same budget
53
+ await deps.sleep(backoff);
54
+ backoff = Math.min(backoff * 2, ceiling);
55
+ }
56
+
57
+ log.fail(`${container} failed its health check after ${retries} attempts`);
58
+ return false;
59
+ }
60
+
61
+ // A body that parses but fails the predicate is a retry, not a verdict. The
62
+ // 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
+ ) {
69
+ let body: unknown;
70
+
71
+ try {
72
+ body = JSON.parse(output);
73
+ } catch {
74
+ log.warn(`${container} returned a body that is not JSON: ${output}`);
75
+ return false;
76
+ }
77
+
78
+ if (typeof body !== "object" || body === null) return false;
79
+ if (spec.expect(body as Record<string, unknown>)) return true;
80
+
81
+ log.warn(`${container} is not healthy yet: ${output}`);
82
+ return false;
83
+ }
package/src/host.ts ADDED
@@ -0,0 +1,54 @@
1
+ // The machine the containers run on. Every command a deploy issues goes through
2
+ // this, so the rest of the library is exercised against a recorder rather than
3
+ // against a host. Two implementations: one over ssh, one on this machine.
4
+
5
+ export type Result = { code: number; stdout: string; stderr: string };
6
+
7
+ // Called per line while a command runs, for the ones long enough that the person
8
+ // watching needs to know what they are waiting for
9
+ export type OnLine = (line: string) => void;
10
+
11
+ export type Host = {
12
+ // A shell command, run where the containers are. The shell is what lets a
13
+ // caller redirect, chain, and quote, which several of them do
14
+ sh(command: string, onLine?: OnLine): Promise<Result>;
15
+ // Writes contents to a file there, answering with the path it landed at.
16
+ // Parent directories are created, so a name may contain slashes
17
+ write(name: string, contents: string): Promise<string>;
18
+ // Scratch space this host hands out, removed when the deploy closes
19
+ readonly directory: string;
20
+ // Survives a deploy: the git mirrors and the checkouts built from them
21
+ readonly cache: string;
22
+ // Streams one command's output into another's input, the near side local and
23
+ // the far side here. Shipping an image is the only caller
24
+ pipe(local: string, remote: string, onLine?: OnLine): Promise<Result>;
25
+ // Signals everything this host started and answers with how many are still
26
+ // running. Zero is the only answer that means nothing was left behind
27
+ stop(signal: "TERM" | "KILL"): Promise<number>;
28
+ close?(): Promise<void>;
29
+ };
30
+
31
+ // A streamed command keeps this many lines, so a build that prints tens of
32
+ // megabytes still fits in the error a failure reports
33
+ const TAIL = 200;
34
+
35
+ export function tail(lines: string[]) {
36
+ return lines.slice(-TAIL).join("\n");
37
+ }
38
+
39
+ // Splits a chunked stream into lines, holding the partial last one back
40
+ export function lineReader(onLine: OnLine) {
41
+ let rest = "";
42
+
43
+ return {
44
+ push(chunk: string) {
45
+ const parts = (rest + chunk).split("\n");
46
+ rest = parts.pop() ?? "";
47
+ for (const line of parts) onLine(line);
48
+ },
49
+ flush() {
50
+ if (rest) onLine(rest);
51
+ rest = "";
52
+ },
53
+ };
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export * from "./build.js";
2
+ export * from "./config.js";
3
+ export * from "./deploy.js";
4
+ export * from "./docker.js";
5
+ export * from "./dockerfile.js";
6
+ export * from "./health.js";
7
+ export * from "./host.js";
8
+ export * from "./layout.js";
9
+ export * from "./localHost.js";
10
+ export * from "./log.js";
11
+ export * from "./nginx.js";
12
+ export * from "./pipeline.js";
13
+ export * from "./presets/index.js";
14
+ export * from "./secrets/bitwarden.js";
15
+ export * from "./secrets/refs.js";
16
+ export * from "./secrets/store.js";
17
+ export * from "./shell.js";
18
+ export * from "./services/ensure.js";
19
+ export * from "./services/index.js";
20
+ export * from "./services/planned.js";
21
+ export * from "./source.js";
22
+ export * from "./sshHost.js";
23
+ export * from "./steps.js";
24
+ export * from "./topology.js";
25
+ export * from "./types.js";
package/src/layout.ts ADDED
@@ -0,0 +1,41 @@
1
+ // Where a cache is mounted and where a carried directory lands. The renderer
2
+ // and the topology have to agree on these, and a second copy of them is a
3
+ // drift waiting to happen.
4
+
5
+ // A package manager's own cache is global; everything else is a path inside the
6
+ // project. Both managers are listed because a preset does not know which the
7
+ // repository uses, and mounting the wrong one is a cache that never hits
8
+ export function mountFor(name: string, dir?: string) {
9
+ if (name === "yarn") return "/root/.yarn";
10
+ if (name === "npm") return "/root/.npm";
11
+ // A workspace resolves one lockfile at the repository root and hoists there
12
+ if (name === "modules") return "/app/node_modules";
13
+ // An app that installs against its own manifest puts them beside it instead.
14
+ // Both are mounted, because only the install command knows which it does, and
15
+ // mounting the wrong one alone is a cache that never hits
16
+ if (name === "app-modules") return `${appRoot(dir)}/node_modules`;
17
+ if (name === "next-app") return rootedAt("/app/.next/cache", dir);
18
+ return rootedAt(`/app/.cache/${name}`, dir);
19
+ }
20
+
21
+ // Where the app itself sits. The whole repository is copied to /app, and dir is
22
+ // the app's own place in it
23
+ export function appRoot(dir?: string) {
24
+ return dir ? `/app/${dir}` : "/app";
25
+ }
26
+
27
+ // A path under /app belongs to the app, so it moves with it. Bare /app is the
28
+ // repository itself and stays: that is what a build shipping its whole tree copies
29
+ export function rootedAt(path: string, dir?: string) {
30
+ if (!dir || !path.startsWith("/app/")) return path;
31
+ return `${appRoot(dir)}/${path.slice("/app/".length)}`;
32
+ }
33
+
34
+ // A directory inside the output root moves with it, because the output becomes
35
+ // /app in the runtime. Anything else keeps the position it had in the builder:
36
+ // flattening it to a basename is what put Next's static assets at /app/static,
37
+ // where the server looks for them under .next and answers 404
38
+ export function destinationFor(path: string, output: string) {
39
+ if (path.startsWith(`${output}/`)) return `/app/${path.slice(output.length + 1)}`;
40
+ return path;
41
+ }
@@ -0,0 +1,50 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import type { Host } from "./host.js";
6
+ import { signalEverything, spawnCollect } from "./shell.js";
7
+
8
+ // The same Host without a network in the way, for an environment that names no
9
+ // bastion. Deploying to this machine and deploying to another one differ in
10
+ // nothing but which of these the CLI constructs.
11
+
12
+ export type LocalOptions = { cache?: string; signal?: AbortSignal };
13
+
14
+ export async function localHost(options: LocalOptions = {}): Promise<Host> {
15
+ const directory = await mkdtemp(join(tmpdir(), "redkite-"));
16
+ const cache = options.cache ?? join(homedir(), ".cache", "redkite");
17
+
18
+ await mkdir(cache, { recursive: true });
19
+
20
+ return {
21
+ directory,
22
+ cache,
23
+ sh: async (command, onLine) =>
24
+ await spawnCollect("sh", ["-c", command], { onLine, signal: options.signal }),
25
+
26
+ write: async (name, contents) => {
27
+ const path = join(directory, name);
28
+
29
+ await mkdir(dirname(path), { recursive: true });
30
+ await writeFile(path, contents, { mode: 0o600 });
31
+
32
+ return path;
33
+ },
34
+
35
+ // Both ends are this machine, so the pipe is one shell away
36
+ pipe: async (local, remote, onLine) =>
37
+ await spawnCollect("sh", ["-c", `${local} | ${remote}`], {
38
+ onLine,
39
+ signal: options.signal,
40
+ }),
41
+
42
+ // The build is a grandchild of the shell that was spawned, and every child
43
+ // is a process group leader, so one signal reaches the whole tree
44
+ stop: async (name) => signalEverything(name === "KILL" ? "SIGKILL" : "SIGTERM"),
45
+
46
+ close: async () => {
47
+ await rm(directory, { recursive: true, force: true });
48
+ },
49
+ };
50
+ }
package/src/log.ts ADDED
@@ -0,0 +1,38 @@
1
+ // The shape progress is reported through. The library only knows this much, so
2
+ // a deploy driven from somewhere without a terminal supplies its own.
3
+
4
+ // 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
6
+ export type Task = {
7
+ // What the step is doing right now, replacing whatever it said before
8
+ detail(message: string): void;
9
+ // A line the step produced, kept under it. detail replaces, this appends,
10
+ // and a build's own output is the reason there are two
11
+ line(message: string): void;
12
+ done(message?: string): void;
13
+ fail(message: string): void;
14
+ };
15
+
16
+ export type Log = ((message: string) => void) & {
17
+ // Something the run survived, but that the person should know happened
18
+ warn(message: string): void;
19
+ // The reason a deploy is about to stop, or did
20
+ fail(message: string): void;
21
+ // A step reaching the state it was waiting for
22
+ done(message: string): void;
23
+ step(label: string): Task;
24
+ };
25
+
26
+ const NOTHING: Task = {
27
+ detail: () => {},
28
+ line: () => {},
29
+ done: () => {},
30
+ fail: () => {},
31
+ };
32
+
33
+ export const silent: Log = Object.assign(() => {}, {
34
+ warn: () => {},
35
+ fail: () => {},
36
+ done: () => {},
37
+ step: () => NOTHING,
38
+ });
package/src/nginx.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { AppTopology, Topology } from "./topology.js";
2
+
3
+ // The nginx configuration is a function of the app list. Header policy is set
4
+ // here once rather than per location, which is what let the two blocks drift
5
+ // apart and lose X-Forwarded-For on one of them.
6
+
7
+ const FORWARDING = [
8
+ "proxy_set_header Host $http_host;",
9
+ "proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
10
+ "proxy_set_header X-Forwarded-Host $http_host;",
11
+ "proxy_set_header X-Forwarded-Proto $scheme;",
12
+ ];
13
+
14
+ const FAILOVER = [
15
+ "proxy_connect_timeout 2s;",
16
+ "proxy_read_timeout 30s;",
17
+ "proxy_next_upstream error timeout http_502 http_503;",
18
+ "proxy_next_upstream_tries 2;",
19
+ ];
20
+
21
+ // The port the proxy listens on inside its container. The published port maps
22
+ // onto this one, so both sides have to agree and only one of them may say it
23
+ export const LISTEN_PORT = 3000;
24
+
25
+ export function renderNginx(topology: Topology, maxBodySize?: string) {
26
+
27
+ const upstreams = topology.apps.map((app) => upstream(app)).join("\n\n");
28
+ // Longest route first, so "/api/" is not shadowed by the "/" catch-all
29
+ const ordered = [...topology.apps].sort((a, b) => b.route.length - a.route.length);
30
+ const locations = ordered.map((app) => location(app)).join("\n\n");
31
+
32
+ return `resolver 127.0.0.1 valid=5s;
33
+
34
+ ${upstreams}
35
+
36
+ server {
37
+ listen ${LISTEN_PORT};
38
+ merge_slashes off;
39
+
40
+ client_max_body_size ${maxBodySize ?? "1M"};
41
+
42
+ ${locations}
43
+ }
44
+ `;
45
+ }
46
+
47
+ function upstream(app: AppTopology) {
48
+ return `upstream ${app.name} {
49
+ server ${app.container}:${app.port} max_fails=1 fail_timeout=2s;
50
+ server ${app.retired}:${app.port} backup;
51
+ }`;
52
+ }
53
+
54
+ function location(app: AppTopology) {
55
+ // A trailing slash on proxy_pass strips the location prefix. The catch-all
56
+ // must not carry one, a mounted route must
57
+ const target = app.route === "/" ? `http://${app.name}` : `http://${app.name}/`;
58
+ const body = [`proxy_pass ${target};`, ...FAILOVER, ...FORWARDING];
59
+
60
+ return ` location ${app.route} {
61
+ ${body.map((line) => ` ${line}`).join("\n")}
62
+ }`;
63
+ }
@@ -0,0 +1,335 @@
1
+ import type { Docker } from "./docker.js";
2
+ import type { Host } from "./host.js";
3
+ import type { Log, Task } from "./log.js";
4
+ import type { SecretStores } from "./secrets/refs.js";
5
+ import type { Topology } from "./topology.js";
6
+ import type { Deployment } from "./types.js";
7
+
8
+ // Capistrano's shape: work addressed by where it runs rather than by who calls
9
+ // it, so a plugin or a config adds a step without any file here knowing it
10
+ // exists. A run is a list of steps, each handed what the one before it answered
11
+ // with. Redkite's own four are ordinary members of that list.
12
+
13
+ // Every phase redkite supplies a step for. Each is also the name of the point
14
+ // that step sits at. No run walks all of them: swap and verify are alternatives
15
+ export const PHASES = ["setup", "build", "verify", "swap", "cleanup"] as const;
16
+ export type Phase = (typeof PHASES)[number];
17
+
18
+ // The phases each command walks, in the order it walks them. A phase left out
19
+ // is not run, and a step hung on one of its slots never fires
20
+ export const RUNS = {
21
+ deploy: ["setup", "build", "swap", "cleanup"],
22
+ verify: ["setup", "build", "verify", "cleanup"],
23
+ } as const satisfies Record<string, readonly Phase[]>;
24
+
25
+ export type Run = keyof typeof RUNS;
26
+
27
+ // Everything before the phase's own step, the phase's own slot, everything after
28
+ export const SLOTS = ["before", "main", "after"] as const;
29
+ export type Slot = (typeof SLOTS)[number];
30
+
31
+ // Where a step runs. The bare phase is the point redkite's own step sits at, and a
32
+ // deployment that puts its own step there replaces it: nothing about redkite's
33
+ // four makes them harder to displace than any other step
34
+ export type Point = Phase | `${Phase}:${string}`;
35
+
36
+ // Every point but the four redkite's own steps sit at. A hook is handed and
37
+ // answers with one value, which is what lets a helper be written over all of
38
+ // them at once
39
+ export type Hook = Exclude<Point, Phase>;
40
+
41
+ // What a run has done so far. Each phase adds to what it was handed rather than
42
+ // replacing it, so a step late in the pipeline reads everything above it
43
+
44
+ export type Start = {
45
+ environment: string;
46
+ };
47
+
48
+ export type Prepared = Start & {
49
+ network: string;
50
+ // Every service's container, whether this run created it or adopted one
51
+ services: string[];
52
+ };
53
+
54
+ export type BuiltApp = {
55
+ name: string;
56
+ container: string;
57
+ release: string;
58
+ fingerprint: string;
59
+ // The host already held this image, and nothing was rebuilt
60
+ cached: boolean;
61
+ // The image a step hung before the swap runs in, holding the app's own
62
+ // toolchain rather than only what it compiled to
63
+ builderTag: string;
64
+ };
65
+
66
+ export type Built = Prepared & {
67
+ apps: BuiltApp[];
68
+ };
69
+
70
+ export type Released = Built & {
71
+ // False when an app failed its health check and every app was put back, or
72
+ // when a check the verify step ran said the build does not work
73
+ ok: boolean;
74
+ released: string[];
75
+ reverted: string[];
76
+ // Apps whose checks ran. A deploy runs none, so only a verify fills this,
77
+ // and the two runs answer with one shape so a step after either reads it
78
+ checked: string[];
79
+ };
80
+
81
+ export type Finished = Released & {
82
+ removed: string[];
83
+ reclaimed: string[];
84
+ };
85
+
86
+ // Everything a step is given besides the value: what is being deployed, and the
87
+ // machine it is being deployed to
88
+ export type Context = {
89
+ config: Deployment;
90
+ environment: string;
91
+ // Which run this is. A step that behaves differently in one is rare, but the
92
+ // service set does: nothing serves in a verify, so it has no proxy
93
+ run: Run;
94
+ topology: Topology;
95
+ host: Host;
96
+ docker: Docker;
97
+ secrets: SecretStores;
98
+ log: Log;
99
+ // The progress row this step already has. A step says what it is doing
100
+ // through this rather than opening a second one beside it
101
+ task: Task;
102
+ };
103
+
104
+ // What a phase's step is handed, and what it answers with. The two together are
105
+ // the only reason a step can be typed by where it runs
106
+ type PhaseInput = {
107
+ setup: Start;
108
+ build: Prepared;
109
+ verify: Built;
110
+ swap: Built;
111
+ cleanup: Released;
112
+ };
113
+
114
+ type PhaseOutput = {
115
+ setup: Prepared;
116
+ build: Built;
117
+ verify: Released;
118
+ swap: Released;
119
+ cleanup: Finished;
120
+ };
121
+
122
+ type PhaseOf<P extends Point> = P extends Phase
123
+ ? P
124
+ : P extends `${infer F extends Phase}:${string}`
125
+ ? F
126
+ : never;
127
+
128
+ // A phase's own step is what moves a run from one value to the next. Everything
129
+ // hung around it is handed what that side of it produced, so a step is typed by
130
+ // where it runs and never has to say what it takes
131
+ export type InputAt<P extends Point> = P extends Phase
132
+ ? PhaseInput[P]
133
+ : P extends `${string}:before:${string}`
134
+ ? PhaseInput[PhaseOf<P>]
135
+ : PhaseOutput[PhaseOf<P>];
136
+
137
+ export type OutputAt<P extends Point> = P extends Phase
138
+ ? PhaseOutput[P]
139
+ : P extends `${string}:before:${string}`
140
+ ? PhaseInput[PhaseOf<P>]
141
+ : PhaseOutput[PhaseOf<P>];
142
+
143
+ // What a step can know before a run starts: nothing has happened yet, so this
144
+ // is the config as written and the environment it was asked for
145
+ export type Plan = {
146
+ config: Deployment;
147
+ environment: string;
148
+ };
149
+
150
+ // A property rather than a method, because a method's parameter is checked
151
+ // bivariantly: declared as one, a step could narrow its input to a value the
152
+ // slot it runs in has not produced yet
153
+ export type Step<P extends Point = Point> = {
154
+ point: P;
155
+ // Run before the first step, so a step that cannot possibly work says so
156
+ // while the host is still untouched rather than half way through a swap
157
+ check?: (plan: Plan) => void;
158
+ run: (input: InputAt<P>, context: Context) => OutputAt<P> | Promise<OutputAt<P>>;
159
+ };
160
+
161
+ // Erased for storage. A list assembled from a config cannot carry each step's
162
+ // own input type, and `never` is what every one of them accepts
163
+ export type AnyStep = {
164
+ point: Point;
165
+ check?: (plan: Plan) => void;
166
+ run: (input: never, context: Context) => unknown;
167
+ };
168
+
169
+ // Identity, but it pins the point so a typo fails to compile and the input is
170
+ // inferred from where the step runs rather than annotated
171
+ export function defineStep<const P extends Point>(
172
+ point: P,
173
+ run: Step<P>["run"],
174
+ check?: Step<P>["check"],
175
+ ): Step<P> {
176
+ return { point, run, check };
177
+ }
178
+
179
+ // A step at the same point as one redkite supplies replaces it, in the place redkite
180
+ // had it. That is the whole of turning one of redkite's four off: put something
181
+ // there that does less, or nothing
182
+ export function merge(supplied: AnyStep[], added: AnyStep[]): AnyStep[] {
183
+ const overrides = new Map(added.map((step) => [step.point, step]));
184
+ const claimed = new Set(supplied.map((step) => step.point));
185
+
186
+ return [
187
+ ...supplied.map((step) => overrides.get(step.point) ?? step),
188
+ ...added.filter((step) => !claimed.has(step.point)),
189
+ ];
190
+ }
191
+
192
+ // Stopping between steps, rather than in the middle of one. A step is the unit
193
+ // that leaves the host in a state the next deploy can read
194
+ export class Aborted extends Error {
195
+ constructor(point: string) {
196
+ super(`Stopped before ${point}`);
197
+ this.name = "Aborted";
198
+ }
199
+ }
200
+
201
+ export async function runPipeline(
202
+ run: Run,
203
+ steps: AnyStep[],
204
+ setting: Omit<Context, "task">,
205
+ signal?: AbortSignal,
206
+ ): Promise<Finished> {
207
+ const ordered = sequence(steps, RUNS[run]);
208
+ const plan: Plan = { config: setting.config, environment: setting.environment };
209
+
210
+ // Every check before any step, so a run that is going to fail on a config
211
+ // mistake fails before it has created a network or built an image. Nothing
212
+ // wraps what a check throws: it is about the config, and the config is what
213
+ // the reader has in front of them
214
+ for (const step of ordered) step.check?.(plan);
215
+
216
+ // Every step is handed what the one before it answered with, so the whole run
217
+ // is a fold over one list. Running part of it at once is a change here alone
218
+ let value: unknown = { environment: setting.environment } satisfies Start;
219
+
220
+ for (const step of ordered) {
221
+ if (signal?.aborted) throw new Aborted(step.point);
222
+
223
+ const task = setting.log.step(step.point);
224
+
225
+ // A step declares what it takes and answers with through its point, and a
226
+ // list assembled from a config cannot carry that through. The declared
227
+ // types are the contract, and this is the one place they are taken on trust
228
+ const run = step.run as (input: unknown, context: Context) => unknown;
229
+
230
+ try {
231
+ value = await run(value, { ...setting, task });
232
+ task.done();
233
+ } catch (error) {
234
+ // One step throwing ends the run. Everything after it was written
235
+ // assuming the steps before did what they said they would
236
+ task.fail(`${step.point} failed`);
237
+ throw new Error(`Step ${step.point} failed`, { cause: error });
238
+ }
239
+ }
240
+
241
+ // Every run ends at cleanup, and a step there answers with a Finished or does
242
+ // not compile
243
+ return value as Finished;
244
+ }
245
+
246
+ // The order a run walks: for each of its phases, everything before it, the
247
+ // phase's own slot, then everything after. Redkite's own step leads its slot
248
+ // because merge keeps the supplied list first
249
+ export function sequence(
250
+ steps: AnyStep[],
251
+ phases: readonly Phase[] = RUNS.deploy,
252
+ ): AnyStep[] {
253
+ const ordered: AnyStep[] = [];
254
+
255
+ for (const phase of phases) {
256
+ for (const slot of SLOTS) {
257
+ ordered.push(...steps.filter((step) => runsAt(step, phase, slot)));
258
+ }
259
+ }
260
+
261
+ return ordered;
262
+ }
263
+
264
+ function runsAt(step: AnyStep, phase: Phase, slot: Slot) {
265
+ const address = addressOf(step.point);
266
+ return address.phase === phase && address.slot === slot;
267
+ }
268
+
269
+ export type Address = { phase: Phase; slot: Slot; name: string };
270
+
271
+ // The names everything else in redkite derives, so a step reads like the container
272
+ // and cache keys beside it
273
+ const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
274
+
275
+ // Points a config may still be written against, and what they became
276
+ const RENAMED: Record<string, Phase> = { deploy: "swap" };
277
+
278
+ export function addressOf(point: string): Address {
279
+ const parts = point.split(":");
280
+ const phase = parts[0];
281
+
282
+ const renamed = phase ? RENAMED[phase] : undefined;
283
+ if (renamed) {
284
+ throw new Error(`${point} names the phase ${phase}, which is now ${renamed}`);
285
+ }
286
+
287
+ if (!phase || !isPhase(phase)) {
288
+ throw new Error(`${point} names no phase, expected one of ${PHASES.join(", ")}`);
289
+ }
290
+
291
+ // The point redkite's own step sits at, and the one a deployment claims to
292
+ // replace it
293
+ if (parts.length === 1) return { phase, slot: "main", name: phase };
294
+
295
+ if (parts.length === 2) return { phase, slot: "main", name: named(point, parts[1]) };
296
+
297
+ if (parts.length !== 3) {
298
+ throw new Error(
299
+ `${point} is not a point, expected ${phase}, ${phase}:… or ${phase}:before|after:…`,
300
+ );
301
+ }
302
+
303
+ const slot = parts[1];
304
+ if (slot !== "before" && slot !== "after") {
305
+ throw new Error(`${point} names no slot, expected ${phase}:before:… or ${phase}:after:…`);
306
+ }
307
+
308
+ return { phase, slot, name: named(point, parts[2]) };
309
+ }
310
+
311
+ // Checked where the config is defined rather than where it runs, so a typo or a
312
+ // collision is a config that fails to load rather than a deploy that stops half
313
+ // way through with the host already changed
314
+ export function assertSteps(steps: AnyStep[]) {
315
+ const seen = new Set<string>();
316
+
317
+ for (const step of steps) {
318
+ addressOf(step.point);
319
+
320
+ if (seen.has(step.point)) {
321
+ throw new Error(`Two steps share the point ${step.point}`);
322
+ }
323
+
324
+ seen.add(step.point);
325
+ }
326
+ }
327
+
328
+ function named(point: string, name: string | undefined) {
329
+ if (name && NAME.test(name)) return name;
330
+ throw new Error(`${point} needs a kebab-case name`);
331
+ }
332
+
333
+ function isPhase(value: string): value is Phase {
334
+ return (PHASES as readonly string[]).includes(value);
335
+ }