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.
- package/README.md +313 -25
- 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/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 +16 -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/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 +4 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/checks.ts +4 -0
- package/src/cli/agent.ts +28 -1
- package/src/cli/index.ts +118 -29
- package/src/config.ts +19 -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/sshHost.ts +35 -11
- package/src/steps.ts +7 -6
- package/src/types.ts +22 -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/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 {
|
|
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
|
-
|
|
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))
|
|
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
|
|
512
|
+
const opened: SecretStores = {};
|
|
421
513
|
|
|
422
|
-
|
|
514
|
+
for (const provider of new Set(refs.map((ref) => ref.provider))) {
|
|
515
|
+
const open = storeFor(config.plugins, provider);
|
|
423
516
|
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|