redkite 0.1.7 → 0.1.9

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 (123) hide show
  1. package/README.md +343 -26
  2. package/dist/build.d.ts +2 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +11 -1
  5. package/dist/build.js.map +1 -1
  6. package/dist/checks.d.ts.map +1 -1
  7. package/dist/checks.js +4 -0
  8. package/dist/checks.js.map +1 -1
  9. package/dist/cli/agent.d.ts +2 -0
  10. package/dist/cli/agent.d.ts.map +1 -1
  11. package/dist/cli/agent.js +23 -1
  12. package/dist/cli/agent.js.map +1 -1
  13. package/dist/cli/config.d.ts.map +1 -1
  14. package/dist/cli/config.js +15 -2
  15. package/dist/cli/config.js.map +1 -1
  16. package/dist/cli/index.d.ts.map +1 -1
  17. package/dist/cli/index.js +98 -30
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +20 -1
  21. package/dist/config.js.map +1 -1
  22. package/dist/deploy.d.ts +4 -1
  23. package/dist/deploy.d.ts.map +1 -1
  24. package/dist/deploy.js +77 -23
  25. package/dist/deploy.js.map +1 -1
  26. package/dist/dockerfile.js +6 -7
  27. package/dist/dockerfile.js.map +1 -1
  28. package/dist/environment.d.ts +5 -0
  29. package/dist/environment.d.ts.map +1 -0
  30. package/dist/environment.js +20 -0
  31. package/dist/environment.js.map +1 -0
  32. package/dist/host.d.ts +2 -0
  33. package/dist/host.d.ts.map +1 -1
  34. package/dist/host.js +6 -0
  35. package/dist/host.js.map +1 -1
  36. package/dist/index.d.ts +4 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/localHost.d.ts.map +1 -1
  41. package/dist/localHost.js +2 -0
  42. package/dist/localHost.js.map +1 -1
  43. package/dist/plugin.d.ts +15 -0
  44. package/dist/plugin.d.ts.map +1 -0
  45. package/dist/plugin.js +21 -0
  46. package/dist/plugin.js.map +1 -0
  47. package/dist/plugins/bitwarden.d.ts +11 -0
  48. package/dist/plugins/bitwarden.d.ts.map +1 -0
  49. package/dist/plugins/bitwarden.js +40 -0
  50. package/dist/plugins/bitwarden.js.map +1 -0
  51. package/dist/plugins/digitalOcean.d.ts +19 -0
  52. package/dist/plugins/digitalOcean.d.ts.map +1 -0
  53. package/dist/plugins/digitalOcean.js +71 -0
  54. package/dist/plugins/digitalOcean.js.map +1 -0
  55. package/dist/plugins/index.d.ts +5 -0
  56. package/dist/plugins/index.d.ts.map +1 -0
  57. package/dist/plugins/index.js +5 -0
  58. package/dist/plugins/index.js.map +1 -0
  59. package/dist/plugins/rds.d.ts +14 -0
  60. package/dist/plugins/rds.d.ts.map +1 -0
  61. package/dist/plugins/rds.js +86 -0
  62. package/dist/plugins/rds.js.map +1 -0
  63. package/dist/plugins/snapshot.d.ts +7 -0
  64. package/dist/plugins/snapshot.d.ts.map +1 -0
  65. package/dist/plugins/snapshot.js +29 -0
  66. package/dist/plugins/snapshot.js.map +1 -0
  67. package/dist/presets/nodeApp.d.ts +1 -2
  68. package/dist/presets/nodeApp.d.ts.map +1 -1
  69. package/dist/presets/nodeApp.js +0 -1
  70. package/dist/presets/nodeApp.js.map +1 -1
  71. package/dist/recover.d.ts +19 -0
  72. package/dist/recover.d.ts.map +1 -0
  73. package/dist/recover.js +58 -0
  74. package/dist/recover.js.map +1 -0
  75. package/dist/secrets/store.d.ts +5 -2
  76. package/dist/secrets/store.d.ts.map +1 -1
  77. package/dist/secrets/store.js +25 -14
  78. package/dist/secrets/store.js.map +1 -1
  79. package/dist/source.d.ts +6 -1
  80. package/dist/source.d.ts.map +1 -1
  81. package/dist/source.js +12 -4
  82. package/dist/source.js.map +1 -1
  83. package/dist/sshHost.d.ts +2 -0
  84. package/dist/sshHost.d.ts.map +1 -1
  85. package/dist/sshHost.js +29 -11
  86. package/dist/sshHost.js.map +1 -1
  87. package/dist/steps.d.ts +0 -7
  88. package/dist/steps.d.ts.map +1 -1
  89. package/dist/steps.js +6 -3
  90. package/dist/steps.js.map +1 -1
  91. package/dist/types.d.ts +7 -5
  92. package/dist/types.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/build.ts +12 -1
  95. package/src/checks.ts +4 -0
  96. package/src/cli/agent.ts +28 -1
  97. package/src/cli/config.ts +19 -2
  98. package/src/cli/index.ts +118 -29
  99. package/src/config.ts +27 -1
  100. package/src/deploy.ts +101 -29
  101. package/src/dockerfile.ts +7 -11
  102. package/src/environment.ts +24 -0
  103. package/src/host.ts +11 -0
  104. package/src/index.ts +4 -1
  105. package/src/localHost.ts +3 -0
  106. package/src/plugin.ts +50 -0
  107. package/src/plugins/bitwarden.ts +57 -0
  108. package/src/plugins/digitalOcean.ts +111 -0
  109. package/src/plugins/index.ts +4 -0
  110. package/src/plugins/rds.ts +126 -0
  111. package/src/plugins/snapshot.ts +44 -0
  112. package/src/presets/nodeApp.ts +1 -3
  113. package/src/recover.ts +90 -0
  114. package/src/secrets/store.ts +39 -20
  115. package/src/source.ts +20 -5
  116. package/src/sshHost.ts +35 -11
  117. package/src/steps.ts +7 -6
  118. package/src/types.ts +29 -12
  119. package/dist/secrets/bitwarden.d.ts +0 -3
  120. package/dist/secrets/bitwarden.d.ts.map +0 -1
  121. package/dist/secrets/bitwarden.js +0 -6
  122. package/dist/secrets/bitwarden.js.map +0 -1
  123. package/src/secrets/bitwarden.ts +0 -7
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
+ }
@@ -0,0 +1,57 @@
1
+ import { definePlugin, type OpenContext, type Plugin } from "../plugin.js";
2
+ import { bitwardenStore } from "../secrets/store.js";
3
+ import type { SecretRef } from "../types.js";
4
+
5
+ // Redkite's own vault, and a plugin like any other: a deployment that does not
6
+ // register it cannot resolve a bitwarden ref, and says so before it builds.
7
+
8
+ // The one variable a vault needs when the session was obtained elsewhere. A CI
9
+ // job that unlocked once and reuses it across several deploys is the case
10
+ const KEY = "BW_KEY";
11
+
12
+ export type BitwardenOptions = {
13
+ // Whether this vault answers secret refs, and what unlocks it. true reads
14
+ // BW_KEY, and falls back to the api credentials when that is not set. A
15
+ // string is the session itself, for a config that names its own variable.
16
+ // false registers the plugin without a store, for a deployment that has
17
+ // stopped reading from it but has not yet taken the refs out
18
+ secrets?: boolean | string;
19
+ };
20
+
21
+ function vault(options: BitwardenOptions = {}): Plugin {
22
+ const secrets = options.secrets ?? true;
23
+ if (secrets === false) return definePlugin({ name: "bitwarden" });
24
+
25
+ return definePlugin({
26
+ name: "bitwarden",
27
+ stores: { bitwarden: async (context) => await open(secrets, context) },
28
+ });
29
+ }
30
+
31
+ async function open(secrets: true | string, context: OpenContext) {
32
+ const session = typeof secrets === "string" ? secrets : process.env[KEY];
33
+ if (session) return await bitwardenStore({ session, detail: context.detail });
34
+
35
+ return await bitwardenStore({
36
+ detail: context.detail,
37
+ clientId: required("BW_CLIENT_ID"),
38
+ clientSecret: required("BW_CLIENT_SECRET"),
39
+ password: required("BW_PASSWORD"),
40
+ });
41
+ }
42
+
43
+ function required(name: string) {
44
+ const value = process.env[name];
45
+ if (value) return value;
46
+
47
+ throw new Error(
48
+ `Neither ${KEY} nor ${name} is set, and this deployment reads its environment from Bitwarden`,
49
+ );
50
+ }
51
+
52
+ // The plugin is what a deployment registers; the item is what an app points at.
53
+ // Both are named bitwarden because both are the same vault, and the id is a
54
+ // pointer rather than a secret, so it belongs in the config
55
+ export const bitwarden = Object.assign(vault, {
56
+ item: (id: string): SecretRef => ({ provider: "bitwarden", id }),
57
+ });
@@ -0,0 +1,111 @@
1
+ import type { Step } from "../pipeline.js";
2
+ import { definePlugin, type Plugin } from "../plugin.js";
3
+
4
+ import { slug, snapshotName, tokenFrom, type SnapshotPoint } from "./snapshot.js";
5
+
6
+ // A snapshot of the disk a database sits on, taken before anything migrates it.
7
+ //
8
+ // DigitalOcean's managed databases have no endpoint that takes one on demand:
9
+ // their backups are automatic and the API only lists them. So what this
10
+ // snapshots is the block storage volume redkite's own postgres service keeps
11
+ // its data on, or the droplet when the database is the whole machine. A volume
12
+ // snapshot of a running Postgres is crash consistent rather than clean, which
13
+ // Postgres is built to survive: it replays the log on the way back up.
14
+
15
+ const API = "https://api.digitalocean.com/v2";
16
+
17
+ // Injected for the same reason the ssh runner is: what this builds is asserted
18
+ // on rather than trusted
19
+ export type Request = (
20
+ url: string,
21
+ init: { method: string; headers: Record<string, string>; body: string },
22
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
23
+
24
+ export type DigitalOceanSnapshotOptions = {
25
+ // The block storage volume the data lives on, by id
26
+ volume?: string;
27
+ // Or the droplet, by id, for a database that is the whole machine
28
+ droplet?: number;
29
+ // Goes in front of the snapshot's name, which carries the environment and
30
+ // the minute after it
31
+ name?: string;
32
+ // Environment variable the API token is read from, never the token itself:
33
+ // a config is committed and a token is not
34
+ tokenFrom?: string;
35
+ request?: Request;
36
+ };
37
+
38
+ const TOKEN = "DIGITALOCEAN_TOKEN";
39
+
40
+ export function digitalOceanSnapshot(options: DigitalOceanSnapshotOptions): Plugin {
41
+ assertOneTarget(options);
42
+
43
+ return definePlugin({
44
+ name: `digitalocean-snapshot-${slug(options.volume ?? String(options.droplet ?? ""))}`,
45
+ steps: [snapshotStep(options)],
46
+ });
47
+ }
48
+
49
+ function snapshotStep(options: DigitalOceanSnapshotOptions): Step<SnapshotPoint> {
50
+ const target = options.volume ?? String(options.droplet ?? "");
51
+ const send = options.request ?? ((url, init) => fetch(url, init));
52
+ const variable = options.tokenFrom ?? TOKEN;
53
+
54
+ return {
55
+ point: `swap:before:snapshot-${slug(target)}`,
56
+ check: (plan) => tokenFrom(variable, plan),
57
+
58
+ run: async (input, context) => {
59
+ const name = snapshotName(options.name ?? target, context.environment, new Date());
60
+ context.task.detail(`snapshotting ${target} as ${name}`);
61
+
62
+ const token = tokenFrom(variable, {
63
+ config: context.config,
64
+ environment: context.environment,
65
+ });
66
+
67
+ const answer = await send(urlFor(options), {
68
+ method: "POST",
69
+ headers: {
70
+ "content-type": "application/json",
71
+ authorization: `Bearer ${token}`,
72
+ },
73
+ body: JSON.stringify(bodyFor(options, name)),
74
+ });
75
+
76
+ if (!answer.ok) {
77
+ throw new Error(
78
+ `DigitalOcean refused the snapshot of ${target} (${answer.status}): ${await answer.text()}`,
79
+ );
80
+ }
81
+
82
+ return input;
83
+ },
84
+ };
85
+ }
86
+
87
+ function urlFor(options: DigitalOceanSnapshotOptions) {
88
+ if (options.volume) return `${API}/volumes/${options.volume}/snapshots`;
89
+ return `${API}/droplets/${options.droplet}/actions`;
90
+ }
91
+
92
+ // A volume takes a snapshot, a droplet is asked to perform one. Two endpoints
93
+ // with two shapes, which is why the target is not one field
94
+ function bodyFor(options: DigitalOceanSnapshotOptions, name: string) {
95
+ if (options.volume) return { name };
96
+ return { type: "snapshot", name };
97
+ }
98
+
99
+ // Checked where the plugin is written rather than where it runs, so the config
100
+ // fails to load. The token cannot be: a plan reads the config without one
101
+ function assertOneTarget(options: DigitalOceanSnapshotOptions) {
102
+ if (options.volume && options.droplet !== undefined) {
103
+ throw new Error(
104
+ "digitalOceanSnapshot names both a volume and a droplet, and they are different disks",
105
+ );
106
+ }
107
+
108
+ if (!options.volume && options.droplet === undefined) {
109
+ throw new Error("digitalOceanSnapshot names no volume or droplet to snapshot");
110
+ }
111
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./bitwarden.js";
2
+ export * from "./digitalOcean.js";
3
+ export * from "./rds.js";
4
+ export * from "./snapshot.js";
@@ -0,0 +1,126 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import type { Step } from "../pipeline.js";
5
+ import { definePlugin, type Plugin } from "../plugin.js";
6
+
7
+ import { slug, snapshotName, type SnapshotPoint } from "./snapshot.js";
8
+
9
+ // A snapshot of a managed Postgres or MySQL, taken before anything migrates it.
10
+ // Through the AWS CLI rather than the REST API: signing a request by hand is a
11
+ // page of crypto that nothing here could check, and every runner already has
12
+ // the CLI with the credentials the job was given.
13
+
14
+ const run = promisify(execFile);
15
+
16
+ // Injected so the argv this builds is asserted on rather than trusted
17
+ export type Aws = (args: string[]) => Promise<{ stdout: string }>;
18
+
19
+ export type RdsSnapshotOptions = {
20
+ // The instance to snapshot, or the cluster for Aurora. One of them
21
+ instance?: string;
22
+ cluster?: string;
23
+ // Goes in front of the snapshot's name, which carries the environment and
24
+ // the minute after it
25
+ name?: string;
26
+ region?: string;
27
+ // Waits for the snapshot to finish rather than only to start. RDS captures
28
+ // the data when it begins, so this is about learning it worked rather than
29
+ // about the restore point, and it can take a very long time
30
+ wait?: boolean;
31
+ aws?: Aws;
32
+ };
33
+
34
+ export function rdsSnapshot(options: RdsSnapshotOptions): Plugin {
35
+ assertOneTarget(options);
36
+ const target = slug(options.instance ?? options.cluster ?? "");
37
+
38
+ return definePlugin({
39
+ name: `rds-snapshot-${target}`,
40
+ steps: [snapshotStep(options)],
41
+ });
42
+ }
43
+
44
+ function snapshotStep(options: RdsSnapshotOptions): Step<SnapshotPoint> {
45
+ const target = options.instance ?? options.cluster ?? "";
46
+ const aws = options.aws ?? ((args: string[]) => run("aws", args));
47
+
48
+ return {
49
+ point: `swap:before:snapshot-${slug(target)}`,
50
+
51
+ run: async (input, context) => {
52
+ const identifier = snapshotName(options.name ?? target, context.environment, new Date());
53
+ context.task.detail(`snapshotting ${target} as ${identifier}`);
54
+
55
+ await aws(create(options, identifier));
56
+
57
+ // The data is captured when it begins, so this is not what makes the
58
+ // restore point good. It is what turns a snapshot that silently failed
59
+ // into a deploy that stops before the migration
60
+ if (options.wait) {
61
+ context.task.detail(`waiting for ${identifier}`);
62
+ await aws(ready(options, identifier));
63
+ }
64
+
65
+ return input;
66
+ },
67
+ };
68
+ }
69
+
70
+ function create(options: RdsSnapshotOptions, identifier: string) {
71
+ const region = options.region ? ["--region", options.region] : [];
72
+
73
+ if (options.cluster) {
74
+ return [
75
+ "rds",
76
+ "create-db-cluster-snapshot",
77
+ "--db-cluster-identifier",
78
+ options.cluster,
79
+ "--db-cluster-snapshot-identifier",
80
+ identifier,
81
+ ...region,
82
+ ];
83
+ }
84
+
85
+ return [
86
+ "rds",
87
+ "create-db-snapshot",
88
+ "--db-instance-identifier",
89
+ options.instance ?? "",
90
+ "--db-snapshot-identifier",
91
+ identifier,
92
+ ...region,
93
+ ];
94
+ }
95
+
96
+ function ready(options: RdsSnapshotOptions, identifier: string) {
97
+ const region = options.region ? ["--region", options.region] : [];
98
+
99
+ if (options.cluster) {
100
+ return [
101
+ "rds",
102
+ "wait",
103
+ "db-cluster-snapshot-available",
104
+ "--db-cluster-snapshot-identifier",
105
+ identifier,
106
+ ...region,
107
+ ];
108
+ }
109
+
110
+ return ["rds", "wait", "db-snapshot-available", "--db-snapshot-identifier", identifier, ...region];
111
+ }
112
+
113
+ // An instance and a cluster are different API calls against different things,
114
+ // and guessing which was meant is worse than being told. Checked where the
115
+ // plugin is written rather than where it runs, so the config fails to load
116
+ function assertOneTarget(options: RdsSnapshotOptions) {
117
+ if (options.instance && options.cluster) {
118
+ throw new Error(
119
+ "rdsSnapshot names both an instance and a cluster, and they are different databases",
120
+ );
121
+ }
122
+
123
+ if (!options.instance && !options.cluster) {
124
+ throw new Error("rdsSnapshot names no instance or cluster to snapshot");
125
+ }
126
+ }