Skip to content

Commit 839342b

Browse files
authored
feat(selfhost): add environment preflight (#2579)
1 parent 2ca1fb6 commit 839342b

4 files changed

Lines changed: 421 additions & 1 deletion

File tree

.github/workflows/selfhost.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ jobs:
120120
if docker exec gt-redis redis-cli ping | grep -q PONG; then break; fi
121121
sleep 1
122122
done
123-
docker run -d --name gt --network gt-smoke -p 8787:8787 -e REDIS_URL=redis://gt-redis:6379 gittensory:selfhost-ci
123+
docker run -d --name gt --network gt-smoke -p 8787:8787 \
124+
-e REDIS_URL=redis://gt-redis:6379 \
125+
-e SELFHOST_SETUP_TOKEN=selfhost-ci-setup-token \
126+
-e PUBLIC_API_ORIGIN=https://selfhost-ci.example \
127+
gittensory:selfhost-ci
124128
ok=0
125129
for _ in $(seq 1 30); do
126130
if curl -sf http://127.0.0.1:8787/health >/dev/null; then ok=1; break; fi

src/selfhost/preflight.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { createPrivateKey } from "node:crypto";
2+
3+
export type SelfHostPreflightProblem = {
4+
var: string;
5+
message: string;
6+
};
7+
8+
export type SelfHostPreflightResult =
9+
| { ok: true; problems: [] }
10+
| { ok: false; problems: SelfHostPreflightProblem[] };
11+
12+
type SelfHostPreflightEnv = Record<string, string | undefined>;
13+
14+
function nonBlank(value: string | undefined): string | undefined {
15+
const trimmed = value?.trim();
16+
return trimmed ? trimmed : undefined;
17+
}
18+
19+
function parsedUrl(value: string): URL | null {
20+
try {
21+
return new URL(value);
22+
} catch {
23+
return null;
24+
}
25+
}
26+
27+
function isBareHttpsOrigin(value: string): boolean {
28+
const url = parsedUrl(value);
29+
return (
30+
url !== null &&
31+
url.protocol === "https:" &&
32+
url.hostname.length > 0 &&
33+
url.username === "" &&
34+
url.password === "" &&
35+
url.pathname === "/" &&
36+
url.search === "" &&
37+
url.hash === ""
38+
);
39+
}
40+
41+
function isRedisUrl(value: string): boolean {
42+
const url = parsedUrl(value);
43+
return (
44+
url !== null &&
45+
(url.protocol === "redis:" || url.protocol === "rediss:") &&
46+
url.hostname.length > 0
47+
);
48+
}
49+
50+
function isPostgresDatabaseUrl(value: string): boolean {
51+
const url = parsedUrl(value);
52+
if (url === null) return false;
53+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return false;
54+
const hasConnectionTarget =
55+
url.hostname.length > 0 || Boolean(url.searchParams.get("host")?.trim());
56+
const hasDatabaseName = url.pathname.length > 1;
57+
return hasConnectionTarget && hasDatabaseName;
58+
}
59+
60+
function isGitHubAppId(value: string): boolean {
61+
return /^\d+$/.test(value);
62+
}
63+
64+
function isGitHubAppPrivateKey(value: string): boolean {
65+
try {
66+
return createPrivateKey(value.replace(/\\n/g, "\n")).asymmetricKeyType === "rsa";
67+
} catch {
68+
return false;
69+
}
70+
}
71+
72+
function addProblem(
73+
problems: SelfHostPreflightProblem[],
74+
name: string,
75+
message: string,
76+
): void {
77+
problems.push({ var: name, message });
78+
}
79+
80+
export function preflightEnv(env: SelfHostPreflightEnv): SelfHostPreflightResult {
81+
const problems: SelfHostPreflightProblem[] = [];
82+
83+
const redisUrl = nonBlank(env.REDIS_URL);
84+
if (!redisUrl || !isRedisUrl(redisUrl))
85+
addProblem(
86+
problems,
87+
"REDIS_URL",
88+
"Set REDIS_URL to the redis:// or rediss:// connection URL used for shared transient review state.",
89+
);
90+
91+
const githubAppId = nonBlank(env.GITHUB_APP_ID);
92+
const githubAppPrivateKey = nonBlank(env.GITHUB_APP_PRIVATE_KEY);
93+
const hasPartialGitHubApp = Boolean(githubAppId || githubAppPrivateKey);
94+
if (hasPartialGitHubApp && !(githubAppId && githubAppPrivateKey)) {
95+
if (!githubAppId)
96+
addProblem(
97+
problems,
98+
"GITHUB_APP_ID",
99+
"Set GITHUB_APP_ID when configuring a GitHub App private key.",
100+
);
101+
if (!githubAppPrivateKey)
102+
addProblem(
103+
problems,
104+
"GITHUB_APP_PRIVATE_KEY",
105+
"Set GITHUB_APP_PRIVATE_KEY when configuring a GitHub App ID.",
106+
);
107+
}
108+
if (githubAppId && githubAppPrivateKey) {
109+
if (!isGitHubAppId(githubAppId))
110+
addProblem(
111+
problems,
112+
"GITHUB_APP_ID",
113+
"Set GITHUB_APP_ID to the numeric GitHub App ID.",
114+
);
115+
if (!isGitHubAppPrivateKey(githubAppPrivateKey))
116+
addProblem(
117+
problems,
118+
"GITHUB_APP_PRIVATE_KEY",
119+
"Set GITHUB_APP_PRIVATE_KEY to the PEM private key for the configured GitHub App.",
120+
);
121+
}
122+
123+
const hasOrbBroker = Boolean(nonBlank(env.ORB_ENROLLMENT_SECRET));
124+
if (!hasPartialGitHubApp && !hasOrbBroker) {
125+
if (!nonBlank(env.SELFHOST_SETUP_TOKEN))
126+
addProblem(
127+
problems,
128+
"SELFHOST_SETUP_TOKEN",
129+
"Set SELFHOST_SETUP_TOKEN before using the first-run setup wizard.",
130+
);
131+
const publicApiOrigin = nonBlank(env.PUBLIC_API_ORIGIN);
132+
if (!publicApiOrigin || !isBareHttpsOrigin(publicApiOrigin))
133+
addProblem(
134+
problems,
135+
"PUBLIC_API_ORIGIN",
136+
"Set PUBLIC_API_ORIGIN to the public HTTPS origin that receives GitHub App setup callbacks.",
137+
);
138+
}
139+
140+
const databaseUrl = nonBlank(env.DATABASE_URL);
141+
if (databaseUrl && !isPostgresDatabaseUrl(databaseUrl))
142+
addProblem(
143+
problems,
144+
"DATABASE_URL",
145+
"Set DATABASE_URL to a valid postgres:// URL with a database name, or leave it unset to use the SQLite backend.",
146+
);
147+
148+
return problems.length === 0 ? { ok: true, problems: [] } : { ok: false, problems };
149+
}
150+
151+
export function formatSelfHostPreflightError(problems: SelfHostPreflightProblem[]): string {
152+
return [
153+
"Self-host environment preflight failed:",
154+
...problems.map((problem) => `- ${problem.var}: ${problem.message}`),
155+
].join("\n");
156+
}
157+
158+
export function assertSelfHostPreflight(env: SelfHostPreflightEnv): void {
159+
const result = preflightEnv(env);
160+
if (!result.ok) throw new Error(formatSelfHostPreflightError(result.problems));
161+
}

src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
makeLocalManifestReader,
5757
makeLocalReviewContextReader,
5858
} from "./selfhost/private-config";
59+
import { assertSelfHostPreflight } from "./selfhost/preflight";
5960
import {
6061
buildSentryOpenTelemetryBridge,
6162
captureError,
@@ -257,6 +258,8 @@ function buildSqliteBackend(
257258

258259
async function main(): Promise<void> {
259260
loadFileSecrets();
261+
/* v8 ignore next -- importing this entrypoint starts the Node server; pure validation is covered in selfhost-preflight tests. */
262+
assertSelfHostPreflight(process.env);
260263
// Container-private per-repo config (self-host): register the GITTENSORY_REPO_CONFIG_DIR reader so the focus-
261264
// manifest loader prefers a mounted `{owner}__{repo}.yml` over the public `.gittensory.yml` (review policy stays
262265
// private). Unset dir ⇒ null reader ⇒ unchanged public-fetch behavior.

0 commit comments

Comments
 (0)