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
@@ -0,0 +1,192 @@
1
+ import type { Log, Task } from "../log.js";
2
+
3
+ import {
4
+ apply,
5
+ elapsed,
6
+ emptyModel,
7
+ keysOf,
8
+ render,
9
+ type Model,
10
+ type Step,
11
+ } from "./screen.js";
12
+
13
+ // The terminal half: raw keys in, a frame out on a timer. Everything it decides
14
+ // lives in screen.ts, so this file is only the parts a test cannot run.
15
+
16
+ const FRAME_MS = 100;
17
+ const ALTERNATE_ON = "\u001b[?1049h\u001b[?25l";
18
+ const ALTERNATE_OFF = "\u001b[?25h\u001b[?1049l";
19
+ const HOME = "\u001b[H";
20
+ const CLEAR_LINE = "\u001b[K";
21
+ const CLEAR_BELOW = "\u001b[J";
22
+
23
+ // A build prints tens of thousands of lines and only the tail is ever read
24
+ const KEPT = 2000;
25
+
26
+ export type Viewer = Log & {
27
+ // Leaves the alternate screen and prints what happened, since nothing drawn
28
+ // inside it survives
29
+ close(): void;
30
+ };
31
+
32
+ // A pty whose size nobody set answers 0 rather than nothing, and a view one row
33
+ // tall can only ever show the step the cursor is on
34
+ const ROWS = 24;
35
+ const COLUMNS = 80;
36
+
37
+ const sizeOf = (stream: NodeJS.WriteStream) => ({
38
+ rows: stream.rows && stream.rows > 4 ? stream.rows : ROWS,
39
+ columns: stream.columns && stream.columns > 20 ? stream.columns : COLUMNS,
40
+ });
41
+
42
+ export type ViewerOptions = {
43
+ // Called on the quit key. The caller decides what asking twice means, since
44
+ // the same request arrives as a signal when there is no view
45
+ onQuit?: () => void;
46
+ };
47
+
48
+ export function createViewer(
49
+ stream: NodeJS.WriteStream,
50
+ input: NodeJS.ReadStream,
51
+ options: ViewerOptions = {},
52
+ ): Viewer {
53
+ // NO_COLOR is a standing instruction rather than a preference to re-ask
54
+ // about, and a terminal that took the alternate screen still may not paint
55
+ const colour = !process.env["NO_COLOR"] && stream.isTTY === true;
56
+ const size = sizeOf(stream);
57
+ let model = emptyModel(size.rows, size.columns, Date.now());
58
+ let open = true;
59
+
60
+ const draw = () => {
61
+ if (!open) return;
62
+
63
+ model = { ...model, now: Date.now(), ...sizeOf(stream) };
64
+ const rows = render(model, colour).map((row) => `${row}${CLEAR_LINE}`);
65
+
66
+ stream.write(`${HOME}${rows.join("\n")}${CLEAR_BELOW}`);
67
+ };
68
+
69
+ const change = (next: (current: Model) => Model) => {
70
+ model = next(model);
71
+ draw();
72
+ };
73
+
74
+ const timer = setInterval(draw, FRAME_MS);
75
+ timer.unref();
76
+
77
+ stream.write(ALTERNATE_ON);
78
+ if (input.isTTY) input.setRawMode(true);
79
+ input.resume();
80
+ input.setEncoding("utf8");
81
+
82
+ const say = (message: string) =>
83
+ change((current) => ({ ...current, messages: [...current.messages, message] }));
84
+
85
+ const onKey = (data: string) => {
86
+ for (const key of keysOf(data)) {
87
+ // Not a return: two presses in one read are two asks, and the second one
88
+ // is what hardens the signal the first one sent
89
+ if (key === "quit") quit();
90
+ else change((current) => apply(current, key));
91
+ }
92
+ };
93
+
94
+ input.on("data", onKey);
95
+ const onResize = () => draw();
96
+ stream.on("resize", onResize);
97
+
98
+ const close = () => {
99
+ if (!open) return;
100
+ open = false;
101
+
102
+ clearInterval(timer);
103
+ input.off("data", onKey);
104
+ stream.off("resize", onResize);
105
+ if (input.isTTY) input.setRawMode(false);
106
+ input.pause();
107
+
108
+ stream.write(ALTERNATE_OFF);
109
+ // The alternate screen takes every frame with it, so the run has to be
110
+ // written again on the screen the person keeps
111
+ for (const line of summary(model)) stream.write(`${line}\n`);
112
+ };
113
+
114
+ // What a second press means is not the view's to decide: the same key
115
+ // arrives as a signal when there is no view, and one answer serves both
116
+ const quit = () => options.onQuit?.();
117
+
118
+ process.once("exit", () => open && stream.write(ALTERNATE_OFF));
119
+
120
+ const step = (label: string): Task => {
121
+ let index = 0;
122
+
123
+ change((current) => {
124
+ index = current.steps.length;
125
+
126
+ const started: Step = {
127
+ label,
128
+ started: Date.now(),
129
+ state: "running",
130
+ lines: [],
131
+ // The one running is the one being read, unless the reader said no
132
+ expanded: !current.minimal,
133
+ held: false,
134
+ offset: 0,
135
+ };
136
+
137
+ return {
138
+ ...current,
139
+ steps: [...current.steps, started],
140
+ cursor: current.following ? index : current.cursor,
141
+ };
142
+ });
143
+
144
+ const edit = (change_: (step: Step) => Step) =>
145
+ change((current) => ({
146
+ ...current,
147
+ steps: current.steps.map((item, at) => (at === index ? change_(item) : item)),
148
+ }));
149
+
150
+ // A finished step shuts, so the list stays a list. One the reader opened by
151
+ // hand stays open: it is being read, and closing it would move the screen
152
+ const settle = (state: Step["state"], note?: string) =>
153
+ edit((item) => ({
154
+ ...item,
155
+ state,
156
+ note,
157
+ detail: undefined,
158
+ ended: Date.now(),
159
+ expanded: item.held,
160
+ }));
161
+
162
+ return {
163
+ detail: (message) => edit((item) => ({ ...item, detail: message })),
164
+ line: (message) =>
165
+ edit((item) => ({ ...item, lines: [...item.lines, message].slice(-KEPT) })),
166
+ done: (message) => settle("done", message),
167
+ fail: (message) => settle("failed", message),
168
+ };
169
+ };
170
+
171
+ return Object.assign(say, {
172
+ warn: say,
173
+ fail: say,
174
+ done: say,
175
+ step,
176
+ close,
177
+ });
178
+ }
179
+
180
+ // One line per step and whatever was said outside them, which is the record a
181
+ // person scrolls back to after the deploy is over
182
+ function summary(model: Model): string[] {
183
+ const rows = model.steps.map((step) => {
184
+ const glyph = step.state === "done" ? "✔" : step.state === "failed" ? "✘" : "·";
185
+ const said = step.note ? `: ${step.note}` : "";
186
+ const took = elapsed(step.ended ?? model.now, step.started);
187
+
188
+ return `${glyph} ${step.label}${said} (${took})`;
189
+ });
190
+
191
+ return [...rows, ...model.messages];
192
+ }
package/src/config.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { assertSteps } from "./pipeline.js";
2
+ import type { Deployment, Environment } from "./types.js";
3
+
4
+ // The one selected, from the one place worth looking. A deployment that carries
5
+ // its own overrides every file, which is what makes it an override rather than
6
+ // a default nobody can displace
7
+ export function environmentOf(config: Deployment, name: string) {
8
+ return config.environment ?? config.environments?.[name];
9
+ }
10
+
11
+ // Identity, but it pins the type so a missing health predicate fails to
12
+ // compile. Environments are excluded rather than merely unused: each lives in
13
+ // a redkite.<name>.config.ts of its own, and a second place to put one is a
14
+ // second place for them to disagree
15
+ export function defineDeployment<const T extends Deployment & { environments?: never }>(
16
+ config: T,
17
+ ): T {
18
+ assertUniqueNames(config);
19
+ assertOneSource(config);
20
+ assertRoutesResolvable(config);
21
+ assertDirsRelative(config);
22
+ assertSteps(config.steps ?? []);
23
+ return config;
24
+ }
25
+
26
+ // Identity, for the environment a redkite.<name>.config.ts holds. It pins the
27
+ // type the same way defineDeployment does, so a missing subnet fails to compile
28
+ export function defineEnvironment<const T extends Environment>(environment: T): T {
29
+ return environment;
30
+ }
31
+
32
+ // Cloned or already here, and the two are built differently enough that
33
+ // guessing between them is worse than being told
34
+ function assertOneSource(config: Deployment) {
35
+ for (const app of config.apps) {
36
+ if (app.repo && app.path) {
37
+ throw new Error(`${app.name} names both a repo and a path, and is built from one of them`);
38
+ }
39
+
40
+ if (!app.repo && !app.path) {
41
+ throw new Error(`${app.name} names no source, so give it a repo to clone or a path to build`);
42
+ }
43
+
44
+ // A clone is whatever the repository holds, so there is nothing here to
45
+ // narrow and an include would quietly do nothing
46
+ if (app.include && !app.path) {
47
+ throw new Error(`${app.name} says what to include, but is cloned rather than built from a path`);
48
+ }
49
+
50
+ if (app.include?.length === 0) {
51
+ throw new Error(`${app.name} includes nothing, so there would be no build context`);
52
+ }
53
+ }
54
+ }
55
+
56
+ // A dir is joined onto /app inside the image, so an absolute one would render
57
+ // a path with two slashes and a climbing one would leave the checkout
58
+ function assertDirsRelative(config: Deployment) {
59
+ for (const app of config.apps) {
60
+ const dir = app.dir;
61
+ if (dir === undefined) continue;
62
+
63
+ if (!dir || dir.startsWith("/") || dir.endsWith("/")) {
64
+ throw new Error(`dir for ${app.name} must be a path inside the repository`);
65
+ }
66
+
67
+ if (dir.split("/").includes("..")) {
68
+ throw new Error(`dir for ${app.name} must not climb out of the repository`);
69
+ }
70
+ }
71
+ }
72
+
73
+ function assertUniqueNames(config: Deployment) {
74
+ const names = [...config.apps, ...config.services].map((item) => item.name);
75
+ const duplicate = names.find((name, i) => names.indexOf(name) !== i);
76
+ if (duplicate) throw new Error(`Duplicate name in deployment: ${duplicate}`);
77
+ }
78
+
79
+ // Two apps on the same route means one of them is unreachable, and which one
80
+ // depends on nginx location precedence rather than on anything written here
81
+ function assertRoutesResolvable(config: Deployment) {
82
+ const routes = config.apps.map((app) => app.route);
83
+ const duplicate = routes.find((route, i) => routes.indexOf(route) !== i);
84
+ if (duplicate) throw new Error(`Two apps share the route ${duplicate}`);
85
+
86
+ for (const app of config.apps) {
87
+ if (app.route.startsWith("/")) continue;
88
+ throw new Error(`Route for ${app.name} must start with a slash`);
89
+ }
90
+ }
package/src/deploy.ts ADDED
@@ -0,0 +1,409 @@
1
+ import { build, type BuildContext, type BuildResult } from "./build.js";
2
+ import { assertCheckable, runChecks } from "./checks.js";
3
+ import { environmentOf } from "./config.js";
4
+ import { Docker } from "./docker.js";
5
+ import { healthcheck, type HealthDeps } from "./health.js";
6
+ import type { Host } from "./host.js";
7
+ import { localHost } from "./localHost.js";
8
+ import { silent, type Log } from "./log.js";
9
+ import {
10
+ defineStep,
11
+ merge,
12
+ runPipeline,
13
+ type AnyStep,
14
+ type Built,
15
+ type BuiltApp,
16
+ type Context,
17
+ type Finished,
18
+ type Plan,
19
+ type Prepared,
20
+ type Released,
21
+ type Run,
22
+ type Start,
23
+ } from "./pipeline.js";
24
+ import { readEnv, readRef, type SecretStores } from "./secrets/refs.js";
25
+ import { ensureService } from "./services/ensure.js";
26
+ import { plannedServices } from "./services/planned.js";
27
+ import { topologyFor, type AppTopology, type Topology } from "./topology.js";
28
+ import type { AppSpec, Deployment } from "./types.js";
29
+
30
+ // The runs redkite offers, and the steps it supplies to them. A deploy is
31
+ // blue-green: bring the host up, build everything, move addresses, check,
32
+ // revert or stop. A verify stops after the build and runs what the apps declare
33
+ // instead of swapping, which is the same host and the same images without the
34
+ // half of it that touches what is serving.
35
+ //
36
+ // Nothing here is privileged. Each of these is an ordinary step at an ordinary
37
+ // point, and a deployment that registers its own at the same point replaces it.
38
+
39
+ export type DeployOptions = {
40
+ config: Deployment;
41
+ environment: string;
42
+ // The machine the containers run on, and which now builds them too
43
+ host: Host;
44
+ // One store per provider named by a ref in the config
45
+ secrets: SecretStores;
46
+ // How the probe waits between attempts. Absent means a real wait, which is
47
+ // what everything but a test wants
48
+ health?: Omit<HealthDeps, "probe">;
49
+ log?: Log;
50
+ // Prints what each build wrote, line by line as it runs
51
+ verbose?: boolean;
52
+ // Stops the run. Whatever command is in flight is killed, and the pipeline
53
+ // unwinds through its own failure path rather than through an exit
54
+ signal?: AbortSignal;
55
+ };
56
+
57
+ export async function deploy(options: DeployOptions): Promise<Finished> {
58
+ return await start("deploy", options);
59
+ }
60
+
61
+ // The same host, the same services and the same images, stopping where a deploy
62
+ // would start moving addresses. What runs instead is what each app declares
63
+ export async function verify(options: DeployOptions): Promise<Finished> {
64
+ return await start("verify", options);
65
+ }
66
+
67
+ async function start(run: Run, options: DeployOptions): Promise<Finished> {
68
+ const log = options.log ?? silent;
69
+
70
+ const setting: Omit<Context, "task"> = {
71
+ config: options.config,
72
+ environment: options.environment,
73
+ topology: topologyFor(options.config, options.environment),
74
+ host: options.host,
75
+ docker: new Docker(options.host),
76
+ secrets: options.secrets,
77
+ log,
78
+ run,
79
+ };
80
+
81
+ const steps = merge(supplied(options), options.config.steps ?? []);
82
+ return await runPipeline(run, steps, setting, options.signal);
83
+ }
84
+
85
+ // Every step redkite supplies, in the order the phases name. A run walks the
86
+ // phases it has, so the ones it does not are never ordered in
87
+ function supplied(options: DeployOptions): AnyStep[] {
88
+ return [
89
+ defineStep("setup", prepare),
90
+ defineStep("build", (input, context) =>
91
+ compile(input, context, options.verbose ?? false, options.signal),
92
+ ),
93
+ defineStep("verify", runChecks, assertCheckable),
94
+ defineStep(
95
+ "swap",
96
+ (input, context) => release(input, context, options.health ?? { sleep: wait }),
97
+ assertPublishable,
98
+ ),
99
+ defineStep("cleanup", finish),
100
+ ];
101
+ }
102
+
103
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
104
+
105
+ // A deploy publishes the proxy on a port, and an environment written for verify
106
+ // alone has no reason to name one. Checked before the run so the mistake lands
107
+ // before a build rather than on a proxy nobody outside can reach
108
+ function assertPublishable(plan: Plan) {
109
+ if (environmentOf(plan.config, plan.environment)?.publicPort) return;
110
+
111
+ throw new Error(
112
+ `${plan.environment} names no publicPort, so a deploy has nothing to publish ` +
113
+ "the proxy on. Only a verify run goes without one",
114
+ );
115
+ }
116
+
117
+ // The network and the services, which outlive a run and are only built when
118
+ // they are missing. This used to overlap the builds, and now costs the round
119
+ // trips it takes rather than nothing, which is what buys a setup step a host
120
+ // it can rely on
121
+ async function prepare(input: Start, context: Context): Promise<Prepared> {
122
+ const { docker, topology } = context;
123
+
124
+ await docker.network.create(topology.network, topology.cidr);
125
+ const services = await ensureServices(context);
126
+
127
+ return { ...input, network: topology.network, services };
128
+ }
129
+
130
+ // Every image is built before anything is disturbed. A build failure has to
131
+ // leave the running deployment exactly as it was, which is why this is a step
132
+ // of its own rather than the first half of the swap
133
+ async function compile(
134
+ input: Prepared,
135
+ context: Context,
136
+ verbose: boolean,
137
+ signal?: AbortSignal,
138
+ ): Promise<Built> {
139
+ // One checkout directory for every app, opened here so the build step owns
140
+ // its lifetime rather than the process
141
+ const here = buildsHere(context) ? await localHost({ signal }) : undefined;
142
+
143
+ try {
144
+ return { ...input, apps: (await buildAll(context, verbose, here)).map(describe) };
145
+ } finally {
146
+ await here?.close?.();
147
+ }
148
+ }
149
+
150
+ // Building here and deploying here are the same daemon, so shipping would be a
151
+ // save and a load of an image that never moved. An app built from a path on
152
+ // this machine has no say in it: the source is here, so the build is too
153
+ function buildsHere(context: Context) {
154
+ const environment = environmentOf(context.config, context.environment);
155
+ if (!environment?.host?.bastion) return false;
156
+
157
+ return environment.buildOn === "local" || context.config.apps.some((app) => app.path);
158
+ }
159
+
160
+ async function release(
161
+ input: Built,
162
+ context: Context,
163
+ health: Omit<HealthDeps, "probe">,
164
+ ): Promise<Released> {
165
+ const { docker, task, topology } = context;
166
+ const apps = context.config.apps.map((app) => appOf(topology, app.name));
167
+
168
+ // Keyed by name rather than by index: appOf resolves the topology, and two
169
+ // lists walked in step is a bug waiting for someone to reorder one
170
+ const environments = new Map(
171
+ context.config.apps.map((app) => [app.name, app.environment ?? {}]),
172
+ );
173
+
174
+ task.detail("retiring the running containers");
175
+ await Promise.all(apps.map((app) => retire(docker, topology, app)));
176
+
177
+ task.detail("creating the new ones");
178
+
179
+ await Promise.all(
180
+ apps.map((app) =>
181
+ create(docker, topology, app, environments.get(app.name)),
182
+ ),
183
+ );
184
+
185
+ task.detail("starting them");
186
+ await Promise.all(apps.map((app) => docker.container.start(app.container)));
187
+
188
+ // One unhealthy app reverts all of them. A half-swapped deployment is the
189
+ // one state nothing downstream knows how to reason about
190
+ if (!(await checkAll(context, health))) {
191
+ context.log.fail("Health checks failed, reverting");
192
+ await Promise.all(apps.map((app) => revert(docker, topology, app)));
193
+
194
+ return {
195
+ ...input,
196
+ ok: false,
197
+ released: [],
198
+ reverted: apps.map((app) => app.container),
199
+ checked: [],
200
+ };
201
+ }
202
+
203
+ return {
204
+ ...input,
205
+ ok: true,
206
+ released: apps.map((app) => app.container),
207
+ reverted: [],
208
+ checked: [],
209
+ };
210
+ }
211
+
212
+ // A build leaves its image on the host rather than sending one, so without this
213
+ // every run adds a runtime image and a builder to a disk nobody is watching
214
+ async function finish(input: Released, context: Context): Promise<Finished> {
215
+ // What would be removed is what is serving, and the images are what a retry
216
+ // would be built from
217
+ if (!input.ok) return { ...input, removed: [], reclaimed: [] };
218
+
219
+ const { docker, topology } = context;
220
+ const apps = context.config.apps.map((app) => appOf(topology, app.name));
221
+
222
+ const removed = await Promise.all(apps.map((app) => cleanup(docker, app)));
223
+ const reclaimed = await Promise.all(input.apps.map((app) => reclaim(docker, app)));
224
+
225
+ return { ...input, removed: removed.flat(), reclaimed: reclaimed.flat() };
226
+ }
227
+
228
+ function describe({ app, result }: Built0): BuiltApp {
229
+ return {
230
+ name: app.name,
231
+ container: result.tag.split(":")[0] ?? app.name,
232
+ release: result.release,
233
+ fingerprint: result.fingerprint,
234
+ cached: result.cached,
235
+ builderTag: result.builderTag,
236
+ };
237
+ }
238
+
239
+ async function checkAll(context: Context, health: Omit<HealthDeps, "probe">) {
240
+ const { docker, log, topology } = context;
241
+
242
+ const results = await Promise.all(
243
+ context.config.apps.map(async (app) => {
244
+ const target = appOf(topology, app.name);
245
+ const deps: HealthDeps = {
246
+ ...health,
247
+ probe: async (container, url) => {
248
+ const result = await docker.run(`exec ${container} curl -s ${url}`);
249
+ return { code: result.code, output: result.stdout };
250
+ },
251
+ log,
252
+ };
253
+
254
+ return await healthcheck(target.container, target.port, app.health, deps);
255
+ }),
256
+ );
257
+
258
+ return !results.includes(false);
259
+ }
260
+
261
+ // The proxy is one of these, derived rather than listed. What each should be
262
+ // is worked out in one place, so a plan reports drift against the same shape a
263
+ // deploy converges to
264
+ async function ensureServices(context: Context) {
265
+ const planned = plannedServices(context.config, context.topology, context.run);
266
+
267
+ return await Promise.all(
268
+ planned.map(async (item) => {
269
+ await ensureService(item.spec, item.service, {
270
+ ...context,
271
+ files: item.files,
272
+ publish: item.publish,
273
+ });
274
+
275
+ return item.service.container;
276
+ }),
277
+ );
278
+ }
279
+
280
+ type Built0 = { app: AppSpec; result: BuildResult };
281
+
282
+ async function buildAll(
283
+ context: Context,
284
+ verbose: boolean,
285
+ here?: Host,
286
+ ): Promise<Built0[]> {
287
+ const { config, log, topology } = context;
288
+ const environment = environmentOf(config, context.environment);
289
+
290
+ if (!environment) throw new Error(`Unknown environment ${context.environment}`);
291
+
292
+ return await Promise.all(
293
+ config.apps.map(async (app) => {
294
+ const task = log.step(`Building ${app.name}`);
295
+
296
+ try {
297
+ const buildContext: BuildContext = {
298
+ host: here ?? context.host,
299
+ docker: here ? new Docker(here) : context.docker,
300
+ deliver: here && { host: context.host, docker: context.docker },
301
+ env: await readEnv(app.secrets, context.secrets),
302
+ files: await resolveFiles(app, context.secrets),
303
+ branch: environment.branch,
304
+ environment: context.environment,
305
+ detail: task.detail,
306
+ output: task.line,
307
+ };
308
+
309
+ const result = await build(app, appOf(topology, app.name), buildContext);
310
+ task.done(`${result.release.slice(0, 7)}${result.cached ? " (held)" : ""}`);
311
+
312
+ return { app, result };
313
+ } catch (error) {
314
+ task.fail(`${app.name} failed to build`);
315
+ throw error;
316
+ }
317
+ }),
318
+ );
319
+ }
320
+
321
+ async function resolveFiles(app: AppSpec, stores: SecretStores) {
322
+ const entries = await Promise.all(
323
+ Object.entries(app.files ?? {}).map(
324
+ async ([path, ref]) => [path, await readRef(ref, stores)] as const,
325
+ ),
326
+ );
327
+
328
+ return Object.fromEntries(entries);
329
+ }
330
+
331
+ // Move the running container out of the way without stopping it, so it keeps
332
+ // answering on the retired address while the new one starts
333
+ async function retire(docker: Docker, topology: Topology, app: AppTopology) {
334
+ await docker.container.stop(app.retired);
335
+ await docker.container.remove(app.retired);
336
+ await docker.network.reconnect(topology.network, app.container, app.retiredAddress);
337
+ await docker.container.rename(app.container, app.retired);
338
+ }
339
+
340
+ async function create(
341
+ docker: Docker,
342
+ topology: Topology,
343
+ app: AppTopology,
344
+ environment: Record<string, string> = {},
345
+ ) {
346
+ const builder = docker.container
347
+ .builder()
348
+ .name(app.container)
349
+ .image(app.container)
350
+ .network(topology.network)
351
+ .ip(app.currentAddress)
352
+ .restart("unless-stopped");
353
+
354
+ for (const [host, ip] of Object.entries(topology.extraHosts)) {
355
+ // An app resolving its own name to the live address would loop
356
+ if (host === app.container || host === app.retired) continue;
357
+ builder.extraHost(host, ip);
358
+ }
359
+
360
+ for (const volume of app.volumes) builder.volume(volume.volume, volume.mountPath);
361
+ for (const [name, value] of Object.entries(environment)) builder.env(name, value);
362
+
363
+ await builder.create();
364
+ }
365
+
366
+ // Put the retired container back on the live address and its original name
367
+ async function revert(docker: Docker, topology: Topology, app: AppTopology) {
368
+ await docker.container.stop(app.container);
369
+ await docker.container.rename(app.container, app.failed);
370
+ await docker.network.reconnect(topology.network, app.retired, app.currentAddress);
371
+ await docker.container.rename(app.retired, app.container);
372
+ await docker.container.start(app.container);
373
+ }
374
+
375
+ async function cleanup(docker: Docker, app: AppTopology) {
376
+ const removed: string[] = [];
377
+
378
+ for (const name of [app.retired, app.failed]) {
379
+ await docker.container.stop(name);
380
+ if (await docker.container.remove(name)) removed.push(name);
381
+ }
382
+
383
+ return removed;
384
+ }
385
+
386
+ // Every version of this app's images except the one that was just released,
387
+ // including the builder a step before the swap ran in
388
+ async function reclaim(docker: Docker, app: BuiltApp) {
389
+ const version = `${app.release}-${app.fingerprint}`;
390
+ const reclaimed: string[] = [];
391
+
392
+ for (const repository of [app.container, `${app.container}-builder`]) {
393
+ for (const held of await docker.image.versionsOf(repository)) {
394
+ if (held === `${repository}:${version}`) continue;
395
+
396
+ await docker.image.remove(held);
397
+ reclaimed.push(held);
398
+ }
399
+ }
400
+
401
+ return reclaimed;
402
+ }
403
+
404
+ function appOf(topology: Topology, name: string) {
405
+ const app = topology.apps.find((item) => item.name === name);
406
+ if (app) return app;
407
+
408
+ throw new Error(`No topology for app ${name}`);
409
+ }