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/health.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { silent, type Log } from "./log.js";
|
|
2
|
+
import type { HealthSpec } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const RETRIES = 10;
|
|
5
|
+
// Ceiling for the backoff, not a fixed wait
|
|
6
|
+
const INTERVAL_MS = 5000;
|
|
7
|
+
// The first probe is free, a container that is already up answers it
|
|
8
|
+
const DELAY_MS = 0;
|
|
9
|
+
const FIRST_BACKOFF_MS = 250;
|
|
10
|
+
|
|
11
|
+
export type Probe = (
|
|
12
|
+
container: string,
|
|
13
|
+
url: string,
|
|
14
|
+
) => Promise<{ code: number; output: string }>;
|
|
15
|
+
|
|
16
|
+
export type HealthDeps = {
|
|
17
|
+
probe: Probe;
|
|
18
|
+
sleep: (ms: number) => Promise<void>;
|
|
19
|
+
log?: Log;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// One loop for every app. The two copies it replaces had already drifted: the
|
|
23
|
+
// frontend never incremented its counter on a failed predicate, so a container
|
|
24
|
+
// that answered but never became healthy span until the deploy was killed.
|
|
25
|
+
export async function healthcheck(
|
|
26
|
+
container: string,
|
|
27
|
+
port: number,
|
|
28
|
+
spec: HealthSpec,
|
|
29
|
+
deps: HealthDeps,
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
const retries = spec.retries ?? RETRIES;
|
|
32
|
+
const ceiling = spec.intervalMs ?? INTERVAL_MS;
|
|
33
|
+
const log = deps.log ?? silent;
|
|
34
|
+
const delay = spec.delayMs ?? DELAY_MS;
|
|
35
|
+
|
|
36
|
+
if (delay > 0) await deps.sleep(delay);
|
|
37
|
+
|
|
38
|
+
let backoff = FIRST_BACKOFF_MS;
|
|
39
|
+
|
|
40
|
+
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
41
|
+
const url = `localhost:${port}${spec.path}`;
|
|
42
|
+
const { code, output } = await deps.probe(container, url);
|
|
43
|
+
|
|
44
|
+
if (code === 0 && passes(spec, output, log, container)) {
|
|
45
|
+
log.done(`${container} is healthy`);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (attempt === retries) break;
|
|
50
|
+
|
|
51
|
+
// Doubling from a quarter second means a container that starts quickly
|
|
52
|
+
// costs almost nothing, and one that does not still gets the same budget
|
|
53
|
+
await deps.sleep(backoff);
|
|
54
|
+
backoff = Math.min(backoff * 2, ceiling);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log.fail(`${container} failed its health check after ${retries} attempts`);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A body that parses but fails the predicate is a retry, not a verdict. The
|
|
62
|
+
// container may still be warming up, and the caller has a retry budget for it
|
|
63
|
+
function passes(
|
|
64
|
+
spec: HealthSpec,
|
|
65
|
+
output: string,
|
|
66
|
+
log: Log,
|
|
67
|
+
container: string,
|
|
68
|
+
) {
|
|
69
|
+
let body: unknown;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
body = JSON.parse(output);
|
|
73
|
+
} catch {
|
|
74
|
+
log.warn(`${container} returned a body that is not JSON: ${output}`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof body !== "object" || body === null) return false;
|
|
79
|
+
if (spec.expect(body as Record<string, unknown>)) return true;
|
|
80
|
+
|
|
81
|
+
log.warn(`${container} is not healthy yet: ${output}`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The machine the containers run on. Every command a deploy issues goes through
|
|
2
|
+
// this, so the rest of the library is exercised against a recorder rather than
|
|
3
|
+
// against a host. Two implementations: one over ssh, one on this machine.
|
|
4
|
+
|
|
5
|
+
export type Result = { code: number; stdout: string; stderr: string };
|
|
6
|
+
|
|
7
|
+
// Called per line while a command runs, for the ones long enough that the person
|
|
8
|
+
// watching needs to know what they are waiting for
|
|
9
|
+
export type OnLine = (line: string) => void;
|
|
10
|
+
|
|
11
|
+
export type Host = {
|
|
12
|
+
// A shell command, run where the containers are. The shell is what lets a
|
|
13
|
+
// caller redirect, chain, and quote, which several of them do
|
|
14
|
+
sh(command: string, onLine?: OnLine): Promise<Result>;
|
|
15
|
+
// Writes contents to a file there, answering with the path it landed at.
|
|
16
|
+
// Parent directories are created, so a name may contain slashes
|
|
17
|
+
write(name: string, contents: string): Promise<string>;
|
|
18
|
+
// Scratch space this host hands out, removed when the deploy closes
|
|
19
|
+
readonly directory: string;
|
|
20
|
+
// Survives a deploy: the git mirrors and the checkouts built from them
|
|
21
|
+
readonly cache: string;
|
|
22
|
+
// Streams one command's output into another's input, the near side local and
|
|
23
|
+
// the far side here. Shipping an image is the only caller
|
|
24
|
+
pipe(local: string, remote: string, onLine?: OnLine): Promise<Result>;
|
|
25
|
+
// Signals everything this host started and answers with how many are still
|
|
26
|
+
// running. Zero is the only answer that means nothing was left behind
|
|
27
|
+
stop(signal: "TERM" | "KILL"): Promise<number>;
|
|
28
|
+
close?(): Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// A streamed command keeps this many lines, so a build that prints tens of
|
|
32
|
+
// megabytes still fits in the error a failure reports
|
|
33
|
+
const TAIL = 200;
|
|
34
|
+
|
|
35
|
+
export function tail(lines: string[]) {
|
|
36
|
+
return lines.slice(-TAIL).join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Splits a chunked stream into lines, holding the partial last one back
|
|
40
|
+
export function lineReader(onLine: OnLine) {
|
|
41
|
+
let rest = "";
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
push(chunk: string) {
|
|
45
|
+
const parts = (rest + chunk).split("\n");
|
|
46
|
+
rest = parts.pop() ?? "";
|
|
47
|
+
for (const line of parts) onLine(line);
|
|
48
|
+
},
|
|
49
|
+
flush() {
|
|
50
|
+
if (rest) onLine(rest);
|
|
51
|
+
rest = "";
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from "./build.js";
|
|
2
|
+
export * from "./config.js";
|
|
3
|
+
export * from "./deploy.js";
|
|
4
|
+
export * from "./docker.js";
|
|
5
|
+
export * from "./dockerfile.js";
|
|
6
|
+
export * from "./health.js";
|
|
7
|
+
export * from "./host.js";
|
|
8
|
+
export * from "./layout.js";
|
|
9
|
+
export * from "./localHost.js";
|
|
10
|
+
export * from "./log.js";
|
|
11
|
+
export * from "./nginx.js";
|
|
12
|
+
export * from "./pipeline.js";
|
|
13
|
+
export * from "./presets/index.js";
|
|
14
|
+
export * from "./secrets/bitwarden.js";
|
|
15
|
+
export * from "./secrets/refs.js";
|
|
16
|
+
export * from "./secrets/store.js";
|
|
17
|
+
export * from "./shell.js";
|
|
18
|
+
export * from "./services/ensure.js";
|
|
19
|
+
export * from "./services/index.js";
|
|
20
|
+
export * from "./services/planned.js";
|
|
21
|
+
export * from "./source.js";
|
|
22
|
+
export * from "./sshHost.js";
|
|
23
|
+
export * from "./steps.js";
|
|
24
|
+
export * from "./topology.js";
|
|
25
|
+
export * from "./types.js";
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Where a cache is mounted and where a carried directory lands. The renderer
|
|
2
|
+
// and the topology have to agree on these, and a second copy of them is a
|
|
3
|
+
// drift waiting to happen.
|
|
4
|
+
|
|
5
|
+
// A package manager's own cache is global; everything else is a path inside the
|
|
6
|
+
// project. Both managers are listed because a preset does not know which the
|
|
7
|
+
// repository uses, and mounting the wrong one is a cache that never hits
|
|
8
|
+
export function mountFor(name: string, dir?: string) {
|
|
9
|
+
if (name === "yarn") return "/root/.yarn";
|
|
10
|
+
if (name === "npm") return "/root/.npm";
|
|
11
|
+
// A workspace resolves one lockfile at the repository root and hoists there
|
|
12
|
+
if (name === "modules") return "/app/node_modules";
|
|
13
|
+
// An app that installs against its own manifest puts them beside it instead.
|
|
14
|
+
// Both are mounted, because only the install command knows which it does, and
|
|
15
|
+
// mounting the wrong one alone is a cache that never hits
|
|
16
|
+
if (name === "app-modules") return `${appRoot(dir)}/node_modules`;
|
|
17
|
+
if (name === "next-app") return rootedAt("/app/.next/cache", dir);
|
|
18
|
+
return rootedAt(`/app/.cache/${name}`, dir);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Where the app itself sits. The whole repository is copied to /app, and dir is
|
|
22
|
+
// the app's own place in it
|
|
23
|
+
export function appRoot(dir?: string) {
|
|
24
|
+
return dir ? `/app/${dir}` : "/app";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A path under /app belongs to the app, so it moves with it. Bare /app is the
|
|
28
|
+
// repository itself and stays: that is what a build shipping its whole tree copies
|
|
29
|
+
export function rootedAt(path: string, dir?: string) {
|
|
30
|
+
if (!dir || !path.startsWith("/app/")) return path;
|
|
31
|
+
return `${appRoot(dir)}/${path.slice("/app/".length)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A directory inside the output root moves with it, because the output becomes
|
|
35
|
+
// /app in the runtime. Anything else keeps the position it had in the builder:
|
|
36
|
+
// flattening it to a basename is what put Next's static assets at /app/static,
|
|
37
|
+
// where the server looks for them under .next and answers 404
|
|
38
|
+
export function destinationFor(path: string, output: string) {
|
|
39
|
+
if (path.startsWith(`${output}/`)) return `/app/${path.slice(output.length + 1)}`;
|
|
40
|
+
return path;
|
|
41
|
+
}
|
package/src/localHost.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { Host } from "./host.js";
|
|
6
|
+
import { signalEverything, spawnCollect } from "./shell.js";
|
|
7
|
+
|
|
8
|
+
// The same Host without a network in the way, for an environment that names no
|
|
9
|
+
// bastion. Deploying to this machine and deploying to another one differ in
|
|
10
|
+
// nothing but which of these the CLI constructs.
|
|
11
|
+
|
|
12
|
+
export type LocalOptions = { cache?: string; signal?: AbortSignal };
|
|
13
|
+
|
|
14
|
+
export async function localHost(options: LocalOptions = {}): Promise<Host> {
|
|
15
|
+
const directory = await mkdtemp(join(tmpdir(), "redkite-"));
|
|
16
|
+
const cache = options.cache ?? join(homedir(), ".cache", "redkite");
|
|
17
|
+
|
|
18
|
+
await mkdir(cache, { recursive: true });
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
directory,
|
|
22
|
+
cache,
|
|
23
|
+
sh: async (command, onLine) =>
|
|
24
|
+
await spawnCollect("sh", ["-c", command], { onLine, signal: options.signal }),
|
|
25
|
+
|
|
26
|
+
write: async (name, contents) => {
|
|
27
|
+
const path = join(directory, name);
|
|
28
|
+
|
|
29
|
+
await mkdir(dirname(path), { recursive: true });
|
|
30
|
+
await writeFile(path, contents, { mode: 0o600 });
|
|
31
|
+
|
|
32
|
+
return path;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Both ends are this machine, so the pipe is one shell away
|
|
36
|
+
pipe: async (local, remote, onLine) =>
|
|
37
|
+
await spawnCollect("sh", ["-c", `${local} | ${remote}`], {
|
|
38
|
+
onLine,
|
|
39
|
+
signal: options.signal,
|
|
40
|
+
}),
|
|
41
|
+
|
|
42
|
+
// The build is a grandchild of the shell that was spawned, and every child
|
|
43
|
+
// is a process group leader, so one signal reaches the whole tree
|
|
44
|
+
stop: async (name) => signalEverything(name === "KILL" ? "SIGKILL" : "SIGTERM"),
|
|
45
|
+
|
|
46
|
+
close: async () => {
|
|
47
|
+
await rm(directory, { recursive: true, force: true });
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The shape progress is reported through. The library only knows this much, so
|
|
2
|
+
// a deploy driven from somewhere without a terminal supplies its own.
|
|
3
|
+
|
|
4
|
+
// Work that is still running. Announced when it starts, because the thing worth
|
|
5
|
+
// knowing during a four minute build is which step is the four minutes
|
|
6
|
+
export type Task = {
|
|
7
|
+
// What the step is doing right now, replacing whatever it said before
|
|
8
|
+
detail(message: string): void;
|
|
9
|
+
// A line the step produced, kept under it. detail replaces, this appends,
|
|
10
|
+
// and a build's own output is the reason there are two
|
|
11
|
+
line(message: string): void;
|
|
12
|
+
done(message?: string): void;
|
|
13
|
+
fail(message: string): void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Log = ((message: string) => void) & {
|
|
17
|
+
// Something the run survived, but that the person should know happened
|
|
18
|
+
warn(message: string): void;
|
|
19
|
+
// The reason a deploy is about to stop, or did
|
|
20
|
+
fail(message: string): void;
|
|
21
|
+
// A step reaching the state it was waiting for
|
|
22
|
+
done(message: string): void;
|
|
23
|
+
step(label: string): Task;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const NOTHING: Task = {
|
|
27
|
+
detail: () => {},
|
|
28
|
+
line: () => {},
|
|
29
|
+
done: () => {},
|
|
30
|
+
fail: () => {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const silent: Log = Object.assign(() => {}, {
|
|
34
|
+
warn: () => {},
|
|
35
|
+
fail: () => {},
|
|
36
|
+
done: () => {},
|
|
37
|
+
step: () => NOTHING,
|
|
38
|
+
});
|
package/src/nginx.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AppTopology, Topology } from "./topology.js";
|
|
2
|
+
|
|
3
|
+
// The nginx configuration is a function of the app list. Header policy is set
|
|
4
|
+
// here once rather than per location, which is what let the two blocks drift
|
|
5
|
+
// apart and lose X-Forwarded-For on one of them.
|
|
6
|
+
|
|
7
|
+
const FORWARDING = [
|
|
8
|
+
"proxy_set_header Host $http_host;",
|
|
9
|
+
"proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
|
10
|
+
"proxy_set_header X-Forwarded-Host $http_host;",
|
|
11
|
+
"proxy_set_header X-Forwarded-Proto $scheme;",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const FAILOVER = [
|
|
15
|
+
"proxy_connect_timeout 2s;",
|
|
16
|
+
"proxy_read_timeout 30s;",
|
|
17
|
+
"proxy_next_upstream error timeout http_502 http_503;",
|
|
18
|
+
"proxy_next_upstream_tries 2;",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// The port the proxy listens on inside its container. The published port maps
|
|
22
|
+
// onto this one, so both sides have to agree and only one of them may say it
|
|
23
|
+
export const LISTEN_PORT = 3000;
|
|
24
|
+
|
|
25
|
+
export function renderNginx(topology: Topology, maxBodySize?: string) {
|
|
26
|
+
|
|
27
|
+
const upstreams = topology.apps.map((app) => upstream(app)).join("\n\n");
|
|
28
|
+
// Longest route first, so "/api/" is not shadowed by the "/" catch-all
|
|
29
|
+
const ordered = [...topology.apps].sort((a, b) => b.route.length - a.route.length);
|
|
30
|
+
const locations = ordered.map((app) => location(app)).join("\n\n");
|
|
31
|
+
|
|
32
|
+
return `resolver 127.0.0.1 valid=5s;
|
|
33
|
+
|
|
34
|
+
${upstreams}
|
|
35
|
+
|
|
36
|
+
server {
|
|
37
|
+
listen ${LISTEN_PORT};
|
|
38
|
+
merge_slashes off;
|
|
39
|
+
|
|
40
|
+
client_max_body_size ${maxBodySize ?? "1M"};
|
|
41
|
+
|
|
42
|
+
${locations}
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function upstream(app: AppTopology) {
|
|
48
|
+
return `upstream ${app.name} {
|
|
49
|
+
server ${app.container}:${app.port} max_fails=1 fail_timeout=2s;
|
|
50
|
+
server ${app.retired}:${app.port} backup;
|
|
51
|
+
}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function location(app: AppTopology) {
|
|
55
|
+
// A trailing slash on proxy_pass strips the location prefix. The catch-all
|
|
56
|
+
// must not carry one, a mounted route must
|
|
57
|
+
const target = app.route === "/" ? `http://${app.name}` : `http://${app.name}/`;
|
|
58
|
+
const body = [`proxy_pass ${target};`, ...FAILOVER, ...FORWARDING];
|
|
59
|
+
|
|
60
|
+
return ` location ${app.route} {
|
|
61
|
+
${body.map((line) => ` ${line}`).join("\n")}
|
|
62
|
+
}`;
|
|
63
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import type { Docker } from "./docker.js";
|
|
2
|
+
import type { Host } from "./host.js";
|
|
3
|
+
import type { Log, Task } from "./log.js";
|
|
4
|
+
import type { SecretStores } from "./secrets/refs.js";
|
|
5
|
+
import type { Topology } from "./topology.js";
|
|
6
|
+
import type { Deployment } from "./types.js";
|
|
7
|
+
|
|
8
|
+
// Capistrano's shape: work addressed by where it runs rather than by who calls
|
|
9
|
+
// it, so a plugin or a config adds a step without any file here knowing it
|
|
10
|
+
// exists. A run is a list of steps, each handed what the one before it answered
|
|
11
|
+
// with. Redkite's own four are ordinary members of that list.
|
|
12
|
+
|
|
13
|
+
// Every phase redkite supplies a step for. Each is also the name of the point
|
|
14
|
+
// that step sits at. No run walks all of them: swap and verify are alternatives
|
|
15
|
+
export const PHASES = ["setup", "build", "verify", "swap", "cleanup"] as const;
|
|
16
|
+
export type Phase = (typeof PHASES)[number];
|
|
17
|
+
|
|
18
|
+
// The phases each command walks, in the order it walks them. A phase left out
|
|
19
|
+
// is not run, and a step hung on one of its slots never fires
|
|
20
|
+
export const RUNS = {
|
|
21
|
+
deploy: ["setup", "build", "swap", "cleanup"],
|
|
22
|
+
verify: ["setup", "build", "verify", "cleanup"],
|
|
23
|
+
} as const satisfies Record<string, readonly Phase[]>;
|
|
24
|
+
|
|
25
|
+
export type Run = keyof typeof RUNS;
|
|
26
|
+
|
|
27
|
+
// Everything before the phase's own step, the phase's own slot, everything after
|
|
28
|
+
export const SLOTS = ["before", "main", "after"] as const;
|
|
29
|
+
export type Slot = (typeof SLOTS)[number];
|
|
30
|
+
|
|
31
|
+
// Where a step runs. The bare phase is the point redkite's own step sits at, and a
|
|
32
|
+
// deployment that puts its own step there replaces it: nothing about redkite's
|
|
33
|
+
// four makes them harder to displace than any other step
|
|
34
|
+
export type Point = Phase | `${Phase}:${string}`;
|
|
35
|
+
|
|
36
|
+
// Every point but the four redkite's own steps sit at. A hook is handed and
|
|
37
|
+
// answers with one value, which is what lets a helper be written over all of
|
|
38
|
+
// them at once
|
|
39
|
+
export type Hook = Exclude<Point, Phase>;
|
|
40
|
+
|
|
41
|
+
// What a run has done so far. Each phase adds to what it was handed rather than
|
|
42
|
+
// replacing it, so a step late in the pipeline reads everything above it
|
|
43
|
+
|
|
44
|
+
export type Start = {
|
|
45
|
+
environment: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type Prepared = Start & {
|
|
49
|
+
network: string;
|
|
50
|
+
// Every service's container, whether this run created it or adopted one
|
|
51
|
+
services: string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type BuiltApp = {
|
|
55
|
+
name: string;
|
|
56
|
+
container: string;
|
|
57
|
+
release: string;
|
|
58
|
+
fingerprint: string;
|
|
59
|
+
// The host already held this image, and nothing was rebuilt
|
|
60
|
+
cached: boolean;
|
|
61
|
+
// The image a step hung before the swap runs in, holding the app's own
|
|
62
|
+
// toolchain rather than only what it compiled to
|
|
63
|
+
builderTag: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type Built = Prepared & {
|
|
67
|
+
apps: BuiltApp[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type Released = Built & {
|
|
71
|
+
// False when an app failed its health check and every app was put back, or
|
|
72
|
+
// when a check the verify step ran said the build does not work
|
|
73
|
+
ok: boolean;
|
|
74
|
+
released: string[];
|
|
75
|
+
reverted: string[];
|
|
76
|
+
// Apps whose checks ran. A deploy runs none, so only a verify fills this,
|
|
77
|
+
// and the two runs answer with one shape so a step after either reads it
|
|
78
|
+
checked: string[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type Finished = Released & {
|
|
82
|
+
removed: string[];
|
|
83
|
+
reclaimed: string[];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Everything a step is given besides the value: what is being deployed, and the
|
|
87
|
+
// machine it is being deployed to
|
|
88
|
+
export type Context = {
|
|
89
|
+
config: Deployment;
|
|
90
|
+
environment: string;
|
|
91
|
+
// Which run this is. A step that behaves differently in one is rare, but the
|
|
92
|
+
// service set does: nothing serves in a verify, so it has no proxy
|
|
93
|
+
run: Run;
|
|
94
|
+
topology: Topology;
|
|
95
|
+
host: Host;
|
|
96
|
+
docker: Docker;
|
|
97
|
+
secrets: SecretStores;
|
|
98
|
+
log: Log;
|
|
99
|
+
// The progress row this step already has. A step says what it is doing
|
|
100
|
+
// through this rather than opening a second one beside it
|
|
101
|
+
task: Task;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// What a phase's step is handed, and what it answers with. The two together are
|
|
105
|
+
// the only reason a step can be typed by where it runs
|
|
106
|
+
type PhaseInput = {
|
|
107
|
+
setup: Start;
|
|
108
|
+
build: Prepared;
|
|
109
|
+
verify: Built;
|
|
110
|
+
swap: Built;
|
|
111
|
+
cleanup: Released;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type PhaseOutput = {
|
|
115
|
+
setup: Prepared;
|
|
116
|
+
build: Built;
|
|
117
|
+
verify: Released;
|
|
118
|
+
swap: Released;
|
|
119
|
+
cleanup: Finished;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type PhaseOf<P extends Point> = P extends Phase
|
|
123
|
+
? P
|
|
124
|
+
: P extends `${infer F extends Phase}:${string}`
|
|
125
|
+
? F
|
|
126
|
+
: never;
|
|
127
|
+
|
|
128
|
+
// A phase's own step is what moves a run from one value to the next. Everything
|
|
129
|
+
// hung around it is handed what that side of it produced, so a step is typed by
|
|
130
|
+
// where it runs and never has to say what it takes
|
|
131
|
+
export type InputAt<P extends Point> = P extends Phase
|
|
132
|
+
? PhaseInput[P]
|
|
133
|
+
: P extends `${string}:before:${string}`
|
|
134
|
+
? PhaseInput[PhaseOf<P>]
|
|
135
|
+
: PhaseOutput[PhaseOf<P>];
|
|
136
|
+
|
|
137
|
+
export type OutputAt<P extends Point> = P extends Phase
|
|
138
|
+
? PhaseOutput[P]
|
|
139
|
+
: P extends `${string}:before:${string}`
|
|
140
|
+
? PhaseInput[PhaseOf<P>]
|
|
141
|
+
: PhaseOutput[PhaseOf<P>];
|
|
142
|
+
|
|
143
|
+
// What a step can know before a run starts: nothing has happened yet, so this
|
|
144
|
+
// is the config as written and the environment it was asked for
|
|
145
|
+
export type Plan = {
|
|
146
|
+
config: Deployment;
|
|
147
|
+
environment: string;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// A property rather than a method, because a method's parameter is checked
|
|
151
|
+
// bivariantly: declared as one, a step could narrow its input to a value the
|
|
152
|
+
// slot it runs in has not produced yet
|
|
153
|
+
export type Step<P extends Point = Point> = {
|
|
154
|
+
point: P;
|
|
155
|
+
// Run before the first step, so a step that cannot possibly work says so
|
|
156
|
+
// while the host is still untouched rather than half way through a swap
|
|
157
|
+
check?: (plan: Plan) => void;
|
|
158
|
+
run: (input: InputAt<P>, context: Context) => OutputAt<P> | Promise<OutputAt<P>>;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Erased for storage. A list assembled from a config cannot carry each step's
|
|
162
|
+
// own input type, and `never` is what every one of them accepts
|
|
163
|
+
export type AnyStep = {
|
|
164
|
+
point: Point;
|
|
165
|
+
check?: (plan: Plan) => void;
|
|
166
|
+
run: (input: never, context: Context) => unknown;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Identity, but it pins the point so a typo fails to compile and the input is
|
|
170
|
+
// inferred from where the step runs rather than annotated
|
|
171
|
+
export function defineStep<const P extends Point>(
|
|
172
|
+
point: P,
|
|
173
|
+
run: Step<P>["run"],
|
|
174
|
+
check?: Step<P>["check"],
|
|
175
|
+
): Step<P> {
|
|
176
|
+
return { point, run, check };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A step at the same point as one redkite supplies replaces it, in the place redkite
|
|
180
|
+
// had it. That is the whole of turning one of redkite's four off: put something
|
|
181
|
+
// there that does less, or nothing
|
|
182
|
+
export function merge(supplied: AnyStep[], added: AnyStep[]): AnyStep[] {
|
|
183
|
+
const overrides = new Map(added.map((step) => [step.point, step]));
|
|
184
|
+
const claimed = new Set(supplied.map((step) => step.point));
|
|
185
|
+
|
|
186
|
+
return [
|
|
187
|
+
...supplied.map((step) => overrides.get(step.point) ?? step),
|
|
188
|
+
...added.filter((step) => !claimed.has(step.point)),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Stopping between steps, rather than in the middle of one. A step is the unit
|
|
193
|
+
// that leaves the host in a state the next deploy can read
|
|
194
|
+
export class Aborted extends Error {
|
|
195
|
+
constructor(point: string) {
|
|
196
|
+
super(`Stopped before ${point}`);
|
|
197
|
+
this.name = "Aborted";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function runPipeline(
|
|
202
|
+
run: Run,
|
|
203
|
+
steps: AnyStep[],
|
|
204
|
+
setting: Omit<Context, "task">,
|
|
205
|
+
signal?: AbortSignal,
|
|
206
|
+
): Promise<Finished> {
|
|
207
|
+
const ordered = sequence(steps, RUNS[run]);
|
|
208
|
+
const plan: Plan = { config: setting.config, environment: setting.environment };
|
|
209
|
+
|
|
210
|
+
// Every check before any step, so a run that is going to fail on a config
|
|
211
|
+
// mistake fails before it has created a network or built an image. Nothing
|
|
212
|
+
// wraps what a check throws: it is about the config, and the config is what
|
|
213
|
+
// the reader has in front of them
|
|
214
|
+
for (const step of ordered) step.check?.(plan);
|
|
215
|
+
|
|
216
|
+
// Every step is handed what the one before it answered with, so the whole run
|
|
217
|
+
// is a fold over one list. Running part of it at once is a change here alone
|
|
218
|
+
let value: unknown = { environment: setting.environment } satisfies Start;
|
|
219
|
+
|
|
220
|
+
for (const step of ordered) {
|
|
221
|
+
if (signal?.aborted) throw new Aborted(step.point);
|
|
222
|
+
|
|
223
|
+
const task = setting.log.step(step.point);
|
|
224
|
+
|
|
225
|
+
// A step declares what it takes and answers with through its point, and a
|
|
226
|
+
// list assembled from a config cannot carry that through. The declared
|
|
227
|
+
// types are the contract, and this is the one place they are taken on trust
|
|
228
|
+
const run = step.run as (input: unknown, context: Context) => unknown;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
value = await run(value, { ...setting, task });
|
|
232
|
+
task.done();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
// One step throwing ends the run. Everything after it was written
|
|
235
|
+
// assuming the steps before did what they said they would
|
|
236
|
+
task.fail(`${step.point} failed`);
|
|
237
|
+
throw new Error(`Step ${step.point} failed`, { cause: error });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Every run ends at cleanup, and a step there answers with a Finished or does
|
|
242
|
+
// not compile
|
|
243
|
+
return value as Finished;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The order a run walks: for each of its phases, everything before it, the
|
|
247
|
+
// phase's own slot, then everything after. Redkite's own step leads its slot
|
|
248
|
+
// because merge keeps the supplied list first
|
|
249
|
+
export function sequence(
|
|
250
|
+
steps: AnyStep[],
|
|
251
|
+
phases: readonly Phase[] = RUNS.deploy,
|
|
252
|
+
): AnyStep[] {
|
|
253
|
+
const ordered: AnyStep[] = [];
|
|
254
|
+
|
|
255
|
+
for (const phase of phases) {
|
|
256
|
+
for (const slot of SLOTS) {
|
|
257
|
+
ordered.push(...steps.filter((step) => runsAt(step, phase, slot)));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return ordered;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function runsAt(step: AnyStep, phase: Phase, slot: Slot) {
|
|
265
|
+
const address = addressOf(step.point);
|
|
266
|
+
return address.phase === phase && address.slot === slot;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export type Address = { phase: Phase; slot: Slot; name: string };
|
|
270
|
+
|
|
271
|
+
// The names everything else in redkite derives, so a step reads like the container
|
|
272
|
+
// and cache keys beside it
|
|
273
|
+
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
274
|
+
|
|
275
|
+
// Points a config may still be written against, and what they became
|
|
276
|
+
const RENAMED: Record<string, Phase> = { deploy: "swap" };
|
|
277
|
+
|
|
278
|
+
export function addressOf(point: string): Address {
|
|
279
|
+
const parts = point.split(":");
|
|
280
|
+
const phase = parts[0];
|
|
281
|
+
|
|
282
|
+
const renamed = phase ? RENAMED[phase] : undefined;
|
|
283
|
+
if (renamed) {
|
|
284
|
+
throw new Error(`${point} names the phase ${phase}, which is now ${renamed}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!phase || !isPhase(phase)) {
|
|
288
|
+
throw new Error(`${point} names no phase, expected one of ${PHASES.join(", ")}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The point redkite's own step sits at, and the one a deployment claims to
|
|
292
|
+
// replace it
|
|
293
|
+
if (parts.length === 1) return { phase, slot: "main", name: phase };
|
|
294
|
+
|
|
295
|
+
if (parts.length === 2) return { phase, slot: "main", name: named(point, parts[1]) };
|
|
296
|
+
|
|
297
|
+
if (parts.length !== 3) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`${point} is not a point, expected ${phase}, ${phase}:… or ${phase}:before|after:…`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const slot = parts[1];
|
|
304
|
+
if (slot !== "before" && slot !== "after") {
|
|
305
|
+
throw new Error(`${point} names no slot, expected ${phase}:before:… or ${phase}:after:…`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { phase, slot, name: named(point, parts[2]) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Checked where the config is defined rather than where it runs, so a typo or a
|
|
312
|
+
// collision is a config that fails to load rather than a deploy that stops half
|
|
313
|
+
// way through with the host already changed
|
|
314
|
+
export function assertSteps(steps: AnyStep[]) {
|
|
315
|
+
const seen = new Set<string>();
|
|
316
|
+
|
|
317
|
+
for (const step of steps) {
|
|
318
|
+
addressOf(step.point);
|
|
319
|
+
|
|
320
|
+
if (seen.has(step.point)) {
|
|
321
|
+
throw new Error(`Two steps share the point ${step.point}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
seen.add(step.point);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function named(point: string, name: string | undefined) {
|
|
329
|
+
if (name && NAME.test(name)) return name;
|
|
330
|
+
throw new Error(`${point} needs a kebab-case name`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isPhase(value: string): value is Phase {
|
|
334
|
+
return (PHASES as readonly string[]).includes(value);
|
|
335
|
+
}
|