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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Log, Task } from "../log.js";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
apply,
|
|
5
|
+
elapsed,
|
|
6
|
+
emptyModel,
|
|
7
|
+
keysOf,
|
|
8
|
+
render,
|
|
9
|
+
type Model,
|
|
10
|
+
type Step,
|
|
11
|
+
} from "./screen.js";
|
|
12
|
+
|
|
13
|
+
// The terminal half: raw keys in, a frame out on a timer. Everything it decides
|
|
14
|
+
// lives in screen.ts, so this file is only the parts a test cannot run.
|
|
15
|
+
|
|
16
|
+
const FRAME_MS = 100;
|
|
17
|
+
const ALTERNATE_ON = "\u001b[?1049h\u001b[?25l";
|
|
18
|
+
const ALTERNATE_OFF = "\u001b[?25h\u001b[?1049l";
|
|
19
|
+
const HOME = "\u001b[H";
|
|
20
|
+
const CLEAR_LINE = "\u001b[K";
|
|
21
|
+
const CLEAR_BELOW = "\u001b[J";
|
|
22
|
+
|
|
23
|
+
// A build prints tens of thousands of lines and only the tail is ever read
|
|
24
|
+
const KEPT = 2000;
|
|
25
|
+
|
|
26
|
+
export type Viewer = Log & {
|
|
27
|
+
// Leaves the alternate screen and prints what happened, since nothing drawn
|
|
28
|
+
// inside it survives
|
|
29
|
+
close(): void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// A pty whose size nobody set answers 0 rather than nothing, and a view one row
|
|
33
|
+
// tall can only ever show the step the cursor is on
|
|
34
|
+
const ROWS = 24;
|
|
35
|
+
const COLUMNS = 80;
|
|
36
|
+
|
|
37
|
+
const sizeOf = (stream: NodeJS.WriteStream) => ({
|
|
38
|
+
rows: stream.rows && stream.rows > 4 ? stream.rows : ROWS,
|
|
39
|
+
columns: stream.columns && stream.columns > 20 ? stream.columns : COLUMNS,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export type ViewerOptions = {
|
|
43
|
+
// Called on the quit key. The caller decides what asking twice means, since
|
|
44
|
+
// the same request arrives as a signal when there is no view
|
|
45
|
+
onQuit?: () => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function createViewer(
|
|
49
|
+
stream: NodeJS.WriteStream,
|
|
50
|
+
input: NodeJS.ReadStream,
|
|
51
|
+
options: ViewerOptions = {},
|
|
52
|
+
): Viewer {
|
|
53
|
+
// NO_COLOR is a standing instruction rather than a preference to re-ask
|
|
54
|
+
// about, and a terminal that took the alternate screen still may not paint
|
|
55
|
+
const colour = !process.env["NO_COLOR"] && stream.isTTY === true;
|
|
56
|
+
const size = sizeOf(stream);
|
|
57
|
+
let model = emptyModel(size.rows, size.columns, Date.now());
|
|
58
|
+
let open = true;
|
|
59
|
+
|
|
60
|
+
const draw = () => {
|
|
61
|
+
if (!open) return;
|
|
62
|
+
|
|
63
|
+
model = { ...model, now: Date.now(), ...sizeOf(stream) };
|
|
64
|
+
const rows = render(model, colour).map((row) => `${row}${CLEAR_LINE}`);
|
|
65
|
+
|
|
66
|
+
stream.write(`${HOME}${rows.join("\n")}${CLEAR_BELOW}`);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const change = (next: (current: Model) => Model) => {
|
|
70
|
+
model = next(model);
|
|
71
|
+
draw();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const timer = setInterval(draw, FRAME_MS);
|
|
75
|
+
timer.unref();
|
|
76
|
+
|
|
77
|
+
stream.write(ALTERNATE_ON);
|
|
78
|
+
if (input.isTTY) input.setRawMode(true);
|
|
79
|
+
input.resume();
|
|
80
|
+
input.setEncoding("utf8");
|
|
81
|
+
|
|
82
|
+
const say = (message: string) =>
|
|
83
|
+
change((current) => ({ ...current, messages: [...current.messages, message] }));
|
|
84
|
+
|
|
85
|
+
const onKey = (data: string) => {
|
|
86
|
+
for (const key of keysOf(data)) {
|
|
87
|
+
// Not a return: two presses in one read are two asks, and the second one
|
|
88
|
+
// is what hardens the signal the first one sent
|
|
89
|
+
if (key === "quit") quit();
|
|
90
|
+
else change((current) => apply(current, key));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
input.on("data", onKey);
|
|
95
|
+
const onResize = () => draw();
|
|
96
|
+
stream.on("resize", onResize);
|
|
97
|
+
|
|
98
|
+
const close = () => {
|
|
99
|
+
if (!open) return;
|
|
100
|
+
open = false;
|
|
101
|
+
|
|
102
|
+
clearInterval(timer);
|
|
103
|
+
input.off("data", onKey);
|
|
104
|
+
stream.off("resize", onResize);
|
|
105
|
+
if (input.isTTY) input.setRawMode(false);
|
|
106
|
+
input.pause();
|
|
107
|
+
|
|
108
|
+
stream.write(ALTERNATE_OFF);
|
|
109
|
+
// The alternate screen takes every frame with it, so the run has to be
|
|
110
|
+
// written again on the screen the person keeps
|
|
111
|
+
for (const line of summary(model)) stream.write(`${line}\n`);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// What a second press means is not the view's to decide: the same key
|
|
115
|
+
// arrives as a signal when there is no view, and one answer serves both
|
|
116
|
+
const quit = () => options.onQuit?.();
|
|
117
|
+
|
|
118
|
+
process.once("exit", () => open && stream.write(ALTERNATE_OFF));
|
|
119
|
+
|
|
120
|
+
const step = (label: string): Task => {
|
|
121
|
+
let index = 0;
|
|
122
|
+
|
|
123
|
+
change((current) => {
|
|
124
|
+
index = current.steps.length;
|
|
125
|
+
|
|
126
|
+
const started: Step = {
|
|
127
|
+
label,
|
|
128
|
+
started: Date.now(),
|
|
129
|
+
state: "running",
|
|
130
|
+
lines: [],
|
|
131
|
+
// The one running is the one being read, unless the reader said no
|
|
132
|
+
expanded: !current.minimal,
|
|
133
|
+
held: false,
|
|
134
|
+
offset: 0,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
...current,
|
|
139
|
+
steps: [...current.steps, started],
|
|
140
|
+
cursor: current.following ? index : current.cursor,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const edit = (change_: (step: Step) => Step) =>
|
|
145
|
+
change((current) => ({
|
|
146
|
+
...current,
|
|
147
|
+
steps: current.steps.map((item, at) => (at === index ? change_(item) : item)),
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
// A finished step shuts, so the list stays a list. One the reader opened by
|
|
151
|
+
// hand stays open: it is being read, and closing it would move the screen
|
|
152
|
+
const settle = (state: Step["state"], note?: string) =>
|
|
153
|
+
edit((item) => ({
|
|
154
|
+
...item,
|
|
155
|
+
state,
|
|
156
|
+
note,
|
|
157
|
+
detail: undefined,
|
|
158
|
+
ended: Date.now(),
|
|
159
|
+
expanded: item.held,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
detail: (message) => edit((item) => ({ ...item, detail: message })),
|
|
164
|
+
line: (message) =>
|
|
165
|
+
edit((item) => ({ ...item, lines: [...item.lines, message].slice(-KEPT) })),
|
|
166
|
+
done: (message) => settle("done", message),
|
|
167
|
+
fail: (message) => settle("failed", message),
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return Object.assign(say, {
|
|
172
|
+
warn: say,
|
|
173
|
+
fail: say,
|
|
174
|
+
done: say,
|
|
175
|
+
step,
|
|
176
|
+
close,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// One line per step and whatever was said outside them, which is the record a
|
|
181
|
+
// person scrolls back to after the deploy is over
|
|
182
|
+
function summary(model: Model): string[] {
|
|
183
|
+
const rows = model.steps.map((step) => {
|
|
184
|
+
const glyph = step.state === "done" ? "✔" : step.state === "failed" ? "✘" : "·";
|
|
185
|
+
const said = step.note ? `: ${step.note}` : "";
|
|
186
|
+
const took = elapsed(step.ended ?? model.now, step.started);
|
|
187
|
+
|
|
188
|
+
return `${glyph} ${step.label}${said} (${took})`;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return [...rows, ...model.messages];
|
|
192
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { assertSteps } from "./pipeline.js";
|
|
2
|
+
import type { Deployment, Environment } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// The one selected, from the one place worth looking. A deployment that carries
|
|
5
|
+
// its own overrides every file, which is what makes it an override rather than
|
|
6
|
+
// a default nobody can displace
|
|
7
|
+
export function environmentOf(config: Deployment, name: string) {
|
|
8
|
+
return config.environment ?? config.environments?.[name];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Identity, but it pins the type so a missing health predicate fails to
|
|
12
|
+
// compile. Environments are excluded rather than merely unused: each lives in
|
|
13
|
+
// a redkite.<name>.config.ts of its own, and a second place to put one is a
|
|
14
|
+
// second place for them to disagree
|
|
15
|
+
export function defineDeployment<const T extends Deployment & { environments?: never }>(
|
|
16
|
+
config: T,
|
|
17
|
+
): T {
|
|
18
|
+
assertUniqueNames(config);
|
|
19
|
+
assertOneSource(config);
|
|
20
|
+
assertRoutesResolvable(config);
|
|
21
|
+
assertDirsRelative(config);
|
|
22
|
+
assertSteps(config.steps ?? []);
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Identity, for the environment a redkite.<name>.config.ts holds. It pins the
|
|
27
|
+
// type the same way defineDeployment does, so a missing subnet fails to compile
|
|
28
|
+
export function defineEnvironment<const T extends Environment>(environment: T): T {
|
|
29
|
+
return environment;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Cloned or already here, and the two are built differently enough that
|
|
33
|
+
// guessing between them is worse than being told
|
|
34
|
+
function assertOneSource(config: Deployment) {
|
|
35
|
+
for (const app of config.apps) {
|
|
36
|
+
if (app.repo && app.path) {
|
|
37
|
+
throw new Error(`${app.name} names both a repo and a path, and is built from one of them`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!app.repo && !app.path) {
|
|
41
|
+
throw new Error(`${app.name} names no source, so give it a repo to clone or a path to build`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A clone is whatever the repository holds, so there is nothing here to
|
|
45
|
+
// narrow and an include would quietly do nothing
|
|
46
|
+
if (app.include && !app.path) {
|
|
47
|
+
throw new Error(`${app.name} says what to include, but is cloned rather than built from a path`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (app.include?.length === 0) {
|
|
51
|
+
throw new Error(`${app.name} includes nothing, so there would be no build context`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A dir is joined onto /app inside the image, so an absolute one would render
|
|
57
|
+
// a path with two slashes and a climbing one would leave the checkout
|
|
58
|
+
function assertDirsRelative(config: Deployment) {
|
|
59
|
+
for (const app of config.apps) {
|
|
60
|
+
const dir = app.dir;
|
|
61
|
+
if (dir === undefined) continue;
|
|
62
|
+
|
|
63
|
+
if (!dir || dir.startsWith("/") || dir.endsWith("/")) {
|
|
64
|
+
throw new Error(`dir for ${app.name} must be a path inside the repository`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (dir.split("/").includes("..")) {
|
|
68
|
+
throw new Error(`dir for ${app.name} must not climb out of the repository`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertUniqueNames(config: Deployment) {
|
|
74
|
+
const names = [...config.apps, ...config.services].map((item) => item.name);
|
|
75
|
+
const duplicate = names.find((name, i) => names.indexOf(name) !== i);
|
|
76
|
+
if (duplicate) throw new Error(`Duplicate name in deployment: ${duplicate}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Two apps on the same route means one of them is unreachable, and which one
|
|
80
|
+
// depends on nginx location precedence rather than on anything written here
|
|
81
|
+
function assertRoutesResolvable(config: Deployment) {
|
|
82
|
+
const routes = config.apps.map((app) => app.route);
|
|
83
|
+
const duplicate = routes.find((route, i) => routes.indexOf(route) !== i);
|
|
84
|
+
if (duplicate) throw new Error(`Two apps share the route ${duplicate}`);
|
|
85
|
+
|
|
86
|
+
for (const app of config.apps) {
|
|
87
|
+
if (app.route.startsWith("/")) continue;
|
|
88
|
+
throw new Error(`Route for ${app.name} must start with a slash`);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/deploy.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { build, type BuildContext, type BuildResult } from "./build.js";
|
|
2
|
+
import { assertCheckable, runChecks } from "./checks.js";
|
|
3
|
+
import { environmentOf } from "./config.js";
|
|
4
|
+
import { Docker } from "./docker.js";
|
|
5
|
+
import { healthcheck, type HealthDeps } from "./health.js";
|
|
6
|
+
import type { Host } from "./host.js";
|
|
7
|
+
import { localHost } from "./localHost.js";
|
|
8
|
+
import { silent, type Log } from "./log.js";
|
|
9
|
+
import {
|
|
10
|
+
defineStep,
|
|
11
|
+
merge,
|
|
12
|
+
runPipeline,
|
|
13
|
+
type AnyStep,
|
|
14
|
+
type Built,
|
|
15
|
+
type BuiltApp,
|
|
16
|
+
type Context,
|
|
17
|
+
type Finished,
|
|
18
|
+
type Plan,
|
|
19
|
+
type Prepared,
|
|
20
|
+
type Released,
|
|
21
|
+
type Run,
|
|
22
|
+
type Start,
|
|
23
|
+
} from "./pipeline.js";
|
|
24
|
+
import { readEnv, readRef, type SecretStores } from "./secrets/refs.js";
|
|
25
|
+
import { ensureService } from "./services/ensure.js";
|
|
26
|
+
import { plannedServices } from "./services/planned.js";
|
|
27
|
+
import { topologyFor, type AppTopology, type Topology } from "./topology.js";
|
|
28
|
+
import type { AppSpec, Deployment } from "./types.js";
|
|
29
|
+
|
|
30
|
+
// The runs redkite offers, and the steps it supplies to them. A deploy is
|
|
31
|
+
// blue-green: bring the host up, build everything, move addresses, check,
|
|
32
|
+
// revert or stop. A verify stops after the build and runs what the apps declare
|
|
33
|
+
// instead of swapping, which is the same host and the same images without the
|
|
34
|
+
// half of it that touches what is serving.
|
|
35
|
+
//
|
|
36
|
+
// Nothing here is privileged. Each of these is an ordinary step at an ordinary
|
|
37
|
+
// point, and a deployment that registers its own at the same point replaces it.
|
|
38
|
+
|
|
39
|
+
export type DeployOptions = {
|
|
40
|
+
config: Deployment;
|
|
41
|
+
environment: string;
|
|
42
|
+
// The machine the containers run on, and which now builds them too
|
|
43
|
+
host: Host;
|
|
44
|
+
// One store per provider named by a ref in the config
|
|
45
|
+
secrets: SecretStores;
|
|
46
|
+
// How the probe waits between attempts. Absent means a real wait, which is
|
|
47
|
+
// what everything but a test wants
|
|
48
|
+
health?: Omit<HealthDeps, "probe">;
|
|
49
|
+
log?: Log;
|
|
50
|
+
// Prints what each build wrote, line by line as it runs
|
|
51
|
+
verbose?: boolean;
|
|
52
|
+
// Stops the run. Whatever command is in flight is killed, and the pipeline
|
|
53
|
+
// unwinds through its own failure path rather than through an exit
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export async function deploy(options: DeployOptions): Promise<Finished> {
|
|
58
|
+
return await start("deploy", options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The same host, the same services and the same images, stopping where a deploy
|
|
62
|
+
// would start moving addresses. What runs instead is what each app declares
|
|
63
|
+
export async function verify(options: DeployOptions): Promise<Finished> {
|
|
64
|
+
return await start("verify", options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function start(run: Run, options: DeployOptions): Promise<Finished> {
|
|
68
|
+
const log = options.log ?? silent;
|
|
69
|
+
|
|
70
|
+
const setting: Omit<Context, "task"> = {
|
|
71
|
+
config: options.config,
|
|
72
|
+
environment: options.environment,
|
|
73
|
+
topology: topologyFor(options.config, options.environment),
|
|
74
|
+
host: options.host,
|
|
75
|
+
docker: new Docker(options.host),
|
|
76
|
+
secrets: options.secrets,
|
|
77
|
+
log,
|
|
78
|
+
run,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const steps = merge(supplied(options), options.config.steps ?? []);
|
|
82
|
+
return await runPipeline(run, steps, setting, options.signal);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Every step redkite supplies, in the order the phases name. A run walks the
|
|
86
|
+
// phases it has, so the ones it does not are never ordered in
|
|
87
|
+
function supplied(options: DeployOptions): AnyStep[] {
|
|
88
|
+
return [
|
|
89
|
+
defineStep("setup", prepare),
|
|
90
|
+
defineStep("build", (input, context) =>
|
|
91
|
+
compile(input, context, options.verbose ?? false, options.signal),
|
|
92
|
+
),
|
|
93
|
+
defineStep("verify", runChecks, assertCheckable),
|
|
94
|
+
defineStep(
|
|
95
|
+
"swap",
|
|
96
|
+
(input, context) => release(input, context, options.health ?? { sleep: wait }),
|
|
97
|
+
assertPublishable,
|
|
98
|
+
),
|
|
99
|
+
defineStep("cleanup", finish),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
104
|
+
|
|
105
|
+
// A deploy publishes the proxy on a port, and an environment written for verify
|
|
106
|
+
// alone has no reason to name one. Checked before the run so the mistake lands
|
|
107
|
+
// before a build rather than on a proxy nobody outside can reach
|
|
108
|
+
function assertPublishable(plan: Plan) {
|
|
109
|
+
if (environmentOf(plan.config, plan.environment)?.publicPort) return;
|
|
110
|
+
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${plan.environment} names no publicPort, so a deploy has nothing to publish ` +
|
|
113
|
+
"the proxy on. Only a verify run goes without one",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The network and the services, which outlive a run and are only built when
|
|
118
|
+
// they are missing. This used to overlap the builds, and now costs the round
|
|
119
|
+
// trips it takes rather than nothing, which is what buys a setup step a host
|
|
120
|
+
// it can rely on
|
|
121
|
+
async function prepare(input: Start, context: Context): Promise<Prepared> {
|
|
122
|
+
const { docker, topology } = context;
|
|
123
|
+
|
|
124
|
+
await docker.network.create(topology.network, topology.cidr);
|
|
125
|
+
const services = await ensureServices(context);
|
|
126
|
+
|
|
127
|
+
return { ...input, network: topology.network, services };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Every image is built before anything is disturbed. A build failure has to
|
|
131
|
+
// leave the running deployment exactly as it was, which is why this is a step
|
|
132
|
+
// of its own rather than the first half of the swap
|
|
133
|
+
async function compile(
|
|
134
|
+
input: Prepared,
|
|
135
|
+
context: Context,
|
|
136
|
+
verbose: boolean,
|
|
137
|
+
signal?: AbortSignal,
|
|
138
|
+
): Promise<Built> {
|
|
139
|
+
// One checkout directory for every app, opened here so the build step owns
|
|
140
|
+
// its lifetime rather than the process
|
|
141
|
+
const here = buildsHere(context) ? await localHost({ signal }) : undefined;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
return { ...input, apps: (await buildAll(context, verbose, here)).map(describe) };
|
|
145
|
+
} finally {
|
|
146
|
+
await here?.close?.();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Building here and deploying here are the same daemon, so shipping would be a
|
|
151
|
+
// save and a load of an image that never moved. An app built from a path on
|
|
152
|
+
// this machine has no say in it: the source is here, so the build is too
|
|
153
|
+
function buildsHere(context: Context) {
|
|
154
|
+
const environment = environmentOf(context.config, context.environment);
|
|
155
|
+
if (!environment?.host?.bastion) return false;
|
|
156
|
+
|
|
157
|
+
return environment.buildOn === "local" || context.config.apps.some((app) => app.path);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function release(
|
|
161
|
+
input: Built,
|
|
162
|
+
context: Context,
|
|
163
|
+
health: Omit<HealthDeps, "probe">,
|
|
164
|
+
): Promise<Released> {
|
|
165
|
+
const { docker, task, topology } = context;
|
|
166
|
+
const apps = context.config.apps.map((app) => appOf(topology, app.name));
|
|
167
|
+
|
|
168
|
+
// Keyed by name rather than by index: appOf resolves the topology, and two
|
|
169
|
+
// lists walked in step is a bug waiting for someone to reorder one
|
|
170
|
+
const environments = new Map(
|
|
171
|
+
context.config.apps.map((app) => [app.name, app.environment ?? {}]),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
task.detail("retiring the running containers");
|
|
175
|
+
await Promise.all(apps.map((app) => retire(docker, topology, app)));
|
|
176
|
+
|
|
177
|
+
task.detail("creating the new ones");
|
|
178
|
+
|
|
179
|
+
await Promise.all(
|
|
180
|
+
apps.map((app) =>
|
|
181
|
+
create(docker, topology, app, environments.get(app.name)),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
task.detail("starting them");
|
|
186
|
+
await Promise.all(apps.map((app) => docker.container.start(app.container)));
|
|
187
|
+
|
|
188
|
+
// One unhealthy app reverts all of them. A half-swapped deployment is the
|
|
189
|
+
// one state nothing downstream knows how to reason about
|
|
190
|
+
if (!(await checkAll(context, health))) {
|
|
191
|
+
context.log.fail("Health checks failed, reverting");
|
|
192
|
+
await Promise.all(apps.map((app) => revert(docker, topology, app)));
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
...input,
|
|
196
|
+
ok: false,
|
|
197
|
+
released: [],
|
|
198
|
+
reverted: apps.map((app) => app.container),
|
|
199
|
+
checked: [],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
...input,
|
|
205
|
+
ok: true,
|
|
206
|
+
released: apps.map((app) => app.container),
|
|
207
|
+
reverted: [],
|
|
208
|
+
checked: [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A build leaves its image on the host rather than sending one, so without this
|
|
213
|
+
// every run adds a runtime image and a builder to a disk nobody is watching
|
|
214
|
+
async function finish(input: Released, context: Context): Promise<Finished> {
|
|
215
|
+
// What would be removed is what is serving, and the images are what a retry
|
|
216
|
+
// would be built from
|
|
217
|
+
if (!input.ok) return { ...input, removed: [], reclaimed: [] };
|
|
218
|
+
|
|
219
|
+
const { docker, topology } = context;
|
|
220
|
+
const apps = context.config.apps.map((app) => appOf(topology, app.name));
|
|
221
|
+
|
|
222
|
+
const removed = await Promise.all(apps.map((app) => cleanup(docker, app)));
|
|
223
|
+
const reclaimed = await Promise.all(input.apps.map((app) => reclaim(docker, app)));
|
|
224
|
+
|
|
225
|
+
return { ...input, removed: removed.flat(), reclaimed: reclaimed.flat() };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function describe({ app, result }: Built0): BuiltApp {
|
|
229
|
+
return {
|
|
230
|
+
name: app.name,
|
|
231
|
+
container: result.tag.split(":")[0] ?? app.name,
|
|
232
|
+
release: result.release,
|
|
233
|
+
fingerprint: result.fingerprint,
|
|
234
|
+
cached: result.cached,
|
|
235
|
+
builderTag: result.builderTag,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function checkAll(context: Context, health: Omit<HealthDeps, "probe">) {
|
|
240
|
+
const { docker, log, topology } = context;
|
|
241
|
+
|
|
242
|
+
const results = await Promise.all(
|
|
243
|
+
context.config.apps.map(async (app) => {
|
|
244
|
+
const target = appOf(topology, app.name);
|
|
245
|
+
const deps: HealthDeps = {
|
|
246
|
+
...health,
|
|
247
|
+
probe: async (container, url) => {
|
|
248
|
+
const result = await docker.run(`exec ${container} curl -s ${url}`);
|
|
249
|
+
return { code: result.code, output: result.stdout };
|
|
250
|
+
},
|
|
251
|
+
log,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return await healthcheck(target.container, target.port, app.health, deps);
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return !results.includes(false);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The proxy is one of these, derived rather than listed. What each should be
|
|
262
|
+
// is worked out in one place, so a plan reports drift against the same shape a
|
|
263
|
+
// deploy converges to
|
|
264
|
+
async function ensureServices(context: Context) {
|
|
265
|
+
const planned = plannedServices(context.config, context.topology, context.run);
|
|
266
|
+
|
|
267
|
+
return await Promise.all(
|
|
268
|
+
planned.map(async (item) => {
|
|
269
|
+
await ensureService(item.spec, item.service, {
|
|
270
|
+
...context,
|
|
271
|
+
files: item.files,
|
|
272
|
+
publish: item.publish,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return item.service.container;
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
type Built0 = { app: AppSpec; result: BuildResult };
|
|
281
|
+
|
|
282
|
+
async function buildAll(
|
|
283
|
+
context: Context,
|
|
284
|
+
verbose: boolean,
|
|
285
|
+
here?: Host,
|
|
286
|
+
): Promise<Built0[]> {
|
|
287
|
+
const { config, log, topology } = context;
|
|
288
|
+
const environment = environmentOf(config, context.environment);
|
|
289
|
+
|
|
290
|
+
if (!environment) throw new Error(`Unknown environment ${context.environment}`);
|
|
291
|
+
|
|
292
|
+
return await Promise.all(
|
|
293
|
+
config.apps.map(async (app) => {
|
|
294
|
+
const task = log.step(`Building ${app.name}`);
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const buildContext: BuildContext = {
|
|
298
|
+
host: here ?? context.host,
|
|
299
|
+
docker: here ? new Docker(here) : context.docker,
|
|
300
|
+
deliver: here && { host: context.host, docker: context.docker },
|
|
301
|
+
env: await readEnv(app.secrets, context.secrets),
|
|
302
|
+
files: await resolveFiles(app, context.secrets),
|
|
303
|
+
branch: environment.branch,
|
|
304
|
+
environment: context.environment,
|
|
305
|
+
detail: task.detail,
|
|
306
|
+
output: task.line,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const result = await build(app, appOf(topology, app.name), buildContext);
|
|
310
|
+
task.done(`${result.release.slice(0, 7)}${result.cached ? " (held)" : ""}`);
|
|
311
|
+
|
|
312
|
+
return { app, result };
|
|
313
|
+
} catch (error) {
|
|
314
|
+
task.fail(`${app.name} failed to build`);
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function resolveFiles(app: AppSpec, stores: SecretStores) {
|
|
322
|
+
const entries = await Promise.all(
|
|
323
|
+
Object.entries(app.files ?? {}).map(
|
|
324
|
+
async ([path, ref]) => [path, await readRef(ref, stores)] as const,
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
return Object.fromEntries(entries);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Move the running container out of the way without stopping it, so it keeps
|
|
332
|
+
// answering on the retired address while the new one starts
|
|
333
|
+
async function retire(docker: Docker, topology: Topology, app: AppTopology) {
|
|
334
|
+
await docker.container.stop(app.retired);
|
|
335
|
+
await docker.container.remove(app.retired);
|
|
336
|
+
await docker.network.reconnect(topology.network, app.container, app.retiredAddress);
|
|
337
|
+
await docker.container.rename(app.container, app.retired);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function create(
|
|
341
|
+
docker: Docker,
|
|
342
|
+
topology: Topology,
|
|
343
|
+
app: AppTopology,
|
|
344
|
+
environment: Record<string, string> = {},
|
|
345
|
+
) {
|
|
346
|
+
const builder = docker.container
|
|
347
|
+
.builder()
|
|
348
|
+
.name(app.container)
|
|
349
|
+
.image(app.container)
|
|
350
|
+
.network(topology.network)
|
|
351
|
+
.ip(app.currentAddress)
|
|
352
|
+
.restart("unless-stopped");
|
|
353
|
+
|
|
354
|
+
for (const [host, ip] of Object.entries(topology.extraHosts)) {
|
|
355
|
+
// An app resolving its own name to the live address would loop
|
|
356
|
+
if (host === app.container || host === app.retired) continue;
|
|
357
|
+
builder.extraHost(host, ip);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for (const volume of app.volumes) builder.volume(volume.volume, volume.mountPath);
|
|
361
|
+
for (const [name, value] of Object.entries(environment)) builder.env(name, value);
|
|
362
|
+
|
|
363
|
+
await builder.create();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Put the retired container back on the live address and its original name
|
|
367
|
+
async function revert(docker: Docker, topology: Topology, app: AppTopology) {
|
|
368
|
+
await docker.container.stop(app.container);
|
|
369
|
+
await docker.container.rename(app.container, app.failed);
|
|
370
|
+
await docker.network.reconnect(topology.network, app.retired, app.currentAddress);
|
|
371
|
+
await docker.container.rename(app.retired, app.container);
|
|
372
|
+
await docker.container.start(app.container);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function cleanup(docker: Docker, app: AppTopology) {
|
|
376
|
+
const removed: string[] = [];
|
|
377
|
+
|
|
378
|
+
for (const name of [app.retired, app.failed]) {
|
|
379
|
+
await docker.container.stop(name);
|
|
380
|
+
if (await docker.container.remove(name)) removed.push(name);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return removed;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Every version of this app's images except the one that was just released,
|
|
387
|
+
// including the builder a step before the swap ran in
|
|
388
|
+
async function reclaim(docker: Docker, app: BuiltApp) {
|
|
389
|
+
const version = `${app.release}-${app.fingerprint}`;
|
|
390
|
+
const reclaimed: string[] = [];
|
|
391
|
+
|
|
392
|
+
for (const repository of [app.container, `${app.container}-builder`]) {
|
|
393
|
+
for (const held of await docker.image.versionsOf(repository)) {
|
|
394
|
+
if (held === `${repository}:${version}`) continue;
|
|
395
|
+
|
|
396
|
+
await docker.image.remove(held);
|
|
397
|
+
reclaimed.push(held);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return reclaimed;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function appOf(topology: Topology, name: string) {
|
|
405
|
+
const app = topology.apps.find((item) => item.name === name);
|
|
406
|
+
if (app) return app;
|
|
407
|
+
|
|
408
|
+
throw new Error(`No topology for app ${name}`);
|
|
409
|
+
}
|