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.
- package/README.md +343 -26
- package/dist/build.d.ts +2 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +11 -1
- package/dist/build.js.map +1 -1
- package/dist/checks.d.ts.map +1 -1
- package/dist/checks.js +4 -0
- package/dist/checks.js.map +1 -1
- package/dist/cli/agent.d.ts +2 -0
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/agent.js +23 -1
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +15 -2
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +98 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -1
- package/dist/config.js.map +1 -1
- package/dist/deploy.d.ts +4 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +77 -23
- package/dist/deploy.js.map +1 -1
- package/dist/dockerfile.js +6 -7
- package/dist/dockerfile.js.map +1 -1
- package/dist/environment.d.ts +5 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +20 -0
- package/dist/environment.js.map +1 -0
- package/dist/host.d.ts +2 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +6 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/localHost.d.ts.map +1 -1
- package/dist/localHost.js +2 -0
- package/dist/localHost.js.map +1 -1
- package/dist/plugin.d.ts +15 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +21 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugins/bitwarden.d.ts +11 -0
- package/dist/plugins/bitwarden.d.ts.map +1 -0
- package/dist/plugins/bitwarden.js +40 -0
- package/dist/plugins/bitwarden.js.map +1 -0
- package/dist/plugins/digitalOcean.d.ts +19 -0
- package/dist/plugins/digitalOcean.d.ts.map +1 -0
- package/dist/plugins/digitalOcean.js +71 -0
- package/dist/plugins/digitalOcean.js.map +1 -0
- package/dist/plugins/index.d.ts +5 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +5 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/rds.d.ts +14 -0
- package/dist/plugins/rds.d.ts.map +1 -0
- package/dist/plugins/rds.js +86 -0
- package/dist/plugins/rds.js.map +1 -0
- package/dist/plugins/snapshot.d.ts +7 -0
- package/dist/plugins/snapshot.d.ts.map +1 -0
- package/dist/plugins/snapshot.js +29 -0
- package/dist/plugins/snapshot.js.map +1 -0
- package/dist/presets/nodeApp.d.ts +1 -2
- package/dist/presets/nodeApp.d.ts.map +1 -1
- package/dist/presets/nodeApp.js +0 -1
- package/dist/presets/nodeApp.js.map +1 -1
- package/dist/recover.d.ts +19 -0
- package/dist/recover.d.ts.map +1 -0
- package/dist/recover.js +58 -0
- package/dist/recover.js.map +1 -0
- package/dist/secrets/store.d.ts +5 -2
- package/dist/secrets/store.d.ts.map +1 -1
- package/dist/secrets/store.js +25 -14
- package/dist/secrets/store.js.map +1 -1
- package/dist/source.d.ts +6 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +12 -4
- package/dist/source.js.map +1 -1
- package/dist/sshHost.d.ts +2 -0
- package/dist/sshHost.d.ts.map +1 -1
- package/dist/sshHost.js +29 -11
- package/dist/sshHost.js.map +1 -1
- package/dist/steps.d.ts +0 -7
- package/dist/steps.d.ts.map +1 -1
- package/dist/steps.js +6 -3
- package/dist/steps.js.map +1 -1
- package/dist/types.d.ts +7 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +12 -1
- package/src/checks.ts +4 -0
- package/src/cli/agent.ts +28 -1
- package/src/cli/config.ts +19 -2
- package/src/cli/index.ts +118 -29
- package/src/config.ts +27 -1
- package/src/deploy.ts +101 -29
- package/src/dockerfile.ts +7 -11
- package/src/environment.ts +24 -0
- package/src/host.ts +11 -0
- package/src/index.ts +4 -1
- package/src/localHost.ts +3 -0
- package/src/plugin.ts +50 -0
- package/src/plugins/bitwarden.ts +57 -0
- package/src/plugins/digitalOcean.ts +111 -0
- package/src/plugins/index.ts +4 -0
- package/src/plugins/rds.ts +126 -0
- package/src/plugins/snapshot.ts +44 -0
- package/src/presets/nodeApp.ts +1 -3
- package/src/recover.ts +90 -0
- package/src/secrets/store.ts +39 -20
- package/src/source.ts +20 -5
- package/src/sshHost.ts +35 -11
- package/src/steps.ts +7 -6
- package/src/types.ts +29 -12
- package/dist/secrets/bitwarden.d.ts +0 -3
- package/dist/secrets/bitwarden.d.ts.map +0 -1
- package/dist/secrets/bitwarden.js +0 -6
- package/dist/secrets/bitwarden.js.map +0 -1
- 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
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
await Promise.all(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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,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
|
+
}
|