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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/presets/nodeApp.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BuildSpec, CarryPath
|
|
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
|
+
}
|
package/src/secrets/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
...
|
|
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
|
}
|