redkite 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -12
- package/dist/build.d.ts +3 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +18 -2
- package/dist/build.js.map +1 -1
- package/dist/cli/agent.js +2 -2
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/config.d.ts +1 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +82 -16
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/dotenv.d.ts +2 -0
- package/dist/cli/dotenv.d.ts.map +1 -0
- package/dist/cli/dotenv.js +30 -0
- package/dist/cli/dotenv.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +11 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/screen.d.ts +10 -3
- package/dist/cli/screen.d.ts.map +1 -1
- package/dist/cli/screen.js +153 -36
- package/dist/cli/screen.js.map +1 -1
- package/dist/cli/viewer.d.ts.map +1 -1
- package/dist/cli/viewer.js +36 -4
- package/dist/cli/viewer.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +12 -2
- package/dist/deploy.js.map +1 -1
- package/dist/docker.d.ts +1 -0
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +3 -0
- package/dist/docker.js.map +1 -1
- package/dist/dockerfile.d.ts +1 -0
- package/dist/dockerfile.d.ts.map +1 -1
- package/dist/dockerfile.js +16 -3
- package/dist/dockerfile.js.map +1 -1
- package/dist/environment.d.ts +2 -0
- package/dist/environment.d.ts.map +1 -1
- package/dist/environment.js +56 -1
- package/dist/environment.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +1 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +1 -0
- package/dist/log.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +3 -0
- package/dist/pipeline.js.map +1 -1
- package/dist/plugins/bitwarden.d.ts.map +1 -1
- package/dist/plugins/bitwarden.js +25 -6
- package/dist/plugins/bitwarden.js.map +1 -1
- package/dist/presets/nextApp.d.ts.map +1 -1
- package/dist/presets/nextApp.js +5 -4
- package/dist/presets/nextApp.js.map +1 -1
- package/dist/presets/nodeApp.d.ts.map +1 -1
- package/dist/presets/nodeApp.js +15 -6
- package/dist/presets/nodeApp.js.map +1 -1
- package/dist/secrets/manager.d.ts +9 -0
- package/dist/secrets/manager.d.ts.map +1 -0
- package/dist/secrets/manager.js +130 -0
- package/dist/secrets/manager.js.map +1 -0
- package/dist/secrets/store.d.ts.map +1 -1
- package/dist/secrets/store.js +46 -12
- package/dist/secrets/store.js.map +1 -1
- package/dist/services/ensure.js +1 -1
- package/dist/services/ensure.js.map +1 -1
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +11 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/planned.d.ts +0 -1
- package/dist/services/planned.d.ts.map +1 -1
- package/dist/services/planned.js +3 -12
- package/dist/services/planned.js.map +1 -1
- package/dist/services/proxy.d.ts +7 -0
- package/dist/services/proxy.d.ts.map +1 -0
- package/dist/services/proxy.js +103 -0
- package/dist/services/proxy.js.map +1 -0
- package/dist/source.d.ts +7 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +17 -9
- package/dist/source.js.map +1 -1
- package/dist/steps.d.ts +1 -6
- package/dist/steps.d.ts.map +1 -1
- package/dist/steps.js +3 -3
- package/dist/steps.js.map +1 -1
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +22 -2
- package/src/cli/agent.ts +2 -2
- package/src/cli/config.ts +96 -15
- package/src/cli/dotenv.ts +35 -0
- package/src/cli/index.ts +11 -5
- package/src/cli/screen.ts +193 -43
- package/src/cli/viewer.ts +41 -4
- package/src/config.ts +19 -0
- package/src/deploy.ts +14 -2
- package/src/docker.ts +5 -0
- package/src/dockerfile.ts +23 -3
- package/src/environment.ts +71 -1
- package/src/index.ts +2 -1
- package/src/log.ts +4 -0
- package/src/pipeline.ts +4 -0
- package/src/plugins/bitwarden.ts +38 -11
- package/src/presets/nextApp.ts +5 -4
- package/src/presets/nodeApp.ts +15 -6
- package/src/secrets/manager.ts +172 -0
- package/src/secrets/store.ts +51 -16
- package/src/services/ensure.ts +1 -1
- package/src/services/index.ts +13 -1
- package/src/services/planned.ts +3 -13
- package/src/services/proxy.ts +126 -0
- package/src/source.ts +33 -9
- package/src/steps.ts +7 -7
- package/src/types.ts +26 -4
- package/dist/nginx.d.ts +0 -4
- package/dist/nginx.d.ts.map +0 -1
- package/dist/nginx.js +0 -53
- package/dist/nginx.js.map +0 -1
- package/src/nginx.ts +0 -63
package/src/secrets/store.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { mkdir } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
6
|
|
|
8
7
|
import type { SecretStore } from "./refs.js";
|
|
9
8
|
|
|
@@ -14,7 +13,32 @@ import type { SecretStore } from "./refs.js";
|
|
|
14
13
|
// purely as a sandbox, which meant installing several thousand packages on
|
|
15
14
|
// every deploy to read three items.
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
// Nothing on stdin, ever. Both CLIs ask for a password when they decide the
|
|
17
|
+
// vault is locked, and a question put down a pipe nobody writes to is a deploy
|
|
18
|
+
// that waits for ever rather than one that fails.
|
|
19
|
+
//
|
|
20
|
+
// The output is kept exactly as it came: a secret is a file's contents, and a
|
|
21
|
+
// key that lost its last newline is a key some parsers refuse
|
|
22
|
+
function run(file: string, args: string[], env: Record<string, string> = {}) {
|
|
23
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
24
|
+
const child = spawn(file, args, {
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
26
|
+
env: { ...process.env, ...env },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let stdout = "";
|
|
30
|
+
let stderr = "";
|
|
31
|
+
|
|
32
|
+
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
|
|
33
|
+
child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
|
|
34
|
+
child.on("error", reject);
|
|
35
|
+
|
|
36
|
+
child.on("close", (code) => {
|
|
37
|
+
if (code === 0) return resolve({ stdout, stderr });
|
|
38
|
+
reject(new Error(stderr.trim() || stdout.trim() || `${file} exited ${code}`));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
18
42
|
|
|
19
43
|
// A session the caller already holds, or what it takes to obtain one. Both
|
|
20
44
|
// spellings in one object would be a pair of credentials nobody can tell apart
|
|
@@ -44,25 +68,32 @@ export async function bitwardenStore(
|
|
|
44
68
|
|
|
45
69
|
const bw = async (args: string[], env: Record<string, string> = {}) =>
|
|
46
70
|
await run(command.file, [...command.prefix, ...args], {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
BITWARDENCLI_APPDATA_DIR: appdata,
|
|
50
|
-
...env,
|
|
51
|
-
},
|
|
52
|
-
maxBuffer: 32 * 1024 * 1024,
|
|
71
|
+
BITWARDENCLI_APPDATA_DIR: appdata,
|
|
72
|
+
...env,
|
|
53
73
|
});
|
|
54
74
|
|
|
55
75
|
const session = await unlock(bw, credentials, detail);
|
|
56
76
|
|
|
57
77
|
// An item added since the last deploy is not in the local vault otherwise,
|
|
58
|
-
// and bw get answers "not found" rather than fetching it
|
|
59
|
-
|
|
78
|
+
// and bw get answers "not found" rather than fetching it. This is also what
|
|
79
|
+
// proves a session handed in still works: without it the first read is what
|
|
80
|
+
// finds out, and by then a build is waiting on the answer
|
|
81
|
+
await bw(["sync"], { BW_SESSION: session }).catch((error: unknown) => {
|
|
82
|
+
if (!("session" in credentials)) return undefined;
|
|
83
|
+
|
|
84
|
+
throw new Error(
|
|
85
|
+
`The session it was given does not open this vault: ${messageOf(error)}. ` +
|
|
86
|
+
"Obtain one with bw unlock --raw, or unset it and let the api credentials do it",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
60
89
|
|
|
61
90
|
// Fetched once each. A second read of the same item during a deploy is the
|
|
62
91
|
// same answer, and this avoids a process per call site
|
|
63
92
|
const cache = new Map<string, Promise<string>>();
|
|
64
93
|
|
|
65
94
|
const fetch = async (id: string) => {
|
|
95
|
+
detail(`reading ${id.slice(0, 8)}`);
|
|
96
|
+
|
|
66
97
|
const item = await bw(["get", "notes", id, "--raw"], {
|
|
67
98
|
BW_SESSION: session,
|
|
68
99
|
}).catch((error: unknown) => {
|
|
@@ -145,11 +176,15 @@ async function install(detail: (message: string) => void) {
|
|
|
145
176
|
detail(`installing ${CLI}, once for this machine`);
|
|
146
177
|
await mkdir(directory, { recursive: true });
|
|
147
178
|
|
|
148
|
-
await run(
|
|
149
|
-
"
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
179
|
+
await run("npm", [
|
|
180
|
+
"install",
|
|
181
|
+
"--prefix",
|
|
182
|
+
directory,
|
|
183
|
+
"--no-save",
|
|
184
|
+
"--no-audit",
|
|
185
|
+
"--no-fund",
|
|
186
|
+
CLI,
|
|
187
|
+
]).catch((error: unknown) => {
|
|
153
188
|
throw new Error(`Could not install ${CLI}: ${messageOf(error)}`);
|
|
154
189
|
});
|
|
155
190
|
|
package/src/services/ensure.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SPEC_LABEL, type Docker } from "../docker.js";
|
|
2
2
|
import type { Host } from "../host.js";
|
|
3
3
|
import type { Log } from "../log.js";
|
|
4
|
-
import { LISTEN_PORT } from "
|
|
4
|
+
import { LISTEN_PORT } from "./proxy.js";
|
|
5
5
|
import { readEnv, type SecretStores } from "../secrets/refs.js";
|
|
6
6
|
import type { ServiceTopology, Topology } from "../topology.js";
|
|
7
7
|
import type { ServiceSpec } from "../types.js";
|
package/src/services/index.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import type { SecretRefs, ServiceSpec } from "../types.js";
|
|
1
|
+
import type { ProxySpec, SecretRefs, ServiceSpec } from "../types.js";
|
|
2
|
+
|
|
3
|
+
// Not something a deployment lists. Apps carry routes, routes need something
|
|
4
|
+
// to resolve them, and this says what that something is rather than that it
|
|
5
|
+
// exists. A service by this name is refused: there is already one
|
|
6
|
+
export function nginx(options: ProxySpec = {}): ProxySpec {
|
|
7
|
+
return {
|
|
8
|
+
image: options.image ?? "nginx:stable",
|
|
9
|
+
maxBodySize: options.maxBodySize ?? "1M",
|
|
10
|
+
server: options.server ?? [],
|
|
11
|
+
location: options.location ?? [],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
type RedisOptions = {
|
|
4
16
|
image?: string;
|
package/src/services/planned.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
import type { Docker } from "../docker.js";
|
|
4
|
-
import { LISTEN_PORT,
|
|
4
|
+
import { LISTEN_PORT, proxyService, renderProxy } from "./proxy.js";
|
|
5
5
|
import type { Run } from "../pipeline.js";
|
|
6
6
|
import type { ServiceTopology, Topology } from "../topology.js";
|
|
7
7
|
import type { Deployment, ServiceSpec } from "../types.js";
|
|
@@ -36,10 +36,10 @@ export function plannedServices(
|
|
|
36
36
|
if (run === "verify") return rest;
|
|
37
37
|
|
|
38
38
|
const proxy: PlannedService = {
|
|
39
|
-
spec:
|
|
39
|
+
spec: proxyService(config),
|
|
40
40
|
service: topology.router,
|
|
41
41
|
files: {
|
|
42
|
-
"/etc/nginx/conf.d/default.conf":
|
|
42
|
+
"/etc/nginx/conf.d/default.conf": renderProxy(topology, config.proxy),
|
|
43
43
|
},
|
|
44
44
|
publish: topology.publicPort,
|
|
45
45
|
};
|
|
@@ -47,16 +47,6 @@ export function plannedServices(
|
|
|
47
47
|
return [proxy, ...rest];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
// Not something a deployment lists. Apps carry routes, routes need a proxy to
|
|
51
|
-
// resolve them, and the one that renders them is this
|
|
52
|
-
export function router(config: Deployment): ServiceSpec {
|
|
53
|
-
return {
|
|
54
|
-
name: "nginx",
|
|
55
|
-
image: config.proxyImage ?? "nginx:stable",
|
|
56
|
-
restart: "always",
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
50
|
// Everything a recreate would change, and nothing a deploy resolves. Secrets
|
|
61
51
|
// are named by their refs rather than their values: whether the config is the
|
|
62
52
|
// one running is a question a plan should answer without unlocking a vault
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { AppTopology, Topology } from "../topology.js";
|
|
2
|
+
import type { Deployment, ProxySpec, ServiceSpec } from "../types.js";
|
|
3
|
+
|
|
4
|
+
// The proxy every route is resolved by: what it is, and the configuration it
|
|
5
|
+
// runs. Both halves here, because a deployment does not list this service and
|
|
6
|
+
// the only thing that decides either is the app list.
|
|
7
|
+
//
|
|
8
|
+
// Header policy is set once rather than per location, which is what let the two
|
|
9
|
+
// blocks drift apart and lose X-Forwarded-For on one of them.
|
|
10
|
+
|
|
11
|
+
const FORWARDING = [
|
|
12
|
+
"proxy_set_header Host $http_host;",
|
|
13
|
+
"proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
|
14
|
+
"proxy_set_header X-Forwarded-Host $http_host;",
|
|
15
|
+
"proxy_set_header X-Forwarded-Proto $scheme;",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const FAILOVER = [
|
|
19
|
+
"proxy_connect_timeout 2s;",
|
|
20
|
+
"proxy_read_timeout 30s;",
|
|
21
|
+
"proxy_next_upstream error timeout http_502 http_503;",
|
|
22
|
+
"proxy_next_upstream_tries 2;",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// The port the proxy listens on inside its container. The published port maps
|
|
26
|
+
// onto this one, so both sides have to agree and only one of them may say it
|
|
27
|
+
export const LISTEN_PORT = 3000;
|
|
28
|
+
|
|
29
|
+
// What the derived proxy is called. A service by this name would be a second
|
|
30
|
+
// container claiming the first one's, so the config surface refuses it
|
|
31
|
+
export const PROXY = "nginx";
|
|
32
|
+
|
|
33
|
+
// Not something a deployment lists. Apps carry routes, routes need something
|
|
34
|
+
// to resolve them, and this says what that something is
|
|
35
|
+
export function proxyService(config: Deployment): ServiceSpec {
|
|
36
|
+
return {
|
|
37
|
+
name: PROXY,
|
|
38
|
+
image: config.proxy?.image ?? "nginx:stable",
|
|
39
|
+
restart: "always",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderProxy(topology: Topology, proxy: ProxySpec = {}) {
|
|
44
|
+
const upstreams = topology.apps.map((app) => upstream(app)).join("\n\n");
|
|
45
|
+
// Longest route first, so "/api/" is not shadowed by the "/" catch-all
|
|
46
|
+
const ordered = [...topology.apps].sort((a, b) => b.route.length - a.route.length);
|
|
47
|
+
const locations = ordered.map((app) => location(app, proxy.location ?? [])).join("\n\n");
|
|
48
|
+
|
|
49
|
+
assertNotDerived(proxy.server ?? []);
|
|
50
|
+
|
|
51
|
+
const server = settled([
|
|
52
|
+
`merge_slashes off;`,
|
|
53
|
+
`client_max_body_size ${proxy.maxBodySize ?? "1M"};`,
|
|
54
|
+
...(proxy.server ?? []),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
return `resolver 127.0.0.1 valid=5s;
|
|
58
|
+
|
|
59
|
+
${upstreams}
|
|
60
|
+
|
|
61
|
+
server {
|
|
62
|
+
listen ${LISTEN_PORT};
|
|
63
|
+
${indented(server, 4)}
|
|
64
|
+
${locations}
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// nginx refuses a second proxy_read_timeout but expects several
|
|
70
|
+
// proxy_set_header, so what identifies a line is the directive and, for the
|
|
71
|
+
// ones that carry a name, the name after it
|
|
72
|
+
const NAMED = new Set(["add_header", "proxy_set_header", "proxy_hide_header", "set"]);
|
|
73
|
+
|
|
74
|
+
function directive(line: string) {
|
|
75
|
+
const [name = "", argument = ""] = line.trim().split(/\s+/);
|
|
76
|
+
return NAMED.has(name) ? `${name} ${argument}` : name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The last of each wins. A line written by hand replaces the one redkite would
|
|
80
|
+
// have put there rather than repeating it, which nginx would refuse outright
|
|
81
|
+
function settled(lines: string[]) {
|
|
82
|
+
const kept = new Map<string, string>();
|
|
83
|
+
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (!line.trim()) continue;
|
|
86
|
+
kept.set(directive(line), line.trim());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [...kept.values()];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The published port maps onto this one, so both sides have to agree and only
|
|
93
|
+
// one of them may say it
|
|
94
|
+
function assertNotDerived(lines: string[]) {
|
|
95
|
+
const listening = lines.find((line) => directive(line) === "listen");
|
|
96
|
+
if (!listening) return;
|
|
97
|
+
|
|
98
|
+
throw new Error(
|
|
99
|
+
`The proxy's server block says ${listening.trim()}, and the port it listens ` +
|
|
100
|
+
"on inside its container is what publicPort is published onto",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function indented(lines: string[], by: number) {
|
|
105
|
+
return lines.map((line) => " ".repeat(by) + line).join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function upstream(app: AppTopology) {
|
|
109
|
+
return `upstream ${app.name} {
|
|
110
|
+
server ${app.container}:${app.port} max_fails=1 fail_timeout=2s;
|
|
111
|
+
server ${app.retired}:${app.port} backup;
|
|
112
|
+
}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function location(app: AppTopology, added: string[]) {
|
|
116
|
+
// A trailing slash on proxy_pass strips the location prefix. The catch-all
|
|
117
|
+
// must not carry one, a mounted route must
|
|
118
|
+
const target = app.route === "/" ? `http://${app.name}` : `http://${app.name}/`;
|
|
119
|
+
|
|
120
|
+
// Last, so one of these replaces what redkite set rather than repeating it
|
|
121
|
+
const body = settled([`proxy_pass ${target};`, ...FAILOVER, ...FORWARDING, ...added]);
|
|
122
|
+
|
|
123
|
+
return ` location ${app.route} {
|
|
124
|
+
${indented(body, 8)}
|
|
125
|
+
}`;
|
|
126
|
+
}
|
package/src/source.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type Source = {
|
|
|
13
13
|
tree: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
// What a clone is resolved against. A branch is tracked and a tag or a commit
|
|
17
|
+
// is pinned, and which of the three it is decides where git is asked to look
|
|
18
|
+
export type RefKind = "branch" | "tag" | "commit";
|
|
19
|
+
|
|
20
|
+
export type Ref = { kind: RefKind; name: string };
|
|
21
|
+
|
|
16
22
|
export type SourceRequest = {
|
|
17
23
|
// Names the mirror and the checkout. Keyed per app and environment rather
|
|
18
24
|
// than per repository, so two deploys never fetch into one directory at once
|
|
@@ -23,9 +29,20 @@ export type SourceRequest = {
|
|
|
23
29
|
// What of a path goes into the build. Absent leaves it to the work tree's
|
|
24
30
|
// own .gitignore, which is the only other thing that can say
|
|
25
31
|
include?: string[];
|
|
26
|
-
|
|
32
|
+
ref: Ref;
|
|
27
33
|
submodules: boolean;
|
|
28
34
|
detail?: (message: string) => void;
|
|
35
|
+
// Every line git writes. A first clone of a large repository is the slowest
|
|
36
|
+
// part of a build and it used to say nothing at all while it happened
|
|
37
|
+
output?: (line: string) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Where each kind lives. A tag is peeled, so an annotated one answers with the
|
|
41
|
+
// commit it points at rather than with the tag object
|
|
42
|
+
const REVISION: Record<RefKind, (name: string) => string> = {
|
|
43
|
+
branch: (name) => `refs/heads/${name}`,
|
|
44
|
+
tag: (name) => `refs/tags/${name}^{commit}`,
|
|
45
|
+
commit: (name) => `${name}^{commit}`,
|
|
29
46
|
};
|
|
30
47
|
|
|
31
48
|
// accept-new rather than no: a host key that changes is still worth refusing
|
|
@@ -110,8 +127,9 @@ async function clonedSource(
|
|
|
110
127
|
const detail = request.detail ?? (() => {});
|
|
111
128
|
const mirror = `${host.cache}/mirrors/${request.name}.git`;
|
|
112
129
|
const path = `${host.cache}/source/${request.name}`;
|
|
130
|
+
const { kind, name } = request.ref;
|
|
113
131
|
|
|
114
|
-
detail(`fetching ${
|
|
132
|
+
detail(`fetching ${name}`);
|
|
115
133
|
await run(host, `updating the mirror of ${repo}`, [
|
|
116
134
|
`if [ -d '${mirror}' ]; then`,
|
|
117
135
|
` git -C '${mirror}' remote set-url origin '${repo}'`,
|
|
@@ -120,15 +138,15 @@ async function clonedSource(
|
|
|
120
138
|
` mkdir -p '${host.cache}/mirrors'`,
|
|
121
139
|
` git clone --mirror '${repo}' '${mirror}'`,
|
|
122
140
|
"fi",
|
|
123
|
-
]);
|
|
141
|
+
], request.output);
|
|
124
142
|
|
|
125
|
-
const resolved = await run(host, `resolving ${
|
|
126
|
-
`git -C '${mirror}' rev-parse '
|
|
143
|
+
const resolved = await run(host, `resolving ${name}`, [
|
|
144
|
+
`git -C '${mirror}' rev-parse '${REVISION[kind](name)}'`,
|
|
127
145
|
]);
|
|
128
146
|
|
|
129
147
|
const release = resolved.stdout.trim();
|
|
130
148
|
if (!/^[0-9a-f]{40}$/.test(release)) {
|
|
131
|
-
throw new Error(`${repo} has no
|
|
149
|
+
throw new Error(`${repo} has no ${kind} ${name}`);
|
|
132
150
|
}
|
|
133
151
|
|
|
134
152
|
detail(`checking out ${release.slice(0, 7)}`);
|
|
@@ -145,7 +163,7 @@ async function clonedSource(
|
|
|
145
163
|
// Leaves the submodules alone: they are tracked, and clean only removes
|
|
146
164
|
// what is not
|
|
147
165
|
`git -C '${path}' clean -ffdx`,
|
|
148
|
-
]);
|
|
166
|
+
], request.output);
|
|
149
167
|
|
|
150
168
|
if (request.submodules) {
|
|
151
169
|
// --remote follows the branch named in .gitmodules rather than the commit
|
|
@@ -154,15 +172,21 @@ async function clonedSource(
|
|
|
154
172
|
await run(host, "updating submodules", [
|
|
155
173
|
`git -C '${path}' submodule sync --recursive`,
|
|
156
174
|
`git -C '${path}' submodule update --init --remote --recursive`,
|
|
157
|
-
]);
|
|
175
|
+
], request.output);
|
|
158
176
|
}
|
|
159
177
|
|
|
160
178
|
return { release, tree: path };
|
|
161
179
|
}
|
|
162
180
|
|
|
163
|
-
async function run(
|
|
181
|
+
async function run(
|
|
182
|
+
host: Host,
|
|
183
|
+
what: string,
|
|
184
|
+
script: string[],
|
|
185
|
+
output?: (line: string) => void,
|
|
186
|
+
) {
|
|
164
187
|
const result = await host.sh(
|
|
165
188
|
[`export GIT_SSH_COMMAND='${GIT_SSH}'`, "set -e", ...script].join("\n"),
|
|
189
|
+
output,
|
|
166
190
|
);
|
|
167
191
|
|
|
168
192
|
if (result.code === 0) return result;
|
package/src/steps.ts
CHANGED
|
@@ -30,10 +30,10 @@ type MigrateOptions = {
|
|
|
30
30
|
// already reach whatever the app's environment file points at. A database
|
|
31
31
|
// this deployment runs as a service is on "deployment" instead
|
|
32
32
|
network?: StepNetwork;
|
|
33
|
-
//
|
|
34
|
-
// the deploy host
|
|
35
|
-
//
|
|
36
|
-
|
|
33
|
+
// The machine this migration reaches its database through. The step runs on
|
|
34
|
+
// the deploy host, so a deployment that moved to another one is refused
|
|
35
|
+
// rather than pointed at a database it may not be able to see
|
|
36
|
+
through?: string;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// An ordinary step at an ordinary point. Hung before the swap, it runs while
|
|
@@ -78,11 +78,11 @@ function assertReachable(plan: Plan, options: MigrateOptions) {
|
|
|
78
78
|
if (!app) throw new Error(`${options.app} names no app in this deployment`);
|
|
79
79
|
|
|
80
80
|
const bastion = environmentOf(plan.config, plan.environment)?.host?.bastion;
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
81
|
+
const expected = options.through;
|
|
82
|
+
if (!expected || expected === bastion) return;
|
|
83
83
|
|
|
84
84
|
throw new Error(
|
|
85
|
-
`${options.app}
|
|
85
|
+
`${options.app} migrates through ${expected}, ` +
|
|
86
86
|
`but ${plan.environment} deploys to ${bastion ?? "this machine"}. ` +
|
|
87
87
|
"The step runs on the deploy host, so they have to be the same",
|
|
88
88
|
);
|
package/src/types.ts
CHANGED
|
@@ -68,6 +68,22 @@ export type SecretRef = {
|
|
|
68
68
|
// One entry, or several merged in order. A key set by a later ref wins
|
|
69
69
|
export type SecretRefs = SecretRef | SecretRef[];
|
|
70
70
|
|
|
71
|
+
// The proxy every app's route is resolved by. Written with nginx(), because
|
|
72
|
+
// what redkite derives is the server block and the upstreams and what this
|
|
73
|
+
// says is everything around them
|
|
74
|
+
export type ProxySpec = {
|
|
75
|
+
// Image it runs, pinned rather than floating
|
|
76
|
+
image?: string;
|
|
77
|
+
// Largest request body it accepts before answering 413
|
|
78
|
+
maxBodySize?: string;
|
|
79
|
+
// Lines put inside the server block, above the locations. For what belongs
|
|
80
|
+
// to the whole server: a redirect, a rate limit zone, an error page
|
|
81
|
+
server?: string[];
|
|
82
|
+
// Lines put inside every location, below what redkite sets, so one of these
|
|
83
|
+
// replaces a header rather than being replaced by it
|
|
84
|
+
location?: string[];
|
|
85
|
+
};
|
|
86
|
+
|
|
71
87
|
export type ServiceSpec = {
|
|
72
88
|
// Identifies the service, and becomes part of its container name
|
|
73
89
|
name: string;
|
|
@@ -199,6 +215,13 @@ export type AppSpec = {
|
|
|
199
215
|
// Environment for the app. Several are merged in the order written, so a
|
|
200
216
|
// shared ref can come first and a per-app one override it
|
|
201
217
|
secrets?: SecretRefs;
|
|
218
|
+
// What this app is built from, overriding the environment's branch. At most
|
|
219
|
+
// one of the three. A branch is tracked, so every deploy takes whatever its
|
|
220
|
+
// head is by then; a tag or a commit is pinned, and the app stops moving
|
|
221
|
+
// until this line does. Ignored by an app built from a path
|
|
222
|
+
branch?: string;
|
|
223
|
+
tag?: string;
|
|
224
|
+
commit?: string;
|
|
202
225
|
// Where the app sits in the repository, for a monorepo whose root is not it.
|
|
203
226
|
// Build steps and the shipped command run there. The dependency install does
|
|
204
227
|
// not: a workspace lockfile is resolved at the root for every package at once
|
|
@@ -232,10 +255,9 @@ export type Deployment = {
|
|
|
232
255
|
// them: the thing that differs between staging and production is a file, not
|
|
233
256
|
// a key several levels down a literal
|
|
234
257
|
environments?: Record<string, Environment>;
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
proxyImage?: string;
|
|
258
|
+
// The derived proxy, which is not something a deployment lists: apps carry
|
|
259
|
+
// routes, routes need something to resolve them, so there is exactly one
|
|
260
|
+
proxy?: ProxySpec;
|
|
239
261
|
// Long-lived containers shared by the apps, not rebuilt on every deploy.
|
|
240
262
|
// The proxy is not one of them: apps with routes imply exactly one, so it is
|
|
241
263
|
// derived rather than listed
|
package/dist/nginx.d.ts
DELETED
package/dist/nginx.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"nginx.d.ts","sourceRoot":"","sources":["../src/nginx.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,QAAQ,EAAE,MAAM,eAAe,CAAC;AAsB3D,eAAO,MAAM,WAAW,OAAO,CAAC;AAEhC,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,EAAE,WAAW,CAAC,EAAE,MAAM,UAoBnE"}
|
package/dist/nginx.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// The nginx configuration is a function of the app list. Header policy is set
|
|
2
|
-
// here once rather than per location, which is what let the two blocks drift
|
|
3
|
-
// apart and lose X-Forwarded-For on one of them.
|
|
4
|
-
const FORWARDING = [
|
|
5
|
-
"proxy_set_header Host $http_host;",
|
|
6
|
-
"proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
|
7
|
-
"proxy_set_header X-Forwarded-Host $http_host;",
|
|
8
|
-
"proxy_set_header X-Forwarded-Proto $scheme;",
|
|
9
|
-
];
|
|
10
|
-
const FAILOVER = [
|
|
11
|
-
"proxy_connect_timeout 2s;",
|
|
12
|
-
"proxy_read_timeout 30s;",
|
|
13
|
-
"proxy_next_upstream error timeout http_502 http_503;",
|
|
14
|
-
"proxy_next_upstream_tries 2;",
|
|
15
|
-
];
|
|
16
|
-
// The port the proxy listens on inside its container. The published port maps
|
|
17
|
-
// onto this one, so both sides have to agree and only one of them may say it
|
|
18
|
-
export const LISTEN_PORT = 3000;
|
|
19
|
-
export function renderNginx(topology, maxBodySize) {
|
|
20
|
-
const upstreams = topology.apps.map((app) => upstream(app)).join("\n\n");
|
|
21
|
-
// Longest route first, so "/api/" is not shadowed by the "/" catch-all
|
|
22
|
-
const ordered = [...topology.apps].sort((a, b) => b.route.length - a.route.length);
|
|
23
|
-
const locations = ordered.map((app) => location(app)).join("\n\n");
|
|
24
|
-
return `resolver 127.0.0.1 valid=5s;
|
|
25
|
-
|
|
26
|
-
${upstreams}
|
|
27
|
-
|
|
28
|
-
server {
|
|
29
|
-
listen ${LISTEN_PORT};
|
|
30
|
-
merge_slashes off;
|
|
31
|
-
|
|
32
|
-
client_max_body_size ${maxBodySize ?? "1M"};
|
|
33
|
-
|
|
34
|
-
${locations}
|
|
35
|
-
}
|
|
36
|
-
`;
|
|
37
|
-
}
|
|
38
|
-
function upstream(app) {
|
|
39
|
-
return `upstream ${app.name} {
|
|
40
|
-
server ${app.container}:${app.port} max_fails=1 fail_timeout=2s;
|
|
41
|
-
server ${app.retired}:${app.port} backup;
|
|
42
|
-
}`;
|
|
43
|
-
}
|
|
44
|
-
function location(app) {
|
|
45
|
-
// A trailing slash on proxy_pass strips the location prefix. The catch-all
|
|
46
|
-
// must not carry one, a mounted route must
|
|
47
|
-
const target = app.route === "/" ? `http://${app.name}` : `http://${app.name}/`;
|
|
48
|
-
const body = [`proxy_pass ${target};`, ...FAILOVER, ...FORWARDING];
|
|
49
|
-
return ` location ${app.route} {
|
|
50
|
-
${body.map((line) => ` ${line}`).join("\n")}
|
|
51
|
-
}`;
|
|
52
|
-
}
|
|
53
|
-
//# sourceMappingURL=nginx.js.map
|
package/dist/nginx.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"nginx.js","sourceRoot":"","sources":["../src/nginx.ts"],"names":[],"mappings":"AAEA,8EAA8E;AAC9E,6EAA6E;AAC7E,iDAAiD;AAEjD,MAAM,UAAU,GAAG;IACjB,mCAAmC;IACnC,8DAA8D;IAC9D,+CAA+C;IAC/C,6CAA6C;CAC9C,CAAC;AAEF,MAAM,QAAQ,GAAG;IACf,2BAA2B;IAC3B,yBAAyB;IACzB,sDAAsD;IACtD,8BAA8B;CAC/B,CAAC;AAEF,8EAA8E;AAC9E,6EAA6E;AAC7E,MAAM,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC;AAEhC,MAAM,UAAU,WAAW,CAAC,QAAkB,EAAE,WAAoB;IAElE,MAAM,SAAS,GAAG,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACzE,uEAAuE;IACvE,MAAM,OAAO,GAAG,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACnF,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAEnE,OAAO;;EAEP,SAAS;;;aAGE,WAAW;;;2BAGG,WAAW,IAAI,IAAI;;EAE5C,SAAS;;CAEV,CAAC;AACF,CAAC;AAED,SAAS,QAAQ,CAAC,GAAgB;IAChC,OAAO,YAAY,GAAG,CAAC,IAAI;aAChB,GAAG,CAAC,SAAS,IAAI,GAAG,CAAC,IAAI;aACzB,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC,IAAI;EAClC,CAAC;AACH,CAAC;AAED,SAAS,QAAQ,CAAC,GAAgB;IAChC,2EAA2E;IAC3E,2CAA2C;IAC3C,MAAM,MAAM,GAAG,GAAG,CAAC,KAAK,KAAK,GAAG,CAAC,CAAC,CAAC,UAAU,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,UAAU,GAAG,CAAC,IAAI,GAAG,CAAC;IAChF,MAAM,IAAI,GAAG,CAAC,cAAc,MAAM,GAAG,EAAE,GAAG,QAAQ,EAAE,GAAG,UAAU,CAAC,CAAC;IAEnE,OAAO,gBAAgB,GAAG,CAAC,KAAK;EAChC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;MAC5C,CAAC;AACP,CAAC"}
|
package/src/nginx.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
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
|
-
}
|