redkite 0.1.6
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/LICENSE +21 -0
- package/README.md +819 -0
- package/bin/redkite.mjs +12 -0
- package/dist/build.d.ts +27 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +132 -0
- package/dist/build.js.map +1 -0
- package/dist/checks.d.ts +4 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +49 -0
- package/dist/checks.js.map +1 -0
- package/dist/cli/agent.d.ts +2 -0
- package/dist/cli/agent.d.ts.map +1 -0
- package/dist/cli/agent.js +24 -0
- package/dist/cli/agent.js.map +1 -0
- package/dist/cli/config.d.ts +5 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +217 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +438 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/log.d.ts +9 -0
- package/dist/cli/log.d.ts.map +1 -0
- package/dist/cli/log.js +128 -0
- package/dist/cli/log.js.map +1 -0
- package/dist/cli/screen.d.ts +34 -0
- package/dist/cli/screen.d.ts.map +1 -0
- package/dist/cli/screen.js +298 -0
- package/dist/cli/screen.js.map +1 -0
- package/dist/cli/viewer.d.ts +9 -0
- package/dist/cli/viewer.d.ts.map +1 -0
- package/dist/cli/viewer.js +139 -0
- package/dist/cli/viewer.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +79 -0
- package/dist/config.js.map +1 -0
- package/dist/deploy.d.ts +19 -0
- package/dist/deploy.d.ts.map +1 -0
- package/dist/deploy.js +275 -0
- package/dist/deploy.js.map +1 -0
- package/dist/docker.d.ts +96 -0
- package/dist/docker.d.ts.map +1 -0
- package/dist/docker.js +325 -0
- package/dist/docker.js.map +1 -0
- package/dist/dockerfile.d.ts +14 -0
- package/dist/dockerfile.d.ts.map +1 -0
- package/dist/dockerfile.js +160 -0
- package/dist/dockerfile.js.map +1 -0
- package/dist/health.d.ts +13 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +54 -0
- package/dist/health.js.map +1 -0
- package/dist/host.d.ts +21 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +27 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +5 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +45 -0
- package/dist/layout.js.map +1 -0
- package/dist/localHost.d.ts +7 -0
- package/dist/localHost.d.ts.map +1 -0
- package/dist/localHost.js +32 -0
- package/dist/localHost.js.map +1 -0
- package/dist/log.d.ts +14 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +15 -0
- package/dist/log.js.map +1 -0
- package/dist/nginx.d.ts +4 -0
- package/dist/nginx.d.ts.map +1 -0
- package/dist/nginx.js +53 -0
- package/dist/nginx.js.map +1 -0
- package/dist/pipeline.d.ts +103 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +142 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/presets/index.d.ts +3 -0
- package/dist/presets/index.d.ts.map +1 -0
- package/dist/presets/index.js +3 -0
- package/dist/presets/index.js.map +1 -0
- package/dist/presets/nextApp.d.ts +15 -0
- package/dist/presets/nextApp.d.ts.map +1 -0
- package/dist/presets/nextApp.js +53 -0
- package/dist/presets/nextApp.js.map +1 -0
- package/dist/presets/nodeApp.d.ts +19 -0
- package/dist/presets/nodeApp.d.ts.map +1 -0
- package/dist/presets/nodeApp.js +29 -0
- package/dist/presets/nodeApp.js.map +1 -0
- package/dist/secrets/bitwarden.d.ts +3 -0
- package/dist/secrets/bitwarden.d.ts.map +1 -0
- package/dist/secrets/bitwarden.js +6 -0
- package/dist/secrets/bitwarden.js.map +1 -0
- package/dist/secrets/refs.d.ts +9 -0
- package/dist/secrets/refs.d.ts.map +1 -0
- package/dist/secrets/refs.js +20 -0
- package/dist/secrets/refs.js.map +1 -0
- package/dist/secrets/store.d.ts +9 -0
- package/dist/secrets/store.d.ts.map +1 -0
- package/dist/secrets/store.js +104 -0
- package/dist/secrets/store.js.map +1 -0
- package/dist/services/ensure.d.ts +18 -0
- package/dist/services/ensure.d.ts.map +1 -0
- package/dist/services/ensure.js +93 -0
- package/dist/services/ensure.js.map +1 -0
- package/dist/services/index.d.ts +19 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +27 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/planned.d.ts +20 -0
- package/dist/services/planned.d.ts.map +1 -0
- package/dist/services/planned.js +77 -0
- package/dist/services/planned.js.map +1 -0
- package/dist/shell.d.ts +11 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +65 -0
- package/dist/shell.js.map +1 -0
- package/dist/source.d.ts +16 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +108 -0
- package/dist/source.js.map +1 -0
- package/dist/sshHost.d.ts +15 -0
- package/dist/sshHost.d.ts.map +1 -0
- package/dist/sshHost.js +91 -0
- package/dist/sshHost.js.map +1 -0
- package/dist/steps.d.ts +27 -0
- package/dist/steps.d.ts.map +1 -0
- package/dist/steps.js +69 -0
- package/dist/steps.js.map +1 -0
- package/dist/topology.d.ts +40 -0
- package/dist/topology.d.ts.map +1 -0
- package/dist/topology.js +113 -0
- package/dist/topology.js.map +1 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
- package/src/build.ts +236 -0
- package/src/checks.ts +65 -0
- package/src/cli/agent.ts +28 -0
- package/src/cli/config.ts +275 -0
- package/src/cli/index.ts +559 -0
- package/src/cli/log.ts +168 -0
- package/src/cli/screen.ts +409 -0
- package/src/cli/viewer.ts +192 -0
- package/src/config.ts +90 -0
- package/src/deploy.ts +409 -0
- package/src/docker.ts +433 -0
- package/src/dockerfile.ts +228 -0
- package/src/health.ts +83 -0
- package/src/host.ts +54 -0
- package/src/index.ts +25 -0
- package/src/layout.ts +41 -0
- package/src/localHost.ts +50 -0
- package/src/log.ts +38 -0
- package/src/nginx.ts +63 -0
- package/src/pipeline.ts +335 -0
- package/src/presets/index.ts +2 -0
- package/src/presets/nextApp.ts +70 -0
- package/src/presets/nodeApp.ts +46 -0
- package/src/secrets/bitwarden.ts +7 -0
- package/src/secrets/refs.ts +32 -0
- package/src/secrets/store.ts +147 -0
- package/src/services/ensure.ts +160 -0
- package/src/services/index.ts +48 -0
- package/src/services/planned.ts +119 -0
- package/src/shell.ts +95 -0
- package/src/source.ts +171 -0
- package/src/sshHost.ts +145 -0
- package/src/steps.ts +101 -0
- package/src/topology.ts +199 -0
- package/src/types.ts +243 -0
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { environmentOf } from "../config.js";
|
|
2
|
+
import { deploy, verify } from "../deploy.js";
|
|
3
|
+
import { Docker } from "../docker.js";
|
|
4
|
+
import type { Host } from "../host.js";
|
|
5
|
+
import { localHost } from "../localHost.js";
|
|
6
|
+
import { silent, type Log } from "../log.js";
|
|
7
|
+
import { renderNginx } from "../nginx.js";
|
|
8
|
+
import { addressOf, RUNS, SLOTS, type Run } from "../pipeline.js";
|
|
9
|
+
import { listRefs, type SecretStores } from "../secrets/refs.js";
|
|
10
|
+
import { bitwardenStore } from "../secrets/store.js";
|
|
11
|
+
import { driftOf, plannedServices, type Drift } from "../services/planned.js";
|
|
12
|
+
import { sshHost } from "../sshHost.js";
|
|
13
|
+
import { topologyFor, type Topology } from "../topology.js";
|
|
14
|
+
import type { Deployment, DeployHost } from "../types.js";
|
|
15
|
+
|
|
16
|
+
import { requireAgent } from "./agent.js";
|
|
17
|
+
import { loadConfig } from "./config.js";
|
|
18
|
+
import { createLog, describeFailure } from "./log.js";
|
|
19
|
+
|
|
20
|
+
// One command that reads the config and does everything under it: the agent,
|
|
21
|
+
// the vault, the checkout, the build, the swap and the cleanup.
|
|
22
|
+
|
|
23
|
+
const USAGE = `redkite <command> [environment]
|
|
24
|
+
|
|
25
|
+
plan [environment] Print the derived topology, the pipeline and the nginx
|
|
26
|
+
deploy [environment] Build, swap, health check, and revert on failure
|
|
27
|
+
verify [environment] Bring the services up, build, and run each app's checks
|
|
28
|
+
|
|
29
|
+
--config <path> Defaults to redkite.config.ts at the root of the project
|
|
30
|
+
--local Build the images here and ship them to the host
|
|
31
|
+
--full No step view: every line of every step, in full
|
|
32
|
+
--verbose Every host command, and every line a build printed
|
|
33
|
+
--version Print the version and exit
|
|
34
|
+
|
|
35
|
+
Environment defaults to staging. A deployment is one redkite.config.ts at the root
|
|
36
|
+
of the project, and everything below it is derived.
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
// Wraps a host rather than living inside one, so both implementations are
|
|
40
|
+
// measured the same way and neither knows it is being timed
|
|
41
|
+
function measured(host: Host, log?: Log) {
|
|
42
|
+
const totals = { commands: 0, commandMs: 0, files: 0 };
|
|
43
|
+
|
|
44
|
+
const wrapped: Host = {
|
|
45
|
+
directory: host.directory,
|
|
46
|
+
cache: host.cache,
|
|
47
|
+
pipe: host.pipe.bind(host),
|
|
48
|
+
stop: host.stop.bind(host),
|
|
49
|
+
|
|
50
|
+
sh: async (command, onLine) => {
|
|
51
|
+
const started = Date.now();
|
|
52
|
+
const result = await host.sh(command, onLine);
|
|
53
|
+
|
|
54
|
+
totals.commands += 1;
|
|
55
|
+
totals.commandMs += Date.now() - started;
|
|
56
|
+
|
|
57
|
+
// The exit code matters: several of these are allowed to fail, and a
|
|
58
|
+
// deploy reading verbose output is one where somebody wants to know which
|
|
59
|
+
log?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
|
|
60
|
+
return result;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
write: async (name, contents) => {
|
|
64
|
+
totals.files += 1;
|
|
65
|
+
return await host.write(name, contents);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
close: host.close?.bind(host),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const summary = () =>
|
|
72
|
+
`${totals.commands} commands in ${seconds(totals.commandMs)}, ` +
|
|
73
|
+
`${totals.files} files written`;
|
|
74
|
+
|
|
75
|
+
return { host: wrapped, summary };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function seconds(ms: number) {
|
|
79
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Read rather than generated into the build, so a published package and a
|
|
83
|
+
// linked checkout answer with the same thing
|
|
84
|
+
async function version() {
|
|
85
|
+
const manifest = new URL("../../package.json", import.meta.url);
|
|
86
|
+
const { readFile } = await import("node:fs/promises");
|
|
87
|
+
const { version: found } = JSON.parse(await readFile(manifest, "utf8")) as {
|
|
88
|
+
version: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Flags that take a value, so the value is not mistaken for the environment
|
|
95
|
+
const VALUED = new Set(["--config"]);
|
|
96
|
+
|
|
97
|
+
function flag(argv: string[], name: string) {
|
|
98
|
+
const joined = argv.find((arg) => arg.startsWith(`${name}=`));
|
|
99
|
+
if (joined) return joined.slice(name.length + 1);
|
|
100
|
+
|
|
101
|
+
const at = argv.indexOf(name);
|
|
102
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function positional(argv: string[]) {
|
|
106
|
+
const args: string[] = [];
|
|
107
|
+
|
|
108
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
109
|
+
const arg = argv[index]!;
|
|
110
|
+
|
|
111
|
+
if (!arg.startsWith("--")) {
|
|
112
|
+
args.push(arg);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Only the two-word form consumes the next item. --flag=value does not
|
|
117
|
+
if (VALUED.has(arg)) index += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return args;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function main(argv: string[]) {
|
|
124
|
+
try {
|
|
125
|
+
return await dispatch(argv);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// A deploy reports its own failure through the live view. Everything before
|
|
128
|
+
// one has started, a config that will not load above all, lands here
|
|
129
|
+
process.stderr.write(`${describeFailure(error)}\n`);
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function dispatch(argv: string[]) {
|
|
135
|
+
const args = positional(argv);
|
|
136
|
+
const command = args[0] ?? "";
|
|
137
|
+
const environment = args[1] ?? "staging";
|
|
138
|
+
|
|
139
|
+
if (argv.includes("--version") || command === "version") {
|
|
140
|
+
return process.stdout.write(`${await version()}\n`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const configPath = flag(argv, "--config");
|
|
144
|
+
|
|
145
|
+
if (command === "plan") return await plan(environment, configPath);
|
|
146
|
+
if (command === "deploy" || command === "verify") {
|
|
147
|
+
return await run(command, environment, configPath, {
|
|
148
|
+
verbose: argv.includes("--verbose"),
|
|
149
|
+
full: argv.includes("--full"),
|
|
150
|
+
local: argv.includes("--local"),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
process.stdout.write(USAGE);
|
|
155
|
+
process.exitCode = command ? 1 : 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function plan(environment: string, configPath?: string) {
|
|
159
|
+
const config = await loadConfig(configPath);
|
|
160
|
+
const topology = topologyFor(config, environment);
|
|
161
|
+
|
|
162
|
+
const say = (message = "") => process.stdout.write(`${message}\n`);
|
|
163
|
+
|
|
164
|
+
say(`# ${config.project} · ${environment}\n`);
|
|
165
|
+
say(`network ${topology.network} ${topology.cidr}`);
|
|
166
|
+
say(`branch ${topology.branch}`);
|
|
167
|
+
// A verify environment publishes nothing, because nothing in one serves
|
|
168
|
+
say(
|
|
169
|
+
topology.publicPort
|
|
170
|
+
? `published ${topology.publicPort} -> ${topology.router.container} ${topology.router.address}\n`
|
|
171
|
+
: "published nothing, this environment has no publicPort\n",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const sources = new Map(config.apps.map((app) => [app.name, app.path ?? app.repo]));
|
|
175
|
+
|
|
176
|
+
for (const app of topology.apps) {
|
|
177
|
+
say(`app ${app.name}`);
|
|
178
|
+
say(` source ${sources.get(app.name) ?? "?"}`);
|
|
179
|
+
say(` current ${app.container} ${app.currentAddress}:${app.port}`);
|
|
180
|
+
say(` retired ${app.retired} ${app.retiredAddress}`);
|
|
181
|
+
say(` route ${app.route}`);
|
|
182
|
+
|
|
183
|
+
for (const volume of app.volumes) {
|
|
184
|
+
say(` volume ${volume.volume} -> ${volume.mountPath}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const [name, key] of Object.entries(app.caches)) {
|
|
188
|
+
say(` cache ${name} -> ${key}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
say();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const service of topology.services) {
|
|
195
|
+
say(`service ${service.name} ${service.container} ${service.address}`);
|
|
196
|
+
|
|
197
|
+
for (const volume of service.volumes) {
|
|
198
|
+
say(` volume ${volume.volume} -> ${volume.mountPath}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// An environment that publishes nothing cannot serve, so verify is the only
|
|
203
|
+
// run it supports. Nothing declares that: the missing port is what says it
|
|
204
|
+
const serves = Boolean(topology.publicPort);
|
|
205
|
+
|
|
206
|
+
sayPipeline(config, serves, say);
|
|
207
|
+
await sayDrift(config, topology, serves ? "deploy" : "verify", say);
|
|
208
|
+
|
|
209
|
+
if (!serves) return;
|
|
210
|
+
|
|
211
|
+
say(`\n# rendered nginx ${"-".repeat(44)}\n`);
|
|
212
|
+
say(renderNginx(topology, config.maxBodySize));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// What is running against what this file says should be. Services outlive a
|
|
216
|
+
// deploy, so this is the only part of a plan that cannot be answered from the
|
|
217
|
+
// config alone, and the only part that needs the host
|
|
218
|
+
async function sayDrift(
|
|
219
|
+
config: Deployment,
|
|
220
|
+
topology: Topology,
|
|
221
|
+
run: Run,
|
|
222
|
+
say: (message?: string) => void,
|
|
223
|
+
) {
|
|
224
|
+
say(`\nservices on the host (${run})`);
|
|
225
|
+
|
|
226
|
+
const bastion = environmentOf(config, topology.environment)?.host;
|
|
227
|
+
let host: Host | undefined;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
host = await hostFor(bastion, silent);
|
|
231
|
+
const planned = plannedServices(config, topology, run);
|
|
232
|
+
const drifted = await driftOf(planned, topology, new Docker(host));
|
|
233
|
+
|
|
234
|
+
if (drifted.length === 0) return say(" every service is what this file says");
|
|
235
|
+
for (const drift of drifted) say(` ${drift.container.padEnd(34)}${DRIFT[drift.reason]}`);
|
|
236
|
+
say(`\n a ${run} converges these`);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
// A plan is worth printing without a host. Saying so beats printing
|
|
239
|
+
// nothing, and beats printing a clean bill of health nobody checked
|
|
240
|
+
say(` not checked: ${describeFailure(error).split("\n")[0]}`);
|
|
241
|
+
} finally {
|
|
242
|
+
await host?.close?.();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const DRIFT: Record<Drift["reason"], string> = {
|
|
247
|
+
missing: "not there, will be created",
|
|
248
|
+
stopped: "created from this file, but not running",
|
|
249
|
+
changed: "created from an earlier version of this file",
|
|
250
|
+
unrecognised: "not created by redkite, will be recreated",
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// What each run will actually do, in the order it will do it. A step is
|
|
254
|
+
// addressed rather than called, so this is the only place a sequence is visible
|
|
255
|
+
function sayPipeline(
|
|
256
|
+
config: Deployment,
|
|
257
|
+
serves: boolean,
|
|
258
|
+
say: (message?: string) => void,
|
|
259
|
+
) {
|
|
260
|
+
const steps = config.steps ?? [];
|
|
261
|
+
const added = new Set(steps.map((step) => step.point));
|
|
262
|
+
|
|
263
|
+
const at = (phase: string, slot: string) =>
|
|
264
|
+
steps.filter((step) => {
|
|
265
|
+
const address = addressOf(step.point);
|
|
266
|
+
return address.phase === phase && address.slot === slot && step.point !== phase;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const checked = config.apps.filter((app) => app.verify).map((app) => app.name);
|
|
270
|
+
|
|
271
|
+
// Both refusals live on a step's check, so a run printed here is one this
|
|
272
|
+
// environment can actually be asked for
|
|
273
|
+
const runs = Object.entries(RUNS).filter(
|
|
274
|
+
([run]) => (run === "verify" ? checked.length > 0 : serves),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (runs.length === 0) {
|
|
278
|
+
return say(
|
|
279
|
+
"\nno pipeline. This environment publishes nothing, so it cannot deploy, " +
|
|
280
|
+
"and no app declares verify",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (const [run, phases] of runs) {
|
|
285
|
+
say(`\npipeline (${run})`);
|
|
286
|
+
|
|
287
|
+
for (const phase of phases) {
|
|
288
|
+
for (const slot of SLOTS) {
|
|
289
|
+
// Redkite's own step leads its phase's slot. A deployment that puts one
|
|
290
|
+
// at the same point replaces it, and this is where that shows
|
|
291
|
+
if (slot === "main") {
|
|
292
|
+
const who = added.has(phase) ? "replaced" : "redkite";
|
|
293
|
+
const what = phase === "verify" ? ` ${checked.join(", ")}` : "";
|
|
294
|
+
say(` ${phase.padEnd(26)}${who}${what}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const step of at(phase, slot)) say(` ${step.point}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Five presses is a build that has ignored SIGKILL, which is a process nothing
|
|
304
|
+
// here can end. The way out is offered rather than taken, because taking it is
|
|
305
|
+
// the one thing that leaves work running with nothing watching it
|
|
306
|
+
const ASK_AT = 5;
|
|
307
|
+
|
|
308
|
+
// The first ask is polite, every one after it is not. What a press does is
|
|
309
|
+
// choose the signal the wait keeps sending, and only the last one leaves
|
|
310
|
+
export function stopper(on: {
|
|
311
|
+
abort: () => void;
|
|
312
|
+
say: (message: string) => void;
|
|
313
|
+
signal: (name: "TERM" | "KILL") => void;
|
|
314
|
+
leave: () => void;
|
|
315
|
+
}) {
|
|
316
|
+
let presses = 0;
|
|
317
|
+
|
|
318
|
+
return () => {
|
|
319
|
+
presses += 1;
|
|
320
|
+
|
|
321
|
+
if (presses === 1) {
|
|
322
|
+
on.say("Stopping the build (SIGTERM). Press again to kill it");
|
|
323
|
+
on.abort();
|
|
324
|
+
return on.signal("TERM");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (presses < ASK_AT) {
|
|
328
|
+
on.say("Killing the build (SIGKILL). Nothing exits until it is gone");
|
|
329
|
+
return on.signal("KILL");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (presses === ASK_AT) {
|
|
333
|
+
for (const line of REFUSING) on.say(line);
|
|
334
|
+
return on.signal("KILL");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
on.leave();
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// What leaving costs, said before it is offered rather than after it is done
|
|
342
|
+
const REFUSING = [
|
|
343
|
+
"This is not stopping. It has had SIGKILL and is still there.",
|
|
344
|
+
"Press again to leave redkite. That does not stop it: the build keeps running",
|
|
345
|
+
"on the host, may finish and tag an image no deploy is waiting for, and holds",
|
|
346
|
+
"the CPU and disk it is using. Nothing will clean up after it but you.",
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
// A stopped deploy is not over until what it started is gone. The host is asked
|
|
350
|
+
// again and again because the answer is a count, and zero is the only one that
|
|
351
|
+
// ends this
|
|
352
|
+
async function gone(
|
|
353
|
+
host: Host,
|
|
354
|
+
hardest: () => "TERM" | "KILL",
|
|
355
|
+
say: (message: string) => void,
|
|
356
|
+
) {
|
|
357
|
+
let said = 0;
|
|
358
|
+
|
|
359
|
+
for (let left = await host.stop(hardest()); left > 0; left = await host.stop(hardest())) {
|
|
360
|
+
if (left !== said) {
|
|
361
|
+
say(`Waiting for ${left} still running`);
|
|
362
|
+
said = left;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const POLL_MS = 400;
|
|
370
|
+
|
|
371
|
+
// --local says it for this run alone, without the config having to change. It
|
|
372
|
+
// lands wherever the environment came from, so an inline one moves too
|
|
373
|
+
export function buildingHere(config: Deployment, environment: string): Deployment {
|
|
374
|
+
if (config.environment) {
|
|
375
|
+
return { ...config, environment: { ...config.environment, buildOn: "local" } };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const declared = config.environments?.[environment];
|
|
379
|
+
if (!declared) return config;
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
...config,
|
|
383
|
+
environments: {
|
|
384
|
+
...config.environments,
|
|
385
|
+
[environment]: { ...declared, buildOn: "local" },
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Without a bastion the containers are on this machine, and every command the
|
|
391
|
+
// deploy issues is one this process can run itself
|
|
392
|
+
async function hostFor(host: DeployHost | undefined, log: Log, signal?: AbortSignal) {
|
|
393
|
+
if (!host?.bastion) return await localHost({ signal });
|
|
394
|
+
|
|
395
|
+
const task = log.step(`Opening a connection to ${host.bastion}`);
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const opened = await sshHost(host.bastion, { signal });
|
|
399
|
+
task.done();
|
|
400
|
+
return opened;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
task.fail(`Could not reach ${host.bastion}`);
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Only the stores the config actually names. A deployment that keeps its
|
|
408
|
+
// environment somewhere else, or nowhere, needs no credentials to deploy
|
|
409
|
+
async function openStores(config: Deployment, log: Log): Promise<SecretStores> {
|
|
410
|
+
// Services name refs too, and a deployment whose only secret is a database
|
|
411
|
+
// password opened no store at all until this counted them
|
|
412
|
+
const refs = [
|
|
413
|
+
...config.apps.flatMap((app) => [
|
|
414
|
+
...listRefs(app.secrets),
|
|
415
|
+
...Object.values(app.files ?? {}),
|
|
416
|
+
]),
|
|
417
|
+
...config.services.flatMap((service) => listRefs(service.secrets)),
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
const providers = new Set(refs.map((ref) => ref.provider));
|
|
421
|
+
|
|
422
|
+
if (!providers.has("bitwarden")) return {};
|
|
423
|
+
|
|
424
|
+
const vault = log.step("Reading the vault");
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const bitwarden = await bitwardenStore({
|
|
428
|
+
detail: vault.detail,
|
|
429
|
+
clientId: required("BW_CLIENT_ID"),
|
|
430
|
+
clientSecret: required("BW_CLIENT_SECRET"),
|
|
431
|
+
password: required("BW_PASSWORD"),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
vault.done();
|
|
435
|
+
return { bitwarden };
|
|
436
|
+
} catch (error) {
|
|
437
|
+
vault.fail("Could not read the vault");
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function required(name: string) {
|
|
443
|
+
const value = process.env[name];
|
|
444
|
+
if (value) return value;
|
|
445
|
+
|
|
446
|
+
throw new Error(
|
|
447
|
+
`${name} is not set, and this deployment reads its environment from Bitwarden`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
type RunOptions = { verbose: boolean; full: boolean; local: boolean };
|
|
452
|
+
|
|
453
|
+
async function run(
|
|
454
|
+
kind: Run,
|
|
455
|
+
environment: string,
|
|
456
|
+
configPath: string | undefined,
|
|
457
|
+
options: RunOptions,
|
|
458
|
+
) {
|
|
459
|
+
const config = await loadConfig(configPath);
|
|
460
|
+
|
|
461
|
+
// Fail on a missing environment before opening anything for it
|
|
462
|
+
const topology = topologyFor(config, environment);
|
|
463
|
+
const deployHost = environmentOf(config, environment)?.host;
|
|
464
|
+
|
|
465
|
+
// The host clones the repositories over this, so it has to exist before the
|
|
466
|
+
// connection that forwards it is opened. Before the view too: ssh-add asks
|
|
467
|
+
// for a passphrase on the terminal, and by then the view owns it
|
|
468
|
+
requireAgent((message) => process.stderr.write(`${message}\n`));
|
|
469
|
+
|
|
470
|
+
// Whatever is in flight is killed, the pipeline unwinds through its own
|
|
471
|
+
// failure path, and the finally below removes the scratch directory
|
|
472
|
+
const stopping = new AbortController();
|
|
473
|
+
|
|
474
|
+
const log = createLog({
|
|
475
|
+
...options,
|
|
476
|
+
onQuit: () => stop(),
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Read by the wait below, so a press during it hardens what that is sending
|
|
480
|
+
let hardest: "TERM" | "KILL" = "TERM";
|
|
481
|
+
|
|
482
|
+
const stop = stopper({
|
|
483
|
+
say: log.warn,
|
|
484
|
+
abort: () => stopping.abort(),
|
|
485
|
+
signal: (name) => {
|
|
486
|
+
hardest = name;
|
|
487
|
+
// Nothing waits on this: the wait is in the finally, and this is what
|
|
488
|
+
// makes the command in flight answer so the run can get there
|
|
489
|
+
void host?.stop(name);
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
leave: () => {
|
|
493
|
+
log.warn("Leaving. What is still running on the host is yours to stop");
|
|
494
|
+
log.close();
|
|
495
|
+
process.exit(130);
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// The viewer reads ctrl+c as a key, because it holds the terminal in raw
|
|
500
|
+
// mode. Everything else arrives here as a signal
|
|
501
|
+
process.on("SIGINT", stop);
|
|
502
|
+
|
|
503
|
+
let host: Host | undefined;
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const meter = measured(
|
|
507
|
+
await hostFor(deployHost, log, stopping.signal),
|
|
508
|
+
options.verbose ? log : undefined,
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
host = meter.host;
|
|
512
|
+
|
|
513
|
+
const start = kind === "verify" ? verify : deploy;
|
|
514
|
+
|
|
515
|
+
const result = await start({
|
|
516
|
+
config: options.local ? buildingHere(config, environment) : config,
|
|
517
|
+
environment,
|
|
518
|
+
host,
|
|
519
|
+
secrets: await openStores(config, log),
|
|
520
|
+
verbose: options.verbose,
|
|
521
|
+
signal: stopping.signal,
|
|
522
|
+
log,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
log(`Host: ${meter.summary()}`);
|
|
526
|
+
|
|
527
|
+
if (kind === "verify") {
|
|
528
|
+
log.done(`Checked ${result.checked.join(", ")} in ${topology.environment}`);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (result.ok) {
|
|
533
|
+
log.done(`Deployed ${result.released.join(", ")} to ${topology.environment}`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
log.fail(`Reverted ${result.reverted.join(", ")}`);
|
|
538
|
+
process.exitCode = 1;
|
|
539
|
+
} catch (error) {
|
|
540
|
+
// Whatever the command in flight said about being killed is noise: what
|
|
541
|
+
// happened is that somebody asked for it to stop
|
|
542
|
+
if (stopping.signal.aborted) {
|
|
543
|
+
log.fail("Stopped");
|
|
544
|
+
process.exitCode = 130;
|
|
545
|
+
} else {
|
|
546
|
+
log.fail(describeFailure(error));
|
|
547
|
+
process.exitCode = 1;
|
|
548
|
+
}
|
|
549
|
+
} finally {
|
|
550
|
+
// Nothing leaves while the build is still running. The signal a press
|
|
551
|
+
// chose is resent every time round, so pressing again hardens it
|
|
552
|
+
if (stopping.signal.aborted && host) await gone(host, () => hardest, log.warn);
|
|
553
|
+
|
|
554
|
+
await host?.close?.();
|
|
555
|
+
// Leaves the alternate screen and writes the run out on the screen the
|
|
556
|
+
// person keeps, which is also what lets the process exit
|
|
557
|
+
log.close();
|
|
558
|
+
}
|
|
559
|
+
}
|