redkite 0.1.7 → 0.1.8

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 (109) hide show
  1. package/README.md +313 -25
  2. package/dist/checks.d.ts.map +1 -1
  3. package/dist/checks.js +4 -0
  4. package/dist/checks.js.map +1 -1
  5. package/dist/cli/agent.d.ts +2 -0
  6. package/dist/cli/agent.d.ts.map +1 -1
  7. package/dist/cli/agent.js +23 -1
  8. package/dist/cli/agent.js.map +1 -1
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +98 -30
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +16 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/deploy.d.ts +4 -1
  16. package/dist/deploy.d.ts.map +1 -1
  17. package/dist/deploy.js +77 -23
  18. package/dist/deploy.js.map +1 -1
  19. package/dist/dockerfile.js +6 -7
  20. package/dist/dockerfile.js.map +1 -1
  21. package/dist/environment.d.ts +5 -0
  22. package/dist/environment.d.ts.map +1 -0
  23. package/dist/environment.js +20 -0
  24. package/dist/environment.js.map +1 -0
  25. package/dist/host.d.ts +2 -0
  26. package/dist/host.d.ts.map +1 -1
  27. package/dist/host.js +6 -0
  28. package/dist/host.js.map +1 -1
  29. package/dist/index.d.ts +4 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +4 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/localHost.d.ts.map +1 -1
  34. package/dist/localHost.js +2 -0
  35. package/dist/localHost.js.map +1 -1
  36. package/dist/plugin.d.ts +15 -0
  37. package/dist/plugin.d.ts.map +1 -0
  38. package/dist/plugin.js +21 -0
  39. package/dist/plugin.js.map +1 -0
  40. package/dist/plugins/bitwarden.d.ts +11 -0
  41. package/dist/plugins/bitwarden.d.ts.map +1 -0
  42. package/dist/plugins/bitwarden.js +40 -0
  43. package/dist/plugins/bitwarden.js.map +1 -0
  44. package/dist/plugins/digitalOcean.d.ts +19 -0
  45. package/dist/plugins/digitalOcean.d.ts.map +1 -0
  46. package/dist/plugins/digitalOcean.js +71 -0
  47. package/dist/plugins/digitalOcean.js.map +1 -0
  48. package/dist/plugins/index.d.ts +5 -0
  49. package/dist/plugins/index.d.ts.map +1 -0
  50. package/dist/plugins/index.js +5 -0
  51. package/dist/plugins/index.js.map +1 -0
  52. package/dist/plugins/rds.d.ts +14 -0
  53. package/dist/plugins/rds.d.ts.map +1 -0
  54. package/dist/plugins/rds.js +86 -0
  55. package/dist/plugins/rds.js.map +1 -0
  56. package/dist/plugins/snapshot.d.ts +7 -0
  57. package/dist/plugins/snapshot.d.ts.map +1 -0
  58. package/dist/plugins/snapshot.js +29 -0
  59. package/dist/plugins/snapshot.js.map +1 -0
  60. package/dist/presets/nodeApp.d.ts +1 -2
  61. package/dist/presets/nodeApp.d.ts.map +1 -1
  62. package/dist/presets/nodeApp.js +0 -1
  63. package/dist/presets/nodeApp.js.map +1 -1
  64. package/dist/recover.d.ts +19 -0
  65. package/dist/recover.d.ts.map +1 -0
  66. package/dist/recover.js +58 -0
  67. package/dist/recover.js.map +1 -0
  68. package/dist/secrets/store.d.ts +5 -2
  69. package/dist/secrets/store.d.ts.map +1 -1
  70. package/dist/secrets/store.js +25 -14
  71. package/dist/secrets/store.js.map +1 -1
  72. package/dist/sshHost.d.ts +2 -0
  73. package/dist/sshHost.d.ts.map +1 -1
  74. package/dist/sshHost.js +29 -11
  75. package/dist/sshHost.js.map +1 -1
  76. package/dist/steps.d.ts +0 -7
  77. package/dist/steps.d.ts.map +1 -1
  78. package/dist/steps.js +6 -3
  79. package/dist/steps.js.map +1 -1
  80. package/dist/types.d.ts +4 -5
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +1 -1
  83. package/src/checks.ts +4 -0
  84. package/src/cli/agent.ts +28 -1
  85. package/src/cli/index.ts +118 -29
  86. package/src/config.ts +19 -1
  87. package/src/deploy.ts +101 -29
  88. package/src/dockerfile.ts +7 -11
  89. package/src/environment.ts +24 -0
  90. package/src/host.ts +11 -0
  91. package/src/index.ts +4 -1
  92. package/src/localHost.ts +3 -0
  93. package/src/plugin.ts +50 -0
  94. package/src/plugins/bitwarden.ts +57 -0
  95. package/src/plugins/digitalOcean.ts +111 -0
  96. package/src/plugins/index.ts +4 -0
  97. package/src/plugins/rds.ts +126 -0
  98. package/src/plugins/snapshot.ts +44 -0
  99. package/src/presets/nodeApp.ts +1 -3
  100. package/src/recover.ts +90 -0
  101. package/src/secrets/store.ts +39 -20
  102. package/src/sshHost.ts +35 -11
  103. package/src/steps.ts +7 -6
  104. package/src/types.ts +22 -12
  105. package/dist/secrets/bitwarden.d.ts +0 -3
  106. package/dist/secrets/bitwarden.d.ts.map +0 -1
  107. package/dist/secrets/bitwarden.js +0 -6
  108. package/dist/secrets/bitwarden.js.map +0 -1
  109. package/src/secrets/bitwarden.ts +0 -7
package/src/cli/index.ts CHANGED
@@ -7,13 +7,14 @@ import { silent, type Log } from "../log.js";
7
7
  import { renderNginx } from "../nginx.js";
8
8
  import { addressOf, RUNS, SLOTS, type Run } from "../pipeline.js";
9
9
  import { listRefs, type SecretStores } from "../secrets/refs.js";
10
- import { bitwardenStore } from "../secrets/store.js";
10
+ import { pluginSteps, storeFor } from "../plugin.js";
11
+ import { down, rollback } from "../recover.js";
11
12
  import { driftOf, plannedServices, type Drift } from "../services/planned.js";
12
13
  import { sshHost } from "../sshHost.js";
13
14
  import { topologyFor, type Topology } from "../topology.js";
14
15
  import type { Deployment, DeployHost } from "../types.js";
15
16
 
16
- import { requireAgent } from "./agent.js";
17
+ import { needsAgent, requireAgent } from "./agent.js";
17
18
  import { loadConfig } from "./config.js";
18
19
  import { createLog, describeFailure } from "./log.js";
19
20
 
@@ -25,6 +26,8 @@ const USAGE = `redkite <command> [environment]
25
26
  plan [environment] Print the derived topology, the pipeline and the nginx
26
27
  deploy [environment] Build, swap, health check, and revert on failure
27
28
  verify [environment] Bring the services up, build, and run each app's checks
29
+ rollback [environment] Put back any app a run moved and did not finish
30
+ down [environment] Stop everything this environment named
28
31
 
29
32
  --config <path> Defaults to redkite.config.ts at the root of the project
30
33
  --local Build the images here and ship them to the host
@@ -46,6 +49,7 @@ function measured(host: Host, log?: Log) {
46
49
  cache: host.cache,
47
50
  pipe: host.pipe.bind(host),
48
51
  stop: host.stop.bind(host),
52
+ final: host.final.bind(host),
49
53
 
50
54
  sh: async (command, onLine) => {
51
55
  const started = Date.now();
@@ -143,6 +147,9 @@ async function dispatch(argv: string[]) {
143
147
  const configPath = flag(argv, "--config");
144
148
 
145
149
  if (command === "plan") return await plan(environment, configPath);
150
+ if (command === "rollback" || command === "down") {
151
+ return await recover(command, environment, configPath);
152
+ }
146
153
  if (command === "deploy" || command === "verify") {
147
154
  return await run(command, environment, configPath, {
148
155
  verbose: argv.includes("--verbose"),
@@ -155,6 +162,49 @@ async function dispatch(argv: string[]) {
155
162
  process.exitCode = command ? 1 : 0;
156
163
  }
157
164
 
165
+ // Neither runs a pipeline. Both exist for the run that was killed rather than
166
+ // asked to stop, so they read what the host is in rather than what a deploy
167
+ // remembered, and both are safe where there is nothing to do
168
+ async function recover(
169
+ command: "rollback" | "down",
170
+ environment: string,
171
+ configPath?: string,
172
+ ) {
173
+ const config = await loadConfig(configPath);
174
+ const topology = topologyFor(config, environment);
175
+ const deployHost = environmentOf(config, environment)?.host;
176
+
177
+ const say = (message = "") => process.stdout.write(`${message}\n`);
178
+ if (needsAgent(config, environment)) requireAgent(say);
179
+
180
+ const host = await hostFor(deployHost, silent);
181
+
182
+ try {
183
+ if (command === "down") {
184
+ const { stopped } = await down({ config, environment, host, log: asLog(say) });
185
+
186
+ say(stopped.length === 0 ? "Nothing was running" : `Stopped ${stopped.join(", ")}`);
187
+ return;
188
+ }
189
+
190
+ const { restored } = await rollback({ config, environment, host, log: asLog(say) });
191
+
192
+ if (restored.length === 0) {
193
+ return say(`Nothing to put back in ${topology.environment}`);
194
+ }
195
+
196
+ say(`Put back ${restored.join(", ")}`);
197
+ } finally {
198
+ await host.close?.();
199
+ }
200
+ }
201
+
202
+ // These print rather than draw: a recovery runs after the view has gone, and
203
+ // often in a job that is already being torn down
204
+ function asLog(say: (message: string) => void): Log {
205
+ return Object.assign(say, { warn: say, fail: say, done: say, step: () => silent.step("") });
206
+ }
207
+
158
208
  async function plan(environment: string, configPath?: string) {
159
209
  const config = await loadConfig(configPath);
160
210
  const topology = topologyFor(config, environment);
@@ -203,6 +253,7 @@ async function plan(environment: string, configPath?: string) {
203
253
  // run it supports. Nothing declares that: the missing port is what says it
204
254
  const serves = Boolean(topology.publicPort);
205
255
 
256
+ sayPlugins(config, say);
206
257
  sayPipeline(config, serves, say);
207
258
  await sayDrift(config, topology, serves ? "deploy" : "verify", say);
208
259
 
@@ -250,6 +301,29 @@ const DRIFT: Record<Drift["reason"], string> = {
250
301
  unrecognised: "not created by redkite, will be recreated",
251
302
  };
252
303
 
304
+ // Everything this deployment opted into. A vault that is missing from here is
305
+ // the reason its refs will not resolve, and that is worth seeing before a run
306
+ function sayPlugins(config: Deployment, say: (message?: string) => void) {
307
+ const plugins = config.plugins ?? [];
308
+ if (plugins.length === 0) return;
309
+
310
+ say("\nplugins");
311
+
312
+ for (const plugin of plugins) {
313
+ const stores = Object.keys(plugin.stores ?? {});
314
+ const resolves = stores.length > 0 ? ` resolves ${stores.join(", ")}` : "";
315
+ const count = plugin.steps?.length ?? 0;
316
+
317
+ say(` ${plugin.name}${gap(plugin.name.length)}${count} ${count === 1 ? "step" : "steps"}${resolves}`);
318
+ }
319
+ }
320
+
321
+ const COLUMN = 26;
322
+
323
+ function gap(width: number) {
324
+ return " ".repeat(Math.max(2, COLUMN - width));
325
+ }
326
+
253
327
  // What each run will actually do, in the order it will do it. A step is
254
328
  // addressed rather than called, so this is the only place a sequence is visible
255
329
  function sayPipeline(
@@ -257,9 +331,19 @@ function sayPipeline(
257
331
  serves: boolean,
258
332
  say: (message?: string) => void,
259
333
  ) {
260
- const steps = config.steps ?? [];
334
+ // What the run will walk, which is the plugins' steps and then the
335
+ // deployment's own. A plan that showed only one of them would be a plan of a
336
+ // different deploy
337
+ const steps = [...pluginSteps(config.plugins), ...(config.steps ?? [])];
261
338
  const added = new Set(steps.map((step) => step.point));
262
339
 
340
+ // Says which plugin a step came from, because a point on its own does not
341
+ const from = new Map(
342
+ (config.plugins ?? []).flatMap((plugin) =>
343
+ (plugin.steps ?? []).map((step) => [step.point, plugin.name] as const),
344
+ ),
345
+ );
346
+
263
347
  const at = (phase: string, slot: string) =>
264
348
  steps.filter((step) => {
265
349
  const address = addressOf(step.point);
@@ -294,7 +378,12 @@ function sayPipeline(
294
378
  say(` ${phase.padEnd(26)}${who}${what}`);
295
379
  }
296
380
 
297
- for (const step of at(phase, slot)) say(` ${step.point}`);
381
+ for (const step of at(phase, slot)) {
382
+ const owner = from.get(step.point);
383
+ if (!owner) say(` ${step.point}`);
384
+ // A point long enough to fill the column still gets its two spaces
385
+ else say(` ${step.point}${gap(step.point.length)}${owner}`);
386
+ }
298
387
  }
299
388
  }
300
389
  }
@@ -395,7 +484,7 @@ async function hostFor(host: DeployHost | undefined, log: Log, signal?: AbortSig
395
484
  const task = log.step(`Opening a connection to ${host.bastion}`);
396
485
 
397
486
  try {
398
- const opened = await sshHost(host.bastion, { signal });
487
+ const opened = await sshHost(host.bastion, { signal, hostKeys: host.hostKeys });
399
488
  task.done();
400
489
  return opened;
401
490
  } catch (error) {
@@ -406,6 +495,9 @@ async function hostFor(host: DeployHost | undefined, log: Log, signal?: AbortSig
406
495
 
407
496
  // Only the stores the config actually names. A deployment that keeps its
408
497
  // environment somewhere else, or nowhere, needs no credentials to deploy
498
+ // One store per provider a ref names, opened from the plugins the deployment
499
+ // registered. Nothing is opened for a deployment that names no refs, and a ref
500
+ // no plugin answers for is refused rather than reaching for a vault by name
409
501
  async function openStores(config: Deployment, log: Log): Promise<SecretStores> {
410
502
  // Services name refs too, and a deployment whose only secret is a database
411
503
  // password opened no store at all until this counted them
@@ -417,35 +509,30 @@ async function openStores(config: Deployment, log: Log): Promise<SecretStores> {
417
509
  ...config.services.flatMap((service) => listRefs(service.secrets)),
418
510
  ];
419
511
 
420
- const providers = new Set(refs.map((ref) => ref.provider));
512
+ const opened: SecretStores = {};
421
513
 
422
- if (!providers.has("bitwarden")) return {};
514
+ for (const provider of new Set(refs.map((ref) => ref.provider))) {
515
+ const open = storeFor(config.plugins, provider);
423
516
 
424
- const vault = log.step("Reading the vault");
517
+ if (!open) {
518
+ throw new Error(
519
+ `Nothing resolves ${provider} secrets. Add the plugin that does to ` +
520
+ "the plugins this deployment registers",
521
+ );
522
+ }
425
523
 
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
- });
524
+ const task = log.step(`Reading the ${provider} vault`);
433
525
 
434
- vault.done();
435
- return { bitwarden };
436
- } catch (error) {
437
- vault.fail("Could not read the vault");
438
- throw error;
526
+ try {
527
+ opened[provider] = await open({ detail: task.detail });
528
+ task.done();
529
+ } catch (error) {
530
+ task.fail(`Could not read the ${provider} vault`);
531
+ throw error;
532
+ }
439
533
  }
440
- }
441
-
442
- function required(name: string) {
443
- const value = process.env[name];
444
- if (value) return value;
445
534
 
446
- throw new Error(
447
- `${name} is not set, and this deployment reads its environment from Bitwarden`,
448
- );
535
+ return opened;
449
536
  }
450
537
 
451
538
  type RunOptions = { verbose: boolean; full: boolean; local: boolean };
@@ -465,7 +552,9 @@ async function run(
465
552
  // The host clones the repositories over this, so it has to exist before the
466
553
  // connection that forwards it is opened. Before the view too: ssh-add asks
467
554
  // for a passphrase on the terminal, and by then the view owns it
468
- requireAgent((message) => process.stderr.write(`${message}\n`));
555
+ if (needsAgent(config, environment)) {
556
+ requireAgent((message) => process.stderr.write(`${message}\n`));
557
+ }
469
558
 
470
559
  // Whatever is in flight is killed, the pipeline unwinds through its own
471
560
  // failure path, and the finally below removes the scratch directory
package/src/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { assertSteps } from "./pipeline.js";
2
+ import { pluginSteps } from "./plugin.js";
2
3
  import type { Deployment, Environment } from "./types.js";
3
4
 
4
5
  // The one selected, from the one place worth looking. A deployment that carries
@@ -19,7 +20,10 @@ export function defineDeployment<const T extends Deployment & { environments?: n
19
20
  assertOneSource(config);
20
21
  assertRoutesResolvable(config);
21
22
  assertDirsRelative(config);
22
- assertSteps(config.steps ?? []);
23
+ assertUniquePlugins(config);
24
+ // Together, because a plugin's step and the deployment's own share the same
25
+ // points and two of them at one point is the collision worth catching
26
+ assertSteps([...pluginSteps(config.plugins), ...(config.steps ?? [])]);
23
27
  return config;
24
28
  }
25
29
 
@@ -29,6 +33,20 @@ export function defineEnvironment<const T extends Environment>(environment: T):
29
33
  return environment;
30
34
  }
31
35
 
36
+ // Registering one twice is either a mistake or two configurations of the same
37
+ // thing, and neither is something to pick a winner for
38
+ function assertUniquePlugins(config: Deployment) {
39
+ const seen = new Set<string>();
40
+
41
+ for (const plugin of config.plugins ?? []) {
42
+ if (seen.has(plugin.name)) {
43
+ throw new Error(`The plugin ${plugin.name} is registered twice`);
44
+ }
45
+
46
+ seen.add(plugin.name);
47
+ }
48
+ }
49
+
32
50
  // Cloned or already here, and the two are built differently enough that
33
51
  // guessing between them is worse than being told
34
52
  function assertOneSource(config: Deployment) {
package/src/deploy.ts CHANGED
@@ -2,8 +2,9 @@ import { build, type BuildContext, type BuildResult } from "./build.js";
2
2
  import { assertCheckable, runChecks } from "./checks.js";
3
3
  import { environmentOf } from "./config.js";
4
4
  import { Docker } from "./docker.js";
5
+ import { envFileFor } from "./environment.js";
5
6
  import { healthcheck, type HealthDeps } from "./health.js";
6
- import type { Host } from "./host.js";
7
+ import { finalHost, type Host } from "./host.js";
7
8
  import { localHost } from "./localHost.js";
8
9
  import { silent, type Log } from "./log.js";
9
10
  import {
@@ -21,6 +22,7 @@ import {
21
22
  type Run,
22
23
  type Start,
23
24
  } from "./pipeline.js";
25
+ import { pluginSteps } from "./plugin.js";
24
26
  import { readEnv, readRef, type SecretStores } from "./secrets/refs.js";
25
27
  import { ensureService } from "./services/ensure.js";
26
28
  import { plannedServices } from "./services/planned.js";
@@ -78,8 +80,11 @@ async function start(run: Run, options: DeployOptions): Promise<Finished> {
78
80
  run,
79
81
  };
80
82
 
81
- const steps = merge(supplied(options), options.config.steps ?? []);
82
- return await runPipeline(run, steps, setting, options.signal);
83
+ // A plugin's steps lead, so a snapshot listed as a plugin runs above the
84
+ // migration the deployment writes after it
85
+ const added = [...pluginSteps(options.config.plugins), ...(options.config.steps ?? [])];
86
+
87
+ return await runPipeline(run, merge(supplied(options), added), setting, options.signal);
83
88
  }
84
89
 
85
90
  // Every step redkite supplies, in the order the phases name. A run walks the
@@ -171,33 +176,66 @@ async function release(
171
176
  context.config.apps.map((app) => [app.name, app.environment ?? {}]),
172
177
  );
173
178
 
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)),
179
+ // Resolved now and handed over when the container is made. Nothing from the
180
+ // vault is in the image, so this is the only way the running process sees it
181
+ const files = new Map(
182
+ await Promise.all(
183
+ context.config.apps.map(
184
+ async (app) => [app.name, await envFileFor(app, context)] as const,
185
+ ),
182
186
  ),
183
187
  );
184
188
 
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
- };
189
+ // What has actually been moved, rather than what was going to be. A stop
190
+ // lands between two docker commands, and only this says which side of it
191
+ const moved: AppTopology[] = [];
192
+
193
+ try {
194
+ task.detail("retiring the running containers");
195
+
196
+ await Promise.all(
197
+ apps.map(async (app) => {
198
+ await retire(docker, topology, app);
199
+ moved.push(app);
200
+ }),
201
+ );
202
+
203
+ task.detail("creating the new ones");
204
+
205
+ await Promise.all(
206
+ apps.map((app) =>
207
+ create(docker, topology, app, environments.get(app.name), files.get(app.name)),
208
+ ),
209
+ );
210
+
211
+ task.detail("starting them");
212
+ await Promise.all(apps.map((app) => docker.container.start(app.container)));
213
+
214
+ // Inside, because the swap is not over until this says so. A stop lands
215
+ // here more often than anywhere else: it is the longest part, and by now
216
+ // every address has already moved
217
+ if (!(await checkAll(context, health))) {
218
+ context.log.fail("Health checks failed, reverting");
219
+ await Promise.all(apps.map((app) => revert(docker, topology, app)));
220
+
221
+ return {
222
+ ...input,
223
+ ok: false,
224
+ released: [],
225
+ reverted: apps.map((app) => app.container),
226
+ checked: [],
227
+ };
228
+ }
229
+ } catch (error) {
230
+ // Whatever ended this, the addresses have moved and something has to put
231
+ // them back. A stop is the usual one, so the revert runs on a host that
232
+ // has been told to stop: it is the abort that made this necessary
233
+ if (moved.length > 0) {
234
+ context.log.fail(`Putting ${moved.length} back where they were`);
235
+ await putBack(context, moved);
236
+ }
237
+
238
+ throw error;
201
239
  }
202
240
 
203
241
  return {
@@ -209,6 +247,23 @@ async function release(
209
247
  };
210
248
  }
211
249
 
250
+ // Through a host with the stop lifted, because the commands that undo a swap
251
+ // cannot be refused by the same signal that interrupted it. One app failing to
252
+ // go back must not stop the others, so each is settled on its own
253
+ async function putBack(context: Context, moved: AppTopology[]) {
254
+ const docker = new Docker(finalHost(context.host));
255
+
256
+ const put = moved.map(async (app) => {
257
+ try {
258
+ await revert(docker, context.topology, app);
259
+ } catch (error) {
260
+ context.log.fail(`${app.container} could not be put back: ${String(error)}`);
261
+ }
262
+ });
263
+
264
+ await Promise.all(put);
265
+ }
266
+
212
267
  // A build leaves its image on the host rather than sending one, so without this
213
268
  // every run adds a runtime image and a builder to a disk nobody is watching
214
269
  async function finish(input: Released, context: Context): Promise<Finished> {
@@ -342,6 +397,7 @@ async function create(
342
397
  topology: Topology,
343
398
  app: AppTopology,
344
399
  environment: Record<string, string> = {},
400
+ envFile?: string,
345
401
  ) {
346
402
  const builder = docker.container
347
403
  .builder()
@@ -357,19 +413,35 @@ async function create(
357
413
  builder.extraHost(host, ip);
358
414
  }
359
415
 
416
+ if (envFile) builder.envFile(envFile);
417
+
360
418
  for (const volume of app.volumes) builder.volume(volume.volume, volume.mountPath);
361
419
  for (const [name, value] of Object.entries(environment)) builder.env(name, value);
362
420
 
363
421
  await builder.create();
364
422
  }
365
423
 
366
- // Put the retired container back on the live address and its original name
367
- async function revert(docker: Docker, topology: Topology, app: AppTopology) {
424
+ // Put the retired container back on the live address and its original name.
425
+ // Answers with whether there was one: a first deploy has nothing behind it, and
426
+ // the container that just failed is simply left where it was renamed to
427
+ export async function revert(docker: Docker, topology: Topology, app: AppTopology) {
368
428
  await docker.container.stop(app.container);
429
+
430
+ // The slot may still hold what an earlier interrupted run put there, and a
431
+ // rename refuses rather than clobbers. Retiring clears its own slot the same
432
+ // way, and a revert that skipped this would leave the new container live
433
+ await docker.container.stop(app.failed);
434
+ await docker.container.remove(app.failed);
435
+
369
436
  await docker.container.rename(app.container, app.failed);
437
+
438
+ if (!(await docker.container.exists(app.retired))) return false;
439
+
370
440
  await docker.network.reconnect(topology.network, app.retired, app.currentAddress);
371
441
  await docker.container.rename(app.retired, app.container);
372
442
  await docker.container.start(app.container);
443
+
444
+ return true;
373
445
  }
374
446
 
375
447
  async function cleanup(docker: Docker, app: AppTopology) {
package/src/dockerfile.ts CHANGED
@@ -82,22 +82,18 @@ function builderStage(spec: BuildSpec, context: DockerfileContext) {
82
82
  // Only now, so the steps below read the app's own manifest and write beside it
83
83
  if (context.dir) lines.push(`WORKDIR ${workdir}`);
84
84
 
85
- lines.push(
86
- `RUN --mount=type=secret,id=${context.envSecret} cp /run/secrets/${context.envSecret} ${workdir}/.env`,
87
- `ENV SENTRY_RELEASE=${context.release}`,
88
- );
85
+ lines.push(`ENV SENTRY_RELEASE=${context.release}`);
86
+
87
+ // Mounted for the length of each step rather than copied into the tree. A
88
+ // file that is copied is in that layer for good, and no later instruction
89
+ // can take it back out: rm leaves it readable in the layer underneath
90
+ const env = `--mount=type=secret,id=${context.envSecret},target=${workdir}/.env `;
89
91
 
90
92
  // Unquoted: the shell form hands everything after RUN to sh -c, so a step is
91
93
  // written exactly as it would be typed. Quoting it made the whole command one
92
94
  // word, and the shell went looking for a program by that name
93
95
  for (const step of spec.steps) {
94
- lines.push(`RUN ${mounts}${step}`);
95
- }
96
-
97
- if (spec.sourcemaps) {
98
- // The built .env ships inside the image, and an upload token is build-time
99
- const token = spec.sourcemaps.stripFromImage;
100
- lines.push(`RUN sed -i '/^${token}=/d' ${rootedAt(spec.output, context.dir)}/.env`);
96
+ lines.push(`RUN ${env}${mounts}${step}`);
101
97
  }
102
98
 
103
99
  return lines;
@@ -0,0 +1,24 @@
1
+ import type { Context } from "./pipeline.js";
2
+ import { readEnv } from "./secrets/refs.js";
3
+ import type { AppSpec } from "./types.js";
4
+
5
+ // What the vault resolved to, handed over as a file rather than baked into an
6
+ // image. Nothing from a vault is in a layer, so this is the only way anything
7
+ // running the app's image sees its environment: the container itself, and the
8
+ // migrations and checks that run in the builder.
9
+
10
+ // Written to the host and handed over as a file, so no value appears in an
11
+ // argument list. Docker reads it when a container is created, and again for
12
+ // each `run --env-file`, so the file goes with the rest of the deploy's scratch
13
+ export async function envFileFor(app: AppSpec, context: Context) {
14
+ if (!app.secrets) return undefined;
15
+
16
+ const contents = await readEnv(app.secrets, context.secrets);
17
+ return await context.host.write(`apps/${app.name}/env`, contents);
18
+ }
19
+
20
+ // The flag, or nothing at all for an app that names no secrets
21
+ export async function envFlags(app: AppSpec, context: Context) {
22
+ const path = await envFileFor(app, context);
23
+ return path ? [`--env-file ${path}`] : [];
24
+ }
package/src/host.ts CHANGED
@@ -25,9 +25,20 @@ export type Host = {
25
25
  // Signals everything this host started and answers with how many are still
26
26
  // running. Zero is the only answer that means nothing was left behind
27
27
  stop(signal: "TERM" | "KILL"): Promise<number>;
28
+ // A command that runs even once a stop has been asked for. Putting a swap
29
+ // back is the reason there is one: the abort is what made the work necessary,
30
+ // so it cannot also be what refuses to do it
31
+ final(command: string): Promise<Result>;
28
32
  close?(): Promise<void>;
29
33
  };
30
34
 
35
+ // The same host with the stop lifted. Handed to a Docker, it gives every
36
+ // command below it the same exemption, which is how a revert reaches the
37
+ // containers an abort left half moved
38
+ export function finalHost(host: Host): Host {
39
+ return { ...host, sh: async (command) => await host.final(command) };
40
+ }
41
+
31
42
  // A streamed command keeps this many lines, so a build that prints tens of
32
43
  // megabytes still fits in the error a failure reports
33
44
  const TAIL = 200;
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./build.js";
2
2
  export * from "./config.js";
3
3
  export * from "./deploy.js";
4
4
  export * from "./docker.js";
5
+ export * from "./environment.js";
5
6
  export * from "./dockerfile.js";
6
7
  export * from "./health.js";
7
8
  export * from "./host.js";
@@ -10,8 +11,10 @@ export * from "./localHost.js";
10
11
  export * from "./log.js";
11
12
  export * from "./nginx.js";
12
13
  export * from "./pipeline.js";
14
+ export * from "./plugin.js";
15
+ export * from "./plugins/index.js";
16
+ export * from "./recover.js";
13
17
  export * from "./presets/index.js";
14
- export * from "./secrets/bitwarden.js";
15
18
  export * from "./secrets/refs.js";
16
19
  export * from "./secrets/store.js";
17
20
  export * from "./shell.js";
package/src/localHost.ts CHANGED
@@ -43,6 +43,9 @@ export async function localHost(options: LocalOptions = {}): Promise<Host> {
43
43
  // is a process group leader, so one signal reaches the whole tree
44
44
  stop: async (name) => signalEverything(name === "KILL" ? "SIGKILL" : "SIGTERM"),
45
45
 
46
+ // Without the signal, so a stop cannot refuse the work it created
47
+ final: async (command) => await spawnCollect("sh", ["-c", command]),
48
+
46
49
  close: async () => {
47
50
  await rm(directory, { recursive: true, force: true });
48
51
  },
package/src/plugin.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { assertSteps, type AnyStep } from "./pipeline.js";
2
+ import type { SecretStore } from "./secrets/refs.js";
3
+
4
+ // What a deployment can be handed that redkite did not write. A plugin adds
5
+ // work to the run, or teaches it to resolve a kind of secret, and nothing it
6
+ // brings happens until a deployment lists it. Redkite's own vault is one of
7
+ // these rather than something wired in behind them.
8
+
9
+ export type OpenContext = {
10
+ // What the step row says while the store is being opened. Unlocking a vault
11
+ // is the slowest thing a deploy does before it starts building
12
+ detail: (message: string) => void;
13
+ };
14
+
15
+ // Opened once, and only when a ref names the provider it answers for
16
+ export type OpenStore = (context: OpenContext) => Promise<SecretStore>;
17
+
18
+ export type Plugin = {
19
+ // Says which one when a deployment registers the same plugin twice, and what
20
+ // a failure to open a store is reported against
21
+ name: string;
22
+ // Run in the order the deployment lists the plugins, above its own steps
23
+ steps?: AnyStep[];
24
+ // Provider tag to how that store is opened
25
+ stores?: Record<string, OpenStore>;
26
+ };
27
+
28
+ // Identity, but the points a plugin claims are checked where it is written
29
+ // rather than where it is registered, so a typo is the plugin's own failure
30
+ export function definePlugin<const T extends Plugin>(plugin: T): T {
31
+ assertSteps(plugin.steps ?? []);
32
+ return plugin;
33
+ }
34
+
35
+ export function pluginSteps(plugins: Plugin[] = []): AnyStep[] {
36
+ return plugins.flatMap((plugin) => plugin.steps ?? []);
37
+ }
38
+
39
+ // The one that answers for this provider. Two plugins claiming it would be two
40
+ // vaults for one tag, which is a config nobody can read the intent of
41
+ export function storeFor(plugins: Plugin[] = [], provider: string) {
42
+ const claiming = plugins.filter((plugin) => plugin.stores?.[provider]);
43
+
44
+ if (claiming.length > 1) {
45
+ const names = claiming.map((plugin) => plugin.name).join(" and ");
46
+ throw new Error(`${names} both resolve ${provider} secrets, and only one can`);
47
+ }
48
+
49
+ return claiming[0]?.stores?.[provider];
50
+ }