redkite 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +343 -26
- package/dist/build.d.ts +2 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +11 -1
- package/dist/build.js.map +1 -1
- package/dist/checks.d.ts.map +1 -1
- package/dist/checks.js +4 -0
- package/dist/checks.js.map +1 -1
- package/dist/cli/agent.d.ts +2 -0
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/agent.js +23 -1
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +15 -2
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +98 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -1
- package/dist/config.js.map +1 -1
- package/dist/deploy.d.ts +4 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +77 -23
- package/dist/deploy.js.map +1 -1
- package/dist/dockerfile.js +6 -7
- package/dist/dockerfile.js.map +1 -1
- package/dist/environment.d.ts +5 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +20 -0
- package/dist/environment.js.map +1 -0
- package/dist/host.d.ts +2 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +6 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/localHost.d.ts.map +1 -1
- package/dist/localHost.js +2 -0
- package/dist/localHost.js.map +1 -1
- package/dist/plugin.d.ts +15 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +21 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugins/bitwarden.d.ts +11 -0
- package/dist/plugins/bitwarden.d.ts.map +1 -0
- package/dist/plugins/bitwarden.js +40 -0
- package/dist/plugins/bitwarden.js.map +1 -0
- package/dist/plugins/digitalOcean.d.ts +19 -0
- package/dist/plugins/digitalOcean.d.ts.map +1 -0
- package/dist/plugins/digitalOcean.js +71 -0
- package/dist/plugins/digitalOcean.js.map +1 -0
- package/dist/plugins/index.d.ts +5 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +5 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/rds.d.ts +14 -0
- package/dist/plugins/rds.d.ts.map +1 -0
- package/dist/plugins/rds.js +86 -0
- package/dist/plugins/rds.js.map +1 -0
- package/dist/plugins/snapshot.d.ts +7 -0
- package/dist/plugins/snapshot.d.ts.map +1 -0
- package/dist/plugins/snapshot.js +29 -0
- package/dist/plugins/snapshot.js.map +1 -0
- package/dist/presets/nodeApp.d.ts +1 -2
- package/dist/presets/nodeApp.d.ts.map +1 -1
- package/dist/presets/nodeApp.js +0 -1
- package/dist/presets/nodeApp.js.map +1 -1
- package/dist/recover.d.ts +19 -0
- package/dist/recover.d.ts.map +1 -0
- package/dist/recover.js +58 -0
- package/dist/recover.js.map +1 -0
- package/dist/secrets/store.d.ts +5 -2
- package/dist/secrets/store.d.ts.map +1 -1
- package/dist/secrets/store.js +25 -14
- package/dist/secrets/store.js.map +1 -1
- package/dist/source.d.ts +6 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +12 -4
- package/dist/source.js.map +1 -1
- package/dist/sshHost.d.ts +2 -0
- package/dist/sshHost.d.ts.map +1 -1
- package/dist/sshHost.js +29 -11
- package/dist/sshHost.js.map +1 -1
- package/dist/steps.d.ts +0 -7
- package/dist/steps.d.ts.map +1 -1
- package/dist/steps.js +6 -3
- package/dist/steps.js.map +1 -1
- package/dist/types.d.ts +7 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +12 -1
- package/src/checks.ts +4 -0
- package/src/cli/agent.ts +28 -1
- package/src/cli/config.ts +19 -2
- package/src/cli/index.ts +118 -29
- package/src/config.ts +27 -1
- package/src/deploy.ts +101 -29
- package/src/dockerfile.ts +7 -11
- package/src/environment.ts +24 -0
- package/src/host.ts +11 -0
- package/src/index.ts +4 -1
- package/src/localHost.ts +3 -0
- package/src/plugin.ts +50 -0
- package/src/plugins/bitwarden.ts +57 -0
- package/src/plugins/digitalOcean.ts +111 -0
- package/src/plugins/index.ts +4 -0
- package/src/plugins/rds.ts +126 -0
- package/src/plugins/snapshot.ts +44 -0
- package/src/presets/nodeApp.ts +1 -3
- package/src/recover.ts +90 -0
- package/src/secrets/store.ts +39 -20
- package/src/source.ts +20 -5
- package/src/sshHost.ts +35 -11
- package/src/steps.ts +7 -6
- package/src/types.ts +29 -12
- package/dist/secrets/bitwarden.d.ts +0 -3
- package/dist/secrets/bitwarden.d.ts.map +0 -1
- package/dist/secrets/bitwarden.js +0 -6
- package/dist/secrets/bitwarden.js.map +0 -1
- package/src/secrets/bitwarden.ts +0 -7
|
@@ -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/source.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type Source = {
|
|
|
13
13
|
tree: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
// What a clone is resolved against. A branch is tracked and a tag or a commit
|
|
17
|
+
// is pinned, and which of the three it is decides where git is asked to look
|
|
18
|
+
export type RefKind = "branch" | "tag" | "commit";
|
|
19
|
+
|
|
20
|
+
export type Ref = { kind: RefKind; name: string };
|
|
21
|
+
|
|
16
22
|
export type SourceRequest = {
|
|
17
23
|
// Names the mirror and the checkout. Keyed per app and environment rather
|
|
18
24
|
// than per repository, so two deploys never fetch into one directory at once
|
|
@@ -23,11 +29,19 @@ export type SourceRequest = {
|
|
|
23
29
|
// What of a path goes into the build. Absent leaves it to the work tree's
|
|
24
30
|
// own .gitignore, which is the only other thing that can say
|
|
25
31
|
include?: string[];
|
|
26
|
-
|
|
32
|
+
ref: Ref;
|
|
27
33
|
submodules: boolean;
|
|
28
34
|
detail?: (message: string) => void;
|
|
29
35
|
};
|
|
30
36
|
|
|
37
|
+
// Where each kind lives. A tag is peeled, so an annotated one answers with the
|
|
38
|
+
// commit it points at rather than with the tag object
|
|
39
|
+
const REVISION: Record<RefKind, (name: string) => string> = {
|
|
40
|
+
branch: (name) => `refs/heads/${name}`,
|
|
41
|
+
tag: (name) => `refs/tags/${name}^{commit}`,
|
|
42
|
+
commit: (name) => `${name}^{commit}`,
|
|
43
|
+
};
|
|
44
|
+
|
|
31
45
|
// accept-new rather than no: a host key that changes is still worth refusing
|
|
32
46
|
const GIT_SSH = "ssh -o StrictHostKeyChecking=accept-new";
|
|
33
47
|
|
|
@@ -110,8 +124,9 @@ async function clonedSource(
|
|
|
110
124
|
const detail = request.detail ?? (() => {});
|
|
111
125
|
const mirror = `${host.cache}/mirrors/${request.name}.git`;
|
|
112
126
|
const path = `${host.cache}/source/${request.name}`;
|
|
127
|
+
const { kind, name } = request.ref;
|
|
113
128
|
|
|
114
|
-
detail(`fetching ${
|
|
129
|
+
detail(`fetching ${name}`);
|
|
115
130
|
await run(host, `updating the mirror of ${repo}`, [
|
|
116
131
|
`if [ -d '${mirror}' ]; then`,
|
|
117
132
|
` git -C '${mirror}' remote set-url origin '${repo}'`,
|
|
@@ -122,13 +137,13 @@ async function clonedSource(
|
|
|
122
137
|
"fi",
|
|
123
138
|
]);
|
|
124
139
|
|
|
125
|
-
const resolved = await run(host, `resolving ${
|
|
126
|
-
`git -C '${mirror}' rev-parse '
|
|
140
|
+
const resolved = await run(host, `resolving ${name}`, [
|
|
141
|
+
`git -C '${mirror}' rev-parse '${REVISION[kind](name)}'`,
|
|
127
142
|
]);
|
|
128
143
|
|
|
129
144
|
const release = resolved.stdout.trim();
|
|
130
145
|
if (!/^[0-9a-f]{40}$/.test(release)) {
|
|
131
|
-
throw new Error(`${repo} has no
|
|
146
|
+
throw new Error(`${repo} has no ${kind} ${name}`);
|
|
132
147
|
}
|
|
133
148
|
|
|
134
149
|
detail(`checking out ${release.slice(0, 7)}`);
|
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
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AnyStep } from "./pipeline.js";
|
|
2
|
+
import type { Plugin } from "./plugin.js";
|
|
2
3
|
|
|
3
4
|
// The shapes a deploy config is written against. Nothing here knows about
|
|
4
5
|
// Docker or git, so a config can be planned and asserted on without a host to
|
|
@@ -33,11 +34,26 @@ export type Environment = {
|
|
|
33
34
|
// the alias the apps know it by
|
|
34
35
|
export type StepNetwork = "host" | "deployment" | "none" | { named: string };
|
|
35
36
|
|
|
37
|
+
// How the deploy host proves it is the machine it says it is, before a key,
|
|
38
|
+
// an environment file or an image is handed to it
|
|
39
|
+
export type HostKeys =
|
|
40
|
+
// Trusted the first time and refused if the key ever changes after that,
|
|
41
|
+
// which is what a host on a stable address wants and needs no setup
|
|
42
|
+
| "accept-new"
|
|
43
|
+
// Refused unless it is already in known_hosts. Nothing is taken on trust,
|
|
44
|
+
// at the cost of putting the key there before the first deploy
|
|
45
|
+
| "strict"
|
|
46
|
+
// Checked not at all. For a host whose address is handed out again and
|
|
47
|
+
// again, and only where nothing on the way to it can be listened to
|
|
48
|
+
| "off";
|
|
49
|
+
|
|
36
50
|
export type DeployHost = {
|
|
37
51
|
// SSH destination, user@address
|
|
38
52
|
bastion: string;
|
|
39
53
|
// Socket path on that machine. Local when absent, deploying to this one
|
|
40
54
|
socket?: string;
|
|
55
|
+
// Defaults to accept-new
|
|
56
|
+
hostKeys?: HostKeys;
|
|
41
57
|
};
|
|
42
58
|
|
|
43
59
|
// One item in a store, named at the point of use rather than through a lookup
|
|
@@ -127,16 +143,6 @@ export type BuildSpec = {
|
|
|
127
143
|
// Shell commands run in the runtime image, for what a package manager cannot
|
|
128
144
|
// install. Above the output copy, so a new commit does not repeat them
|
|
129
145
|
runtimeSteps: string[];
|
|
130
|
-
// Uploads source maps during the build, then deletes them from the image
|
|
131
|
-
sourcemaps?: SourcemapSpec;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
export type SourcemapSpec = {
|
|
135
|
-
// Which service receives the maps, "sentry" is the only one today
|
|
136
|
-
provider: "sentry";
|
|
137
|
-
// Env var stripped from the shipped file, it is a build-time credential and
|
|
138
|
-
// has no reason to travel inside the image
|
|
139
|
-
stripFromImage: string;
|
|
140
146
|
};
|
|
141
147
|
|
|
142
148
|
// How a build is decided to work. The commands run in the builder image, which
|
|
@@ -193,6 +199,13 @@ export type AppSpec = {
|
|
|
193
199
|
// Environment for the app. Several are merged in the order written, so a
|
|
194
200
|
// shared ref can come first and a per-app one override it
|
|
195
201
|
secrets?: SecretRefs;
|
|
202
|
+
// What this app is built from, overriding the environment's branch. At most
|
|
203
|
+
// one of the three. A branch is tracked, so every deploy takes whatever its
|
|
204
|
+
// head is by then; a tag or a commit is pinned, and the app stops moving
|
|
205
|
+
// until this line does. Ignored by an app built from a path
|
|
206
|
+
branch?: string;
|
|
207
|
+
tag?: string;
|
|
208
|
+
commit?: string;
|
|
196
209
|
// Where the app sits in the repository, for a monorepo whose root is not it.
|
|
197
210
|
// Build steps and the shipped command run there. The dependency install does
|
|
198
211
|
// not: a workspace lockfile is resolved at the root for every package at once
|
|
@@ -237,7 +250,11 @@ export type Deployment = {
|
|
|
237
250
|
// The applications. Everything derived, addresses, container names, nginx
|
|
238
251
|
// upstreams and location blocks, is a function of this list
|
|
239
252
|
apps: AppSpec[];
|
|
240
|
-
// Work injected into the run
|
|
241
|
-
//
|
|
253
|
+
// Work injected into the run. Addressed by where it runs, so nothing here
|
|
254
|
+
// has to be called
|
|
242
255
|
steps?: AnyStep[];
|
|
256
|
+
// Everything this deployment opts into: the vault that answers its secret
|
|
257
|
+
// refs, and whatever else brings steps of its own. Nothing a plugin carries
|
|
258
|
+
// happens until it is listed here, redkite's own vault included
|
|
259
|
+
plugins?: Plugin[];
|
|
243
260
|
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"bitwarden.d.ts","sourceRoot":"","sources":["../../src/secrets/bitwarden.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAI7C,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,CAE/C"}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// Names an item in a Bitwarden vault. The id is not a secret, it is a pointer,
|
|
2
|
-
// so it belongs in the config while the credentials arrive at deploy time
|
|
3
|
-
export function bitwarden(id) {
|
|
4
|
-
return { provider: "bitwarden", id };
|
|
5
|
-
}
|
|
6
|
-
//# sourceMappingURL=bitwarden.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"bitwarden.js","sourceRoot":"","sources":["../../src/secrets/bitwarden.ts"],"names":[],"mappings":"AAEA,+EAA+E;AAC/E,0EAA0E;AAC1E,MAAM,UAAU,SAAS,CAAC,EAAU;IAClC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC;AACvC,CAAC"}
|
package/src/secrets/bitwarden.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { SecretRef } from "../types.js";
|
|
2
|
-
|
|
3
|
-
// Names an item in a Bitwarden vault. The id is not a secret, it is a pointer,
|
|
4
|
-
// so it belongs in the config while the credentials arrive at deploy time
|
|
5
|
-
export function bitwarden(id: string): SecretRef {
|
|
6
|
-
return { provider: "bitwarden", id };
|
|
7
|
-
}
|