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,559 @@
1
+ import { environmentOf } from "../config.js";
2
+ import { deploy, verify } from "../deploy.js";
3
+ import { Docker } from "../docker.js";
4
+ import type { Host } from "../host.js";
5
+ import { localHost } from "../localHost.js";
6
+ import { silent, type Log } from "../log.js";
7
+ import { renderNginx } from "../nginx.js";
8
+ import { addressOf, RUNS, SLOTS, type Run } from "../pipeline.js";
9
+ import { listRefs, type SecretStores } from "../secrets/refs.js";
10
+ import { bitwardenStore } from "../secrets/store.js";
11
+ import { driftOf, plannedServices, type Drift } from "../services/planned.js";
12
+ import { sshHost } from "../sshHost.js";
13
+ import { topologyFor, type Topology } from "../topology.js";
14
+ import type { Deployment, DeployHost } from "../types.js";
15
+
16
+ import { requireAgent } from "./agent.js";
17
+ import { loadConfig } from "./config.js";
18
+ import { createLog, describeFailure } from "./log.js";
19
+
20
+ // One command that reads the config and does everything under it: the agent,
21
+ // the vault, the checkout, the build, the swap and the cleanup.
22
+
23
+ const USAGE = `redkite <command> [environment]
24
+
25
+ plan [environment] Print the derived topology, the pipeline and the nginx
26
+ deploy [environment] Build, swap, health check, and revert on failure
27
+ verify [environment] Bring the services up, build, and run each app's checks
28
+
29
+ --config <path> Defaults to redkite.config.ts at the root of the project
30
+ --local Build the images here and ship them to the host
31
+ --full No step view: every line of every step, in full
32
+ --verbose Every host command, and every line a build printed
33
+ --version Print the version and exit
34
+
35
+ Environment defaults to staging. A deployment is one redkite.config.ts at the root
36
+ of the project, and everything below it is derived.
37
+ `;
38
+
39
+ // Wraps a host rather than living inside one, so both implementations are
40
+ // measured the same way and neither knows it is being timed
41
+ function measured(host: Host, log?: Log) {
42
+ const totals = { commands: 0, commandMs: 0, files: 0 };
43
+
44
+ const wrapped: Host = {
45
+ directory: host.directory,
46
+ cache: host.cache,
47
+ pipe: host.pipe.bind(host),
48
+ stop: host.stop.bind(host),
49
+
50
+ sh: async (command, onLine) => {
51
+ const started = Date.now();
52
+ const result = await host.sh(command, onLine);
53
+
54
+ totals.commands += 1;
55
+ totals.commandMs += Date.now() - started;
56
+
57
+ // The exit code matters: several of these are allowed to fail, and a
58
+ // deploy reading verbose output is one where somebody wants to know which
59
+ log?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
60
+ return result;
61
+ },
62
+
63
+ write: async (name, contents) => {
64
+ totals.files += 1;
65
+ return await host.write(name, contents);
66
+ },
67
+
68
+ close: host.close?.bind(host),
69
+ };
70
+
71
+ const summary = () =>
72
+ `${totals.commands} commands in ${seconds(totals.commandMs)}, ` +
73
+ `${totals.files} files written`;
74
+
75
+ return { host: wrapped, summary };
76
+ }
77
+
78
+ function seconds(ms: number) {
79
+ return `${(ms / 1000).toFixed(1)}s`;
80
+ }
81
+
82
+ // Read rather than generated into the build, so a published package and a
83
+ // linked checkout answer with the same thing
84
+ async function version() {
85
+ const manifest = new URL("../../package.json", import.meta.url);
86
+ const { readFile } = await import("node:fs/promises");
87
+ const { version: found } = JSON.parse(await readFile(manifest, "utf8")) as {
88
+ version: string;
89
+ };
90
+
91
+ return found;
92
+ }
93
+
94
+ // Flags that take a value, so the value is not mistaken for the environment
95
+ const VALUED = new Set(["--config"]);
96
+
97
+ function flag(argv: string[], name: string) {
98
+ const joined = argv.find((arg) => arg.startsWith(`${name}=`));
99
+ if (joined) return joined.slice(name.length + 1);
100
+
101
+ const at = argv.indexOf(name);
102
+ return at === -1 ? undefined : argv[at + 1];
103
+ }
104
+
105
+ export function positional(argv: string[]) {
106
+ const args: string[] = [];
107
+
108
+ for (let index = 0; index < argv.length; index += 1) {
109
+ const arg = argv[index]!;
110
+
111
+ if (!arg.startsWith("--")) {
112
+ args.push(arg);
113
+ continue;
114
+ }
115
+
116
+ // Only the two-word form consumes the next item. --flag=value does not
117
+ if (VALUED.has(arg)) index += 1;
118
+ }
119
+
120
+ return args;
121
+ }
122
+
123
+ export async function main(argv: string[]) {
124
+ try {
125
+ return await dispatch(argv);
126
+ } catch (error) {
127
+ // A deploy reports its own failure through the live view. Everything before
128
+ // one has started, a config that will not load above all, lands here
129
+ process.stderr.write(`${describeFailure(error)}\n`);
130
+ process.exitCode = 1;
131
+ }
132
+ }
133
+
134
+ async function dispatch(argv: string[]) {
135
+ const args = positional(argv);
136
+ const command = args[0] ?? "";
137
+ const environment = args[1] ?? "staging";
138
+
139
+ if (argv.includes("--version") || command === "version") {
140
+ return process.stdout.write(`${await version()}\n`);
141
+ }
142
+
143
+ const configPath = flag(argv, "--config");
144
+
145
+ if (command === "plan") return await plan(environment, configPath);
146
+ if (command === "deploy" || command === "verify") {
147
+ return await run(command, environment, configPath, {
148
+ verbose: argv.includes("--verbose"),
149
+ full: argv.includes("--full"),
150
+ local: argv.includes("--local"),
151
+ });
152
+ }
153
+
154
+ process.stdout.write(USAGE);
155
+ process.exitCode = command ? 1 : 0;
156
+ }
157
+
158
+ async function plan(environment: string, configPath?: string) {
159
+ const config = await loadConfig(configPath);
160
+ const topology = topologyFor(config, environment);
161
+
162
+ const say = (message = "") => process.stdout.write(`${message}\n`);
163
+
164
+ say(`# ${config.project} · ${environment}\n`);
165
+ say(`network ${topology.network} ${topology.cidr}`);
166
+ say(`branch ${topology.branch}`);
167
+ // A verify environment publishes nothing, because nothing in one serves
168
+ say(
169
+ topology.publicPort
170
+ ? `published ${topology.publicPort} -> ${topology.router.container} ${topology.router.address}\n`
171
+ : "published nothing, this environment has no publicPort\n",
172
+ );
173
+
174
+ const sources = new Map(config.apps.map((app) => [app.name, app.path ?? app.repo]));
175
+
176
+ for (const app of topology.apps) {
177
+ say(`app ${app.name}`);
178
+ say(` source ${sources.get(app.name) ?? "?"}`);
179
+ say(` current ${app.container} ${app.currentAddress}:${app.port}`);
180
+ say(` retired ${app.retired} ${app.retiredAddress}`);
181
+ say(` route ${app.route}`);
182
+
183
+ for (const volume of app.volumes) {
184
+ say(` volume ${volume.volume} -> ${volume.mountPath}`);
185
+ }
186
+
187
+ for (const [name, key] of Object.entries(app.caches)) {
188
+ say(` cache ${name} -> ${key}`);
189
+ }
190
+
191
+ say();
192
+ }
193
+
194
+ for (const service of topology.services) {
195
+ say(`service ${service.name} ${service.container} ${service.address}`);
196
+
197
+ for (const volume of service.volumes) {
198
+ say(` volume ${volume.volume} -> ${volume.mountPath}`);
199
+ }
200
+ }
201
+
202
+ // An environment that publishes nothing cannot serve, so verify is the only
203
+ // run it supports. Nothing declares that: the missing port is what says it
204
+ const serves = Boolean(topology.publicPort);
205
+
206
+ sayPipeline(config, serves, say);
207
+ await sayDrift(config, topology, serves ? "deploy" : "verify", say);
208
+
209
+ if (!serves) return;
210
+
211
+ say(`\n# rendered nginx ${"-".repeat(44)}\n`);
212
+ say(renderNginx(topology, config.maxBodySize));
213
+ }
214
+
215
+ // What is running against what this file says should be. Services outlive a
216
+ // deploy, so this is the only part of a plan that cannot be answered from the
217
+ // config alone, and the only part that needs the host
218
+ async function sayDrift(
219
+ config: Deployment,
220
+ topology: Topology,
221
+ run: Run,
222
+ say: (message?: string) => void,
223
+ ) {
224
+ say(`\nservices on the host (${run})`);
225
+
226
+ const bastion = environmentOf(config, topology.environment)?.host;
227
+ let host: Host | undefined;
228
+
229
+ try {
230
+ host = await hostFor(bastion, silent);
231
+ const planned = plannedServices(config, topology, run);
232
+ const drifted = await driftOf(planned, topology, new Docker(host));
233
+
234
+ if (drifted.length === 0) return say(" every service is what this file says");
235
+ for (const drift of drifted) say(` ${drift.container.padEnd(34)}${DRIFT[drift.reason]}`);
236
+ say(`\n a ${run} converges these`);
237
+ } catch (error) {
238
+ // A plan is worth printing without a host. Saying so beats printing
239
+ // nothing, and beats printing a clean bill of health nobody checked
240
+ say(` not checked: ${describeFailure(error).split("\n")[0]}`);
241
+ } finally {
242
+ await host?.close?.();
243
+ }
244
+ }
245
+
246
+ const DRIFT: Record<Drift["reason"], string> = {
247
+ missing: "not there, will be created",
248
+ stopped: "created from this file, but not running",
249
+ changed: "created from an earlier version of this file",
250
+ unrecognised: "not created by redkite, will be recreated",
251
+ };
252
+
253
+ // What each run will actually do, in the order it will do it. A step is
254
+ // addressed rather than called, so this is the only place a sequence is visible
255
+ function sayPipeline(
256
+ config: Deployment,
257
+ serves: boolean,
258
+ say: (message?: string) => void,
259
+ ) {
260
+ const steps = config.steps ?? [];
261
+ const added = new Set(steps.map((step) => step.point));
262
+
263
+ const at = (phase: string, slot: string) =>
264
+ steps.filter((step) => {
265
+ const address = addressOf(step.point);
266
+ return address.phase === phase && address.slot === slot && step.point !== phase;
267
+ });
268
+
269
+ const checked = config.apps.filter((app) => app.verify).map((app) => app.name);
270
+
271
+ // Both refusals live on a step's check, so a run printed here is one this
272
+ // environment can actually be asked for
273
+ const runs = Object.entries(RUNS).filter(
274
+ ([run]) => (run === "verify" ? checked.length > 0 : serves),
275
+ );
276
+
277
+ if (runs.length === 0) {
278
+ return say(
279
+ "\nno pipeline. This environment publishes nothing, so it cannot deploy, " +
280
+ "and no app declares verify",
281
+ );
282
+ }
283
+
284
+ for (const [run, phases] of runs) {
285
+ say(`\npipeline (${run})`);
286
+
287
+ for (const phase of phases) {
288
+ for (const slot of SLOTS) {
289
+ // Redkite's own step leads its phase's slot. A deployment that puts one
290
+ // at the same point replaces it, and this is where that shows
291
+ if (slot === "main") {
292
+ const who = added.has(phase) ? "replaced" : "redkite";
293
+ const what = phase === "verify" ? ` ${checked.join(", ")}` : "";
294
+ say(` ${phase.padEnd(26)}${who}${what}`);
295
+ }
296
+
297
+ for (const step of at(phase, slot)) say(` ${step.point}`);
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ // Five presses is a build that has ignored SIGKILL, which is a process nothing
304
+ // here can end. The way out is offered rather than taken, because taking it is
305
+ // the one thing that leaves work running with nothing watching it
306
+ const ASK_AT = 5;
307
+
308
+ // The first ask is polite, every one after it is not. What a press does is
309
+ // choose the signal the wait keeps sending, and only the last one leaves
310
+ export function stopper(on: {
311
+ abort: () => void;
312
+ say: (message: string) => void;
313
+ signal: (name: "TERM" | "KILL") => void;
314
+ leave: () => void;
315
+ }) {
316
+ let presses = 0;
317
+
318
+ return () => {
319
+ presses += 1;
320
+
321
+ if (presses === 1) {
322
+ on.say("Stopping the build (SIGTERM). Press again to kill it");
323
+ on.abort();
324
+ return on.signal("TERM");
325
+ }
326
+
327
+ if (presses < ASK_AT) {
328
+ on.say("Killing the build (SIGKILL). Nothing exits until it is gone");
329
+ return on.signal("KILL");
330
+ }
331
+
332
+ if (presses === ASK_AT) {
333
+ for (const line of REFUSING) on.say(line);
334
+ return on.signal("KILL");
335
+ }
336
+
337
+ on.leave();
338
+ };
339
+ }
340
+
341
+ // What leaving costs, said before it is offered rather than after it is done
342
+ const REFUSING = [
343
+ "This is not stopping. It has had SIGKILL and is still there.",
344
+ "Press again to leave redkite. That does not stop it: the build keeps running",
345
+ "on the host, may finish and tag an image no deploy is waiting for, and holds",
346
+ "the CPU and disk it is using. Nothing will clean up after it but you.",
347
+ ];
348
+
349
+ // A stopped deploy is not over until what it started is gone. The host is asked
350
+ // again and again because the answer is a count, and zero is the only one that
351
+ // ends this
352
+ async function gone(
353
+ host: Host,
354
+ hardest: () => "TERM" | "KILL",
355
+ say: (message: string) => void,
356
+ ) {
357
+ let said = 0;
358
+
359
+ for (let left = await host.stop(hardest()); left > 0; left = await host.stop(hardest())) {
360
+ if (left !== said) {
361
+ say(`Waiting for ${left} still running`);
362
+ said = left;
363
+ }
364
+
365
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
366
+ }
367
+ }
368
+
369
+ const POLL_MS = 400;
370
+
371
+ // --local says it for this run alone, without the config having to change. It
372
+ // lands wherever the environment came from, so an inline one moves too
373
+ export function buildingHere(config: Deployment, environment: string): Deployment {
374
+ if (config.environment) {
375
+ return { ...config, environment: { ...config.environment, buildOn: "local" } };
376
+ }
377
+
378
+ const declared = config.environments?.[environment];
379
+ if (!declared) return config;
380
+
381
+ return {
382
+ ...config,
383
+ environments: {
384
+ ...config.environments,
385
+ [environment]: { ...declared, buildOn: "local" },
386
+ },
387
+ };
388
+ }
389
+
390
+ // Without a bastion the containers are on this machine, and every command the
391
+ // deploy issues is one this process can run itself
392
+ async function hostFor(host: DeployHost | undefined, log: Log, signal?: AbortSignal) {
393
+ if (!host?.bastion) return await localHost({ signal });
394
+
395
+ const task = log.step(`Opening a connection to ${host.bastion}`);
396
+
397
+ try {
398
+ const opened = await sshHost(host.bastion, { signal });
399
+ task.done();
400
+ return opened;
401
+ } catch (error) {
402
+ task.fail(`Could not reach ${host.bastion}`);
403
+ throw error;
404
+ }
405
+ }
406
+
407
+ // Only the stores the config actually names. A deployment that keeps its
408
+ // environment somewhere else, or nowhere, needs no credentials to deploy
409
+ async function openStores(config: Deployment, log: Log): Promise<SecretStores> {
410
+ // Services name refs too, and a deployment whose only secret is a database
411
+ // password opened no store at all until this counted them
412
+ const refs = [
413
+ ...config.apps.flatMap((app) => [
414
+ ...listRefs(app.secrets),
415
+ ...Object.values(app.files ?? {}),
416
+ ]),
417
+ ...config.services.flatMap((service) => listRefs(service.secrets)),
418
+ ];
419
+
420
+ const providers = new Set(refs.map((ref) => ref.provider));
421
+
422
+ if (!providers.has("bitwarden")) return {};
423
+
424
+ const vault = log.step("Reading the vault");
425
+
426
+ try {
427
+ const bitwarden = await bitwardenStore({
428
+ detail: vault.detail,
429
+ clientId: required("BW_CLIENT_ID"),
430
+ clientSecret: required("BW_CLIENT_SECRET"),
431
+ password: required("BW_PASSWORD"),
432
+ });
433
+
434
+ vault.done();
435
+ return { bitwarden };
436
+ } catch (error) {
437
+ vault.fail("Could not read the vault");
438
+ throw error;
439
+ }
440
+ }
441
+
442
+ function required(name: string) {
443
+ const value = process.env[name];
444
+ if (value) return value;
445
+
446
+ throw new Error(
447
+ `${name} is not set, and this deployment reads its environment from Bitwarden`,
448
+ );
449
+ }
450
+
451
+ type RunOptions = { verbose: boolean; full: boolean; local: boolean };
452
+
453
+ async function run(
454
+ kind: Run,
455
+ environment: string,
456
+ configPath: string | undefined,
457
+ options: RunOptions,
458
+ ) {
459
+ const config = await loadConfig(configPath);
460
+
461
+ // Fail on a missing environment before opening anything for it
462
+ const topology = topologyFor(config, environment);
463
+ const deployHost = environmentOf(config, environment)?.host;
464
+
465
+ // The host clones the repositories over this, so it has to exist before the
466
+ // connection that forwards it is opened. Before the view too: ssh-add asks
467
+ // for a passphrase on the terminal, and by then the view owns it
468
+ requireAgent((message) => process.stderr.write(`${message}\n`));
469
+
470
+ // Whatever is in flight is killed, the pipeline unwinds through its own
471
+ // failure path, and the finally below removes the scratch directory
472
+ const stopping = new AbortController();
473
+
474
+ const log = createLog({
475
+ ...options,
476
+ onQuit: () => stop(),
477
+ });
478
+
479
+ // Read by the wait below, so a press during it hardens what that is sending
480
+ let hardest: "TERM" | "KILL" = "TERM";
481
+
482
+ const stop = stopper({
483
+ say: log.warn,
484
+ abort: () => stopping.abort(),
485
+ signal: (name) => {
486
+ hardest = name;
487
+ // Nothing waits on this: the wait is in the finally, and this is what
488
+ // makes the command in flight answer so the run can get there
489
+ void host?.stop(name);
490
+ },
491
+
492
+ leave: () => {
493
+ log.warn("Leaving. What is still running on the host is yours to stop");
494
+ log.close();
495
+ process.exit(130);
496
+ },
497
+ });
498
+
499
+ // The viewer reads ctrl+c as a key, because it holds the terminal in raw
500
+ // mode. Everything else arrives here as a signal
501
+ process.on("SIGINT", stop);
502
+
503
+ let host: Host | undefined;
504
+
505
+ try {
506
+ const meter = measured(
507
+ await hostFor(deployHost, log, stopping.signal),
508
+ options.verbose ? log : undefined,
509
+ );
510
+
511
+ host = meter.host;
512
+
513
+ const start = kind === "verify" ? verify : deploy;
514
+
515
+ const result = await start({
516
+ config: options.local ? buildingHere(config, environment) : config,
517
+ environment,
518
+ host,
519
+ secrets: await openStores(config, log),
520
+ verbose: options.verbose,
521
+ signal: stopping.signal,
522
+ log,
523
+ });
524
+
525
+ log(`Host: ${meter.summary()}`);
526
+
527
+ if (kind === "verify") {
528
+ log.done(`Checked ${result.checked.join(", ")} in ${topology.environment}`);
529
+ return;
530
+ }
531
+
532
+ if (result.ok) {
533
+ log.done(`Deployed ${result.released.join(", ")} to ${topology.environment}`);
534
+ return;
535
+ }
536
+
537
+ log.fail(`Reverted ${result.reverted.join(", ")}`);
538
+ process.exitCode = 1;
539
+ } catch (error) {
540
+ // Whatever the command in flight said about being killed is noise: what
541
+ // happened is that somebody asked for it to stop
542
+ if (stopping.signal.aborted) {
543
+ log.fail("Stopped");
544
+ process.exitCode = 130;
545
+ } else {
546
+ log.fail(describeFailure(error));
547
+ process.exitCode = 1;
548
+ }
549
+ } finally {
550
+ // Nothing leaves while the build is still running. The signal a press
551
+ // chose is resent every time round, so pressing again hardens it
552
+ if (stopping.signal.aborted && host) await gone(host, () => hardest, log.warn);
553
+
554
+ await host?.close?.();
555
+ // Leaves the alternate screen and writes the run out on the screen the
556
+ // person keeps, which is also what lets the process exit
557
+ log.close();
558
+ }
559
+ }