Skip to content

Commit d046703

Browse files
committed
feat(selfhost): add environment preflight
1 parent 32be1ba commit d046703

3 files changed

Lines changed: 179 additions & 0 deletions

File tree

src/selfhost/preflight.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
export type SelfHostPreflightProblem = {
2+
var: string;
3+
message: string;
4+
};
5+
6+
export type SelfHostPreflightResult =
7+
| { ok: true; problems: [] }
8+
| { ok: false; problems: SelfHostPreflightProblem[] };
9+
10+
type SelfHostPreflightEnv = Record<string, string | undefined>;
11+
12+
function nonBlank(value: string | undefined): string | undefined {
13+
const trimmed = value?.trim();
14+
return trimmed ? trimmed : undefined;
15+
}
16+
17+
function addProblem(
18+
problems: SelfHostPreflightProblem[],
19+
name: string,
20+
message: string,
21+
): void {
22+
problems.push({ var: name, message });
23+
}
24+
25+
export function preflightEnv(env: SelfHostPreflightEnv): SelfHostPreflightResult {
26+
const problems: SelfHostPreflightProblem[] = [];
27+
28+
if (!nonBlank(env.REDIS_URL))
29+
addProblem(
30+
problems,
31+
"REDIS_URL",
32+
"Set REDIS_URL to the Redis connection URL used for shared transient review state.",
33+
);
34+
35+
const hasGitHubApp = Boolean(nonBlank(env.GITHUB_APP_ID));
36+
const hasOrbBroker = Boolean(nonBlank(env.ORB_ENROLLMENT_SECRET));
37+
if (!hasGitHubApp && !hasOrbBroker) {
38+
if (!nonBlank(env.SELFHOST_SETUP_TOKEN))
39+
addProblem(
40+
problems,
41+
"SELFHOST_SETUP_TOKEN",
42+
"Set SELFHOST_SETUP_TOKEN before using the first-run setup wizard.",
43+
);
44+
if (!nonBlank(env.PUBLIC_API_ORIGIN))
45+
addProblem(
46+
problems,
47+
"PUBLIC_API_ORIGIN",
48+
"Set PUBLIC_API_ORIGIN to the public HTTPS origin that receives GitHub App setup callbacks.",
49+
);
50+
}
51+
52+
const databaseUrl = nonBlank(env.DATABASE_URL);
53+
if (databaseUrl && !/^postgres(?:ql)?:\/\//i.test(databaseUrl))
54+
addProblem(
55+
problems,
56+
"DATABASE_URL",
57+
"Set DATABASE_URL to a postgres:// URL, or leave it unset to use the SQLite backend.",
58+
);
59+
60+
return problems.length === 0 ? { ok: true, problems: [] } : { ok: false, problems };
61+
}
62+
63+
export function formatSelfHostPreflightError(problems: SelfHostPreflightProblem[]): string {
64+
return [
65+
"Self-host environment preflight failed:",
66+
...problems.map((problem) => `- ${problem.var}: ${problem.message}`),
67+
].join("\n");
68+
}
69+
70+
export function assertSelfHostPreflight(env: SelfHostPreflightEnv): void {
71+
const result = preflightEnv(env);
72+
if (!result.ok) throw new Error(formatSelfHostPreflightError(result.problems));
73+
}

src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
makeLocalManifestReader,
5656
makeLocalReviewContextReader,
5757
} from "./selfhost/private-config";
58+
import { assertSelfHostPreflight } from "./selfhost/preflight";
5859
import {
5960
buildSentryOpenTelemetryBridge,
6061
captureError,
@@ -256,6 +257,8 @@ function buildSqliteBackend(
256257

257258
async function main(): Promise<void> {
258259
loadFileSecrets();
260+
/* v8 ignore next -- importing this entrypoint starts the Node server; pure validation is covered in selfhost-preflight tests. */
261+
assertSelfHostPreflight(process.env);
259262
// Container-private per-repo config (self-host): register the GITTENSORY_REPO_CONFIG_DIR reader so the focus-
260263
// manifest loader prefers a mounted `{owner}__{repo}.yml` over the public `.gittensory.yml` (review policy stays
261264
// private). Unset dir ⇒ null reader ⇒ unchanged public-fetch behavior.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import {
2+
assertSelfHostPreflight,
3+
formatSelfHostPreflightError,
4+
preflightEnv,
5+
type SelfHostPreflightProblem,
6+
} from "../../src/selfhost/preflight";
7+
8+
describe("self-host environment preflight (#2080)", () => {
9+
it("returns every missing required value at once for the first-run setup path", () => {
10+
const result = preflightEnv({});
11+
12+
expect(result).toEqual({
13+
ok: false,
14+
problems: [
15+
expect.objectContaining({ var: "REDIS_URL" }),
16+
expect.objectContaining({ var: "SELFHOST_SETUP_TOKEN" }),
17+
expect.objectContaining({ var: "PUBLIC_API_ORIGIN" }),
18+
],
19+
});
20+
});
21+
22+
it("trims values, passes configured GitHub App installs, and accepts postgres URLs", () => {
23+
expect(
24+
preflightEnv({
25+
REDIS_URL: " redis://redis:6379 ",
26+
GITHUB_APP_ID: " 123 ",
27+
DATABASE_URL: " postgres://gittensory:secret@postgres:5432/gittensory ",
28+
}),
29+
).toEqual({ ok: true, problems: [] });
30+
31+
expect(
32+
preflightEnv({
33+
REDIS_URL: "redis://redis:6379",
34+
GITHUB_APP_ID: "123",
35+
DATABASE_URL: "postgresql://gittensory:secret@postgres:5432/gittensory",
36+
}),
37+
).toEqual({ ok: true, problems: [] });
38+
});
39+
40+
it("requires setup-wizard vars only when neither a GitHub App nor Orb broker enrollment is configured", () => {
41+
expect(
42+
preflightEnv({
43+
REDIS_URL: "redis://redis:6379",
44+
SELFHOST_SETUP_TOKEN: "setup-secret",
45+
PUBLIC_API_ORIGIN: "https://selfhost.example",
46+
}),
47+
).toEqual({ ok: true, problems: [] });
48+
49+
expect(
50+
preflightEnv({
51+
REDIS_URL: "redis://redis:6379",
52+
ORB_ENROLLMENT_SECRET: "orb-secret",
53+
}),
54+
).toEqual({ ok: true, problems: [] });
55+
});
56+
57+
it("flags blank values and invalid DATABASE_URL while never echoing supplied secrets", () => {
58+
const result = preflightEnv({
59+
REDIS_URL: " ",
60+
SELFHOST_SETUP_TOKEN: "secret-setup-token",
61+
PUBLIC_API_ORIGIN: "https://selfhost.example",
62+
DATABASE_URL: "sqlite:///tmp/gittensory.sqlite?password=super-secret-db",
63+
});
64+
65+
expect(result).toEqual({
66+
ok: false,
67+
problems: [
68+
expect.objectContaining({ var: "REDIS_URL" }),
69+
expect.objectContaining({ var: "DATABASE_URL" }),
70+
],
71+
});
72+
const serialized = JSON.stringify(result);
73+
expect(serialized).not.toContain("secret-setup-token");
74+
expect(serialized).not.toContain("super-secret-db");
75+
expect(serialized).not.toContain("sqlite:///tmp");
76+
});
77+
78+
it("formats all problems with names and actionable hints", () => {
79+
const problems: SelfHostPreflightProblem[] = [
80+
{ var: "REDIS_URL", message: "Set REDIS_URL to Redis." },
81+
{ var: "PUBLIC_API_ORIGIN", message: "Set PUBLIC_API_ORIGIN to HTTPS." },
82+
];
83+
84+
expect(formatSelfHostPreflightError(problems)).toBe(
85+
"Self-host environment preflight failed:\n" +
86+
"- REDIS_URL: Set REDIS_URL to Redis.\n" +
87+
"- PUBLIC_API_ORIGIN: Set PUBLIC_API_ORIGIN to HTTPS.",
88+
);
89+
});
90+
91+
it("asserts the preflight result for the boot path", () => {
92+
expect(() =>
93+
assertSelfHostPreflight({
94+
REDIS_URL: "redis://redis:6379",
95+
GITHUB_APP_ID: "123",
96+
}),
97+
).not.toThrow();
98+
99+
expect(() => assertSelfHostPreflight({ DATABASE_URL: "mysql://db/app" })).toThrow(
100+
/Self-host environment preflight failed:\n- REDIS_URL: .*SELFHOST_SETUP_TOKEN.*PUBLIC_API_ORIGIN.*DATABASE_URL:/s,
101+
);
102+
});
103+
});

0 commit comments

Comments
 (0)