redkite 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +313 -25
  2. package/dist/checks.d.ts.map +1 -1
  3. package/dist/checks.js +4 -0
  4. package/dist/checks.js.map +1 -1
  5. package/dist/cli/agent.d.ts +2 -0
  6. package/dist/cli/agent.d.ts.map +1 -1
  7. package/dist/cli/agent.js +23 -1
  8. package/dist/cli/agent.js.map +1 -1
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +98 -30
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +16 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/deploy.d.ts +4 -1
  16. package/dist/deploy.d.ts.map +1 -1
  17. package/dist/deploy.js +77 -23
  18. package/dist/deploy.js.map +1 -1
  19. package/dist/dockerfile.js +6 -7
  20. package/dist/dockerfile.js.map +1 -1
  21. package/dist/environment.d.ts +5 -0
  22. package/dist/environment.d.ts.map +1 -0
  23. package/dist/environment.js +20 -0
  24. package/dist/environment.js.map +1 -0
  25. package/dist/host.d.ts +2 -0
  26. package/dist/host.d.ts.map +1 -1
  27. package/dist/host.js +6 -0
  28. package/dist/host.js.map +1 -1
  29. package/dist/index.d.ts +4 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +4 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/localHost.d.ts.map +1 -1
  34. package/dist/localHost.js +2 -0
  35. package/dist/localHost.js.map +1 -1
  36. package/dist/plugin.d.ts +15 -0
  37. package/dist/plugin.d.ts.map +1 -0
  38. package/dist/plugin.js +21 -0
  39. package/dist/plugin.js.map +1 -0
  40. package/dist/plugins/bitwarden.d.ts +11 -0
  41. package/dist/plugins/bitwarden.d.ts.map +1 -0
  42. package/dist/plugins/bitwarden.js +40 -0
  43. package/dist/plugins/bitwarden.js.map +1 -0
  44. package/dist/plugins/digitalOcean.d.ts +19 -0
  45. package/dist/plugins/digitalOcean.d.ts.map +1 -0
  46. package/dist/plugins/digitalOcean.js +71 -0
  47. package/dist/plugins/digitalOcean.js.map +1 -0
  48. package/dist/plugins/index.d.ts +5 -0
  49. package/dist/plugins/index.d.ts.map +1 -0
  50. package/dist/plugins/index.js +5 -0
  51. package/dist/plugins/index.js.map +1 -0
  52. package/dist/plugins/rds.d.ts +14 -0
  53. package/dist/plugins/rds.d.ts.map +1 -0
  54. package/dist/plugins/rds.js +86 -0
  55. package/dist/plugins/rds.js.map +1 -0
  56. package/dist/plugins/snapshot.d.ts +7 -0
  57. package/dist/plugins/snapshot.d.ts.map +1 -0
  58. package/dist/plugins/snapshot.js +29 -0
  59. package/dist/plugins/snapshot.js.map +1 -0
  60. package/dist/presets/nodeApp.d.ts +1 -2
  61. package/dist/presets/nodeApp.d.ts.map +1 -1
  62. package/dist/presets/nodeApp.js +0 -1
  63. package/dist/presets/nodeApp.js.map +1 -1
  64. package/dist/recover.d.ts +19 -0
  65. package/dist/recover.d.ts.map +1 -0
  66. package/dist/recover.js +58 -0
  67. package/dist/recover.js.map +1 -0
  68. package/dist/secrets/store.d.ts +5 -2
  69. package/dist/secrets/store.d.ts.map +1 -1
  70. package/dist/secrets/store.js +25 -14
  71. package/dist/secrets/store.js.map +1 -1
  72. package/dist/sshHost.d.ts +2 -0
  73. package/dist/sshHost.d.ts.map +1 -1
  74. package/dist/sshHost.js +29 -11
  75. package/dist/sshHost.js.map +1 -1
  76. package/dist/steps.d.ts +0 -7
  77. package/dist/steps.d.ts.map +1 -1
  78. package/dist/steps.js +6 -3
  79. package/dist/steps.js.map +1 -1
  80. package/dist/types.d.ts +4 -5
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +1 -1
  83. package/src/checks.ts +4 -0
  84. package/src/cli/agent.ts +28 -1
  85. package/src/cli/index.ts +118 -29
  86. package/src/config.ts +19 -1
  87. package/src/deploy.ts +101 -29
  88. package/src/dockerfile.ts +7 -11
  89. package/src/environment.ts +24 -0
  90. package/src/host.ts +11 -0
  91. package/src/index.ts +4 -1
  92. package/src/localHost.ts +3 -0
  93. package/src/plugin.ts +50 -0
  94. package/src/plugins/bitwarden.ts +57 -0
  95. package/src/plugins/digitalOcean.ts +111 -0
  96. package/src/plugins/index.ts +4 -0
  97. package/src/plugins/rds.ts +126 -0
  98. package/src/plugins/snapshot.ts +44 -0
  99. package/src/presets/nodeApp.ts +1 -3
  100. package/src/recover.ts +90 -0
  101. package/src/secrets/store.ts +39 -20
  102. package/src/sshHost.ts +35 -11
  103. package/src/steps.ts +7 -6
  104. package/src/types.ts +22 -12
  105. package/dist/secrets/bitwarden.d.ts +0 -3
  106. package/dist/secrets/bitwarden.d.ts.map +0 -1
  107. package/dist/secrets/bitwarden.js +0 -6
  108. package/dist/secrets/bitwarden.js.map +0 -1
  109. package/src/secrets/bitwarden.ts +0 -7
@@ -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
+ }
@@ -0,0 +1,44 @@
1
+ import type { Plan } from "../pipeline.js";
2
+
3
+ // What the two snapshot plugins share. A snapshot is a control-plane call
4
+ // rather than anything the deploy host does, so it runs where the credentials
5
+ // are, which is the process running redkite.
6
+
7
+ // Hung before the swap and before any migration, so the thing being protected
8
+ // from is still ahead of it. Steps run in the order the config lists them, so a
9
+ // snapshot has to be written above the migrate it guards
10
+ export type SnapshotPoint = `swap:before:${string}`;
11
+
12
+ // Letters, digits and single hyphens, which is the intersection of what RDS
13
+ // accepts as a snapshot identifier and what a point may be named
14
+ export function slug(value: string) {
15
+ return value
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ .replace(/-{2,}/g, "-");
20
+ }
21
+
22
+ // Sortable, second-resolution, and legal in every identifier below. Two
23
+ // snapshots in one second would collide, and two deploys in one second is not
24
+ // a thing a swap can do
25
+ export function stamp(now: Date) {
26
+ return now.toISOString().replace(/[-:T]/g, "").slice(0, 14);
27
+ }
28
+
29
+ // Names the deploy it was taken for. A snapshot nobody can place is one nobody
30
+ // will dare restore
31
+ export function snapshotName(prefix: string, environment: string, now: Date) {
32
+ return slug(`${prefix}-${environment}-${stamp(now)}`);
33
+ }
34
+
35
+ // Read before the run starts, so a missing token is a config that fails while
36
+ // the host is untouched rather than a deploy that stops with the swap ahead
37
+ export function tokenFrom(name: string, plan: Plan) {
38
+ const value = process.env[name];
39
+ if (value) return value;
40
+
41
+ throw new Error(
42
+ `${name} is not set, and ${plan.config.project} snapshots its database before swapping`,
43
+ );
44
+ }
@@ -1,4 +1,4 @@
1
- import type { BuildSpec, CarryPath, SourcemapSpec } from "../types.js";
1
+ import type { BuildSpec, CarryPath } from "../types.js";
2
2
 
3
3
  type NodeAppOptions = {
4
4
  builder?: string;
@@ -12,7 +12,6 @@ type NodeAppOptions = {
12
12
  carry?: CarryPath[];
13
13
  submodules?: boolean;
14
14
  caches?: string[];
15
- sourcemaps?: SourcemapSpec;
16
15
  };
17
16
 
18
17
  export function nodeApp(options: NodeAppOptions): BuildSpec {
@@ -41,6 +40,5 @@ export function nodeApp(options: NodeAppOptions): BuildSpec {
41
40
  aptPackages: ["git"],
42
41
  runtimePackages: ["curl"],
43
42
  runtimeSteps: [],
44
- sourcemaps: options.sourcemaps,
45
43
  };
46
44
  }
package/src/recover.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { revert } from "./deploy.js";
2
+ import { Docker } from "./docker.js";
3
+ import { finalHost, type Host } from "./host.js";
4
+ import { silent, type Log } from "./log.js";
5
+ import { topologyFor, type Topology } from "./topology.js";
6
+ import type { Deployment } from "./types.js";
7
+
8
+ // Undoing a run that did not finish, from a process that is not it. A cancelled
9
+ // job is killed rather than asked, so nothing it was holding survives to tidy
10
+ // up: what the host is left in has to be readable from the host alone.
11
+
12
+ export type RecoverOptions = {
13
+ config: Deployment;
14
+ environment: string;
15
+ host: Host;
16
+ log?: Log;
17
+ };
18
+
19
+ export type RolledBack = {
20
+ // Apps put back on the live address they were serving from
21
+ restored: string[];
22
+ // Apps that were already where they belong, which is most runs
23
+ untouched: string[];
24
+ };
25
+
26
+ // A retired container is the whole signal. One exists between the swap and the
27
+ // cleanup that removes it, so finding one means a run moved an address and did
28
+ // not get to say whether that worked
29
+ export async function rollback(options: RecoverOptions): Promise<RolledBack> {
30
+ const { log, topology, docker } = opened(options);
31
+
32
+ const restored: string[] = [];
33
+ const untouched: string[] = [];
34
+
35
+ for (const app of topology.apps) {
36
+ if (!(await docker.container.exists(app.retired))) {
37
+ untouched.push(app.container);
38
+ continue;
39
+ }
40
+
41
+ log(`Putting ${app.container} back`);
42
+ await revert(docker, topology, app);
43
+ restored.push(app.container);
44
+ }
45
+
46
+ return { restored, untouched };
47
+ }
48
+
49
+ export type TakenDown = {
50
+ stopped: string[];
51
+ };
52
+
53
+ // Stopped rather than removed, so the next run adopts them where it left off.
54
+ // For an environment that exists to be built against rather than served from,
55
+ // which is the one a cancelled job should not leave running
56
+ export async function down(options: RecoverOptions): Promise<TakenDown> {
57
+ const { log, topology, docker } = opened(options);
58
+ const stopped: string[] = [];
59
+
60
+ for (const name of every(topology)) {
61
+ if (!(await docker.container.isRunning(name))) continue;
62
+
63
+ log(`Stopping ${name}`);
64
+ await docker.container.stop(name);
65
+ stopped.push(name);
66
+ }
67
+
68
+ return { stopped };
69
+ }
70
+
71
+ // Everything this environment named, whether or not it was ever created. The
72
+ // proxy is derived rather than listed, so it would be missed by walking the
73
+ // config alone
74
+ function every(topology: Topology) {
75
+ return [
76
+ ...topology.apps.flatMap((app) => [app.container, app.retired, app.failed]),
77
+ ...topology.services.map((service) => service.container),
78
+ topology.router.container,
79
+ ];
80
+ }
81
+
82
+ // Both of these exist because something was stopped, so neither may be refused
83
+ // by that same stop
84
+ function opened(options: RecoverOptions) {
85
+ return {
86
+ log: options.log ?? silent,
87
+ topology: topologyFor(options.config, options.environment),
88
+ docker: new Docker(finalHost(options.host)),
89
+ };
90
+ }
@@ -16,12 +16,11 @@ import type { SecretStore } from "./refs.js";
16
16
 
17
17
  const run = promisify(execFile);
18
18
 
19
+ // A session the caller already holds, or what it takes to obtain one. Both
20
+ // spellings in one object would be a pair of credentials nobody can tell apart
19
21
  export type BitwardenCredentials = {
20
- clientId: string;
21
- clientSecret: string;
22
- password: string;
23
22
  detail?: (message: string) => void;
24
- };
23
+ } & ({ session: string } | { clientId: string; clientSecret: string; password: string });
25
24
 
26
25
  // Used when bw is not already on PATH. Pinned, because an unpinned CLI is a
27
26
  // different program on a machine that has never run a deploy before
@@ -53,22 +52,7 @@ export async function bitwardenStore(
53
52
  maxBuffer: 32 * 1024 * 1024,
54
53
  });
55
54
 
56
- detail("unlocking the vault");
57
-
58
- // Already logged in is not an error, the session is what matters
59
- await bw(["login", "--apikey"], {
60
- BW_CLIENTID: credentials.clientId,
61
- BW_CLIENTSECRET: credentials.clientSecret,
62
- }).catch(() => undefined);
63
-
64
- const unlocked = await bw(["unlock", "--passwordenv", "BW_PASSWORD", "--raw"], {
65
- BW_PASSWORD: credentials.password,
66
- }).catch((error: unknown) => {
67
- throw new Error(`Could not unlock the Bitwarden vault: ${messageOf(error)}`);
68
- });
69
-
70
- const session = unlocked.stdout.trim();
71
- if (!session) throw new Error("Bitwarden unlocked without a session");
55
+ const session = await unlock(bw, credentials, detail);
72
56
 
73
57
  // An item added since the last deploy is not in the local vault otherwise,
74
58
  // and bw get answers "not found" rather than fetching it
@@ -97,6 +81,41 @@ export async function bitwardenStore(
97
81
  };
98
82
  }
99
83
 
84
+ type Bw = (args: string[], env?: Record<string, string>) => Promise<{ stdout: string }>;
85
+
86
+ // A session handed in is one nobody had to obtain, which is the whole of what
87
+ // a key in the environment buys: no api credentials, no master password, and
88
+ // two fewer round trips before the first item is read
89
+ async function unlock(
90
+ bw: Bw,
91
+ credentials: BitwardenCredentials,
92
+ detail: (message: string) => void,
93
+ ) {
94
+ if ("session" in credentials) {
95
+ detail("using the session it was given");
96
+ return credentials.session;
97
+ }
98
+
99
+ detail("unlocking the vault");
100
+
101
+ // Already logged in is not an error, the session is what matters
102
+ await bw(["login", "--apikey"], {
103
+ BW_CLIENTID: credentials.clientId,
104
+ BW_CLIENTSECRET: credentials.clientSecret,
105
+ }).catch(() => undefined);
106
+
107
+ const unlocked = await bw(["unlock", "--passwordenv", "BW_PASSWORD", "--raw"], {
108
+ BW_PASSWORD: credentials.password,
109
+ }).catch((error: unknown) => {
110
+ throw new Error(`Could not unlock the Bitwarden vault: ${messageOf(error)}`);
111
+ });
112
+
113
+ const session = unlocked.stdout.trim();
114
+ if (session) return session;
115
+
116
+ throw new Error("Bitwarden unlocked without a session");
117
+ }
118
+
100
119
  type Cli = { file: string; prefix: string[] };
101
120
 
102
121
  // A machine with the CLI installed pays nothing. One without it installs the
package/src/sshHost.ts CHANGED
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
3
3
 
4
4
  import type { Host, OnLine, Result } from "./host.js";
5
5
  import { quote, spawnCollect } from "./shell.js";
6
+ import type { HostKeys } from "./types.js";
6
7
 
7
8
  // The deploy host, reached over ssh. The agent is forwarded rather than a key,
8
9
  // there is nothing to forward, and the agent travels with the connection so the
@@ -23,23 +24,40 @@ export type SshOptions = {
23
24
  shell?: Shell;
24
25
  directory?: string;
25
26
  cache?: string;
27
+ // How the host's key is checked. Defaults to accept-new
28
+ hostKeys?: HostKeys;
26
29
  // Aborting kills the ssh client. The command it was carrying keeps running on
27
30
  // the other machine, which is what stop is for
28
31
  signal?: AbortSignal;
29
32
  };
30
33
 
34
+ // What ssh calls each of them. accept-new is the default rather than no: a
35
+ // deploy hands over a vault's contents, and the last thing it should do that
36
+ // to is a machine that answered to the address and nothing else
37
+ const CHECKING: Record<HostKeys, string> = {
38
+ "accept-new": "accept-new",
39
+ strict: "yes",
40
+ off: "no",
41
+ };
42
+
31
43
  // One TCP connection and one authentication for the whole deploy. Without it
32
44
  // every command pays a handshake, which is most of what a command costs
33
- const MULTIPLEX = [
34
- "-o",
35
- "ControlMaster=auto",
36
- "-o",
37
- "ControlPersist=60s",
38
- "-o",
39
- "StrictHostKeyChecking=no",
40
- // The host clones from GitHub on our behalf rather than us shipping a tree
41
- "-A",
42
- ];
45
+ function multiplex(hostKeys: HostKeys = "accept-new") {
46
+ return [
47
+ "-o",
48
+ "ControlMaster=auto",
49
+ "-o",
50
+ "ControlPersist=60s",
51
+ "-o",
52
+ `StrictHostKeyChecking=${CHECKING[hostKeys]}`,
53
+ // Never a prompt. Unknown under strict is a refusal with a reason, and a
54
+ // question nobody is there to answer is a deploy that hangs
55
+ "-o",
56
+ "BatchMode=yes",
57
+ // The host clones from GitHub on our behalf rather than us shipping a tree
58
+ "-A",
59
+ ];
60
+ }
43
61
 
44
62
  export async function sshHost(bastion: string, options: SshOptions = {}): Promise<Host> {
45
63
  const control = `/tmp/redkite-${randomUUID().slice(0, 8)}.control`;
@@ -57,8 +75,10 @@ export async function sshHost(bastion: string, options: SshOptions = {}): Promis
57
75
  options.shell ??
58
76
  ((command: string) => spawnCollect("sh", ["-c", command], { signal }));
59
77
 
78
+ const flags = multiplex(options.hostKeys);
79
+
60
80
  const argv = (command: string) => [
61
- ...MULTIPLEX,
81
+ ...flags,
62
82
  "-o",
63
83
  `ControlPath=${control}`,
64
84
  bastion,
@@ -111,6 +131,10 @@ export async function sshHost(bastion: string, options: SshOptions = {}): Promis
111
131
  pipe: async (local, remote) =>
112
132
  await shell(`${local} | ssh ${argv(remote).map(quote).join(" ")}`),
113
133
 
134
+ // Runs on the connection that is already open, and without the signal that
135
+ // killed whatever made it necessary
136
+ final: async (command) => await final(argv(command)),
137
+
114
138
  // Signalled where they are running, not here. Killing the client would
115
139
  // leave the build going with nothing left able to reach it
116
140
  stop: async (name) => {
package/src/steps.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { environmentOf } from "./config.js";
2
+ import { envFlags } from "./environment.js";
2
3
  import type { Built, Plan, Step } from "./pipeline.js";
3
4
  import type { Topology } from "./topology.js";
4
5
  import type { AppSpec, StepNetwork } from "./types.js";
@@ -48,10 +49,16 @@ export function migrate(options: MigrateOptions): Step<`swap:before:${string}`>
48
49
  const image = builderOf(input, options.app);
49
50
  context.task.detail(`${options.app}: ${options.command}`);
50
51
 
52
+ // Nothing from the vault is in the image, so a migration reads its
53
+ // database url from the environment it is given rather than from a file
54
+ // the build left behind
55
+ const app = context.config.apps.find((item) => item.name === options.app);
56
+
51
57
  await context.docker.runOrThrow(
52
58
  [
53
59
  "run --rm",
54
60
  ...attachment(options.network ?? "host", context.topology),
61
+ ...(app ? await envFlags(app, context) : []),
55
62
  "--workdir /app",
56
63
  image,
57
64
  ...command,
@@ -90,12 +97,6 @@ export function builderOf(input: Built, name: string) {
90
97
  throw new Error(`${name} has no builder image, so nothing can be run in it`);
91
98
  }
92
99
 
93
- type SentryOptions = { stripFromImage: string };
94
-
95
- export function sentry(options: SentryOptions) {
96
- return { provider: "sentry" as const, stripFromImage: options.stripFromImage };
97
- }
98
-
99
100
  export function routeOf(app: AppSpec) {
100
101
  return app.route;
101
102
  }