From 5f01a650b1dfd5922f0bdd0cc77668b91fb5a381 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:42:45 -0700 Subject: [PATCH] feat(selfhost): add environment preflight --- .github/workflows/selfhost.yml | 6 +- src/selfhost/preflight.ts | 161 +++++++++++++++++ src/server.ts | 3 + test/unit/selfhost-preflight.test.ts | 252 +++++++++++++++++++++++++++ 4 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 src/selfhost/preflight.ts create mode 100644 test/unit/selfhost-preflight.test.ts diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml index 31441ba54a..8bb8129459 100644 --- a/.github/workflows/selfhost.yml +++ b/.github/workflows/selfhost.yml @@ -110,7 +110,11 @@ jobs: if docker exec gt-redis redis-cli ping | grep -q PONG; then break; fi sleep 1 done - docker run -d --name gt --network gt-smoke -p 8787:8787 -e REDIS_URL=redis://gt-redis:6379 gittensory:selfhost-ci + docker run -d --name gt --network gt-smoke -p 8787:8787 \ + -e REDIS_URL=redis://gt-redis:6379 \ + -e SELFHOST_SETUP_TOKEN=selfhost-ci-setup-token \ + -e PUBLIC_API_ORIGIN=https://selfhost-ci.example \ + gittensory:selfhost-ci ok=0 for _ in $(seq 1 30); do if curl -sf http://127.0.0.1:8787/health >/dev/null; then ok=1; break; fi diff --git a/src/selfhost/preflight.ts b/src/selfhost/preflight.ts new file mode 100644 index 0000000000..7a38769b17 --- /dev/null +++ b/src/selfhost/preflight.ts @@ -0,0 +1,161 @@ +import { createPrivateKey } from "node:crypto"; + +export type SelfHostPreflightProblem = { + var: string; + message: string; +}; + +export type SelfHostPreflightResult = + | { ok: true; problems: [] } + | { ok: false; problems: SelfHostPreflightProblem[] }; + +type SelfHostPreflightEnv = Record; + +function nonBlank(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function parsedUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function isBareHttpsOrigin(value: string): boolean { + const url = parsedUrl(value); + return ( + url !== null && + url.protocol === "https:" && + url.hostname.length > 0 && + url.username === "" && + url.password === "" && + url.pathname === "/" && + url.search === "" && + url.hash === "" + ); +} + +function isRedisUrl(value: string): boolean { + const url = parsedUrl(value); + return ( + url !== null && + (url.protocol === "redis:" || url.protocol === "rediss:") && + url.hostname.length > 0 + ); +} + +function isPostgresDatabaseUrl(value: string): boolean { + const url = parsedUrl(value); + if (url === null) return false; + if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return false; + const hasConnectionTarget = + url.hostname.length > 0 || Boolean(url.searchParams.get("host")?.trim()); + const hasDatabaseName = url.pathname.length > 1; + return hasConnectionTarget && hasDatabaseName; +} + +function isGitHubAppId(value: string): boolean { + return /^\d+$/.test(value); +} + +function isGitHubAppPrivateKey(value: string): boolean { + try { + return createPrivateKey(value.replace(/\\n/g, "\n")).asymmetricKeyType === "rsa"; + } catch { + return false; + } +} + +function addProblem( + problems: SelfHostPreflightProblem[], + name: string, + message: string, +): void { + problems.push({ var: name, message }); +} + +export function preflightEnv(env: SelfHostPreflightEnv): SelfHostPreflightResult { + const problems: SelfHostPreflightProblem[] = []; + + const redisUrl = nonBlank(env.REDIS_URL); + if (!redisUrl || !isRedisUrl(redisUrl)) + addProblem( + problems, + "REDIS_URL", + "Set REDIS_URL to the redis:// or rediss:// connection URL used for shared transient review state.", + ); + + const githubAppId = nonBlank(env.GITHUB_APP_ID); + const githubAppPrivateKey = nonBlank(env.GITHUB_APP_PRIVATE_KEY); + const hasPartialGitHubApp = Boolean(githubAppId || githubAppPrivateKey); + if (hasPartialGitHubApp && !(githubAppId && githubAppPrivateKey)) { + if (!githubAppId) + addProblem( + problems, + "GITHUB_APP_ID", + "Set GITHUB_APP_ID when configuring a GitHub App private key.", + ); + if (!githubAppPrivateKey) + addProblem( + problems, + "GITHUB_APP_PRIVATE_KEY", + "Set GITHUB_APP_PRIVATE_KEY when configuring a GitHub App ID.", + ); + } + if (githubAppId && githubAppPrivateKey) { + if (!isGitHubAppId(githubAppId)) + addProblem( + problems, + "GITHUB_APP_ID", + "Set GITHUB_APP_ID to the numeric GitHub App ID.", + ); + if (!isGitHubAppPrivateKey(githubAppPrivateKey)) + addProblem( + problems, + "GITHUB_APP_PRIVATE_KEY", + "Set GITHUB_APP_PRIVATE_KEY to the PEM private key for the configured GitHub App.", + ); + } + + const hasOrbBroker = Boolean(nonBlank(env.ORB_ENROLLMENT_SECRET)); + if (!hasPartialGitHubApp && !hasOrbBroker) { + if (!nonBlank(env.SELFHOST_SETUP_TOKEN)) + addProblem( + problems, + "SELFHOST_SETUP_TOKEN", + "Set SELFHOST_SETUP_TOKEN before using the first-run setup wizard.", + ); + const publicApiOrigin = nonBlank(env.PUBLIC_API_ORIGIN); + if (!publicApiOrigin || !isBareHttpsOrigin(publicApiOrigin)) + addProblem( + problems, + "PUBLIC_API_ORIGIN", + "Set PUBLIC_API_ORIGIN to the public HTTPS origin that receives GitHub App setup callbacks.", + ); + } + + const databaseUrl = nonBlank(env.DATABASE_URL); + if (databaseUrl && !isPostgresDatabaseUrl(databaseUrl)) + addProblem( + problems, + "DATABASE_URL", + "Set DATABASE_URL to a valid postgres:// URL with a database name, or leave it unset to use the SQLite backend.", + ); + + return problems.length === 0 ? { ok: true, problems: [] } : { ok: false, problems }; +} + +export function formatSelfHostPreflightError(problems: SelfHostPreflightProblem[]): string { + return [ + "Self-host environment preflight failed:", + ...problems.map((problem) => `- ${problem.var}: ${problem.message}`), + ].join("\n"); +} + +export function assertSelfHostPreflight(env: SelfHostPreflightEnv): void { + const result = preflightEnv(env); + if (!result.ok) throw new Error(formatSelfHostPreflightError(result.problems)); +} diff --git a/src/server.ts b/src/server.ts index 83c9a4d17c..7e25885322 100644 --- a/src/server.ts +++ b/src/server.ts @@ -55,6 +55,7 @@ import { makeLocalManifestReader, makeLocalReviewContextReader, } from "./selfhost/private-config"; +import { assertSelfHostPreflight } from "./selfhost/preflight"; import { buildSentryOpenTelemetryBridge, captureError, @@ -256,6 +257,8 @@ function buildSqliteBackend( async function main(): Promise { loadFileSecrets(); + /* v8 ignore next -- importing this entrypoint starts the Node server; pure validation is covered in selfhost-preflight tests. */ + assertSelfHostPreflight(process.env); // Container-private per-repo config (self-host): register the GITTENSORY_REPO_CONFIG_DIR reader so the focus- // manifest loader prefers a mounted `{owner}__{repo}.yml` over the public `.gittensory.yml` (review policy stays // private). Unset dir ⇒ null reader ⇒ unchanged public-fetch behavior. diff --git a/test/unit/selfhost-preflight.test.ts b/test/unit/selfhost-preflight.test.ts new file mode 100644 index 0000000000..a2c7a3c3c9 --- /dev/null +++ b/test/unit/selfhost-preflight.test.ts @@ -0,0 +1,252 @@ +import { generateKeyPairSync } from "node:crypto"; + +import { + assertSelfHostPreflight, + formatSelfHostPreflightError, + preflightEnv, + type SelfHostPreflightProblem, +} from "../../src/selfhost/preflight"; + +describe("self-host environment preflight (#2080)", () => { + const privateKey = generateKeyPairSync("rsa", { + modulusLength: 2048, + }).privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + + it("returns every missing required value at once for the first-run setup path", () => { + const result = preflightEnv({}); + + expect(result).toEqual({ + ok: false, + problems: [ + expect.objectContaining({ var: "REDIS_URL" }), + expect.objectContaining({ var: "SELFHOST_SETUP_TOKEN" }), + expect.objectContaining({ var: "PUBLIC_API_ORIGIN" }), + ], + }); + }); + + it("trims values, passes configured GitHub App installs, and accepts postgres URLs", () => { + expect( + preflightEnv({ + REDIS_URL: " redis://redis:6379 ", + GITHUB_APP_ID: " 123 ", + GITHUB_APP_PRIVATE_KEY: ` ${privateKey} `, + DATABASE_URL: " postgres://gittensory:secret@postgres:5432/gittensory ", + }), + ).toEqual({ ok: true, problems: [] }); + + expect( + preflightEnv({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "123", + GITHUB_APP_PRIVATE_KEY: privateKey, + DATABASE_URL: "postgresql://gittensory:secret@postgres:5432/gittensory", + }), + ).toEqual({ ok: true, problems: [] }); + + expect( + preflightEnv({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "123", + GITHUB_APP_PRIVATE_KEY: privateKey, + DATABASE_URL: "postgresql:///gittensory?host=/var/run/postgresql", + }), + ).toEqual({ ok: true, problems: [] }); + + expect( + preflightEnv({ + REDIS_URL: "rediss://redis.example:6380", + GITHUB_APP_ID: "123", + GITHUB_APP_PRIVATE_KEY: privateKey.replace(/\n/g, "\\n"), + }), + ).toEqual({ ok: true, problems: [] }); + }); + + it("requires setup-wizard vars only when neither a GitHub App nor Orb broker enrollment is configured", () => { + expect( + preflightEnv({ + REDIS_URL: "redis://redis:6379", + SELFHOST_SETUP_TOKEN: "setup-secret", + PUBLIC_API_ORIGIN: "https://selfhost.example", + }), + ).toEqual({ ok: true, problems: [] }); + + expect( + preflightEnv({ + REDIS_URL: "redis://redis:6379", + ORB_ENROLLMENT_SECRET: "orb-secret", + }), + ).toEqual({ ok: true, problems: [] }); + }); + + it("requires PUBLIC_API_ORIGIN to be a parseable bare HTTPS origin", () => { + for (const PUBLIC_API_ORIGIN of [ + "not-a-url", + "http://selfhost.example", + "https://selfhost.example/setup", + "https://user:password@selfhost.example", + ]) { + const result = preflightEnv({ + REDIS_URL: "redis://redis:6379", + SELFHOST_SETUP_TOKEN: "setup-secret", + PUBLIC_API_ORIGIN, + }); + + expect(result).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "PUBLIC_API_ORIGIN" })], + }); + expect(JSON.stringify(result)).not.toContain(PUBLIC_API_ORIGIN); + } + }); + + it("requires Redis to be a parseable redis URL", () => { + for (const REDIS_URL of [ + "redis", + "http://:redis-password@redis:6379", + "redis://", + ]) { + const result = preflightEnv({ + REDIS_URL, + SELFHOST_SETUP_TOKEN: "setup-secret", + PUBLIC_API_ORIGIN: "https://selfhost.example", + }); + + expect(result).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "REDIS_URL" })], + }); + expect(JSON.stringify(result)).not.toContain("redis-password"); + } + }); + + it("requires the complete GitHub App credential pair before bypassing setup", () => { + const missingPrivateKey = preflightEnv({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "123", + }); + + expect(missingPrivateKey).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "GITHUB_APP_PRIVATE_KEY" })], + }); + + const missingAppId = preflightEnv({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_PRIVATE_KEY: privateKey, + }); + + expect(missingAppId).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "GITHUB_APP_ID" })], + }); + expect(JSON.stringify(missingAppId)).not.toContain(privateKey.slice(0, 24)); + }); + + it("requires parseable GitHub App credentials when setup is bypassed", () => { + const result = preflightEnv({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "not-a-number", + GITHUB_APP_PRIVATE_KEY: "not-a-pem-private-key", + }); + + expect(result).toEqual({ + ok: false, + problems: [ + expect.objectContaining({ var: "GITHUB_APP_ID" }), + expect.objectContaining({ var: "GITHUB_APP_PRIVATE_KEY" }), + ], + }); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("not-a-number"); + expect(serialized).not.toContain("not-a-pem-private-key"); + + expect( + preflightEnv({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "123", + GITHUB_APP_PRIVATE_KEY: "\\n", + }), + ).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "GITHUB_APP_PRIVATE_KEY" })], + }); + }); + + it("requires DATABASE_URL to parse as a usable postgres DSN", () => { + for (const DATABASE_URL of [ + "postgres://", + "postgres://postgres", + "postgresql:///gittensory", + "sqlite:///tmp/gittensory.sqlite?password=super-secret-db", + ]) { + const result = preflightEnv({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "123", + GITHUB_APP_PRIVATE_KEY: privateKey, + DATABASE_URL, + }); + + expect(result).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "DATABASE_URL" })], + }); + } + + const secretBearing = preflightEnv({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "123", + GITHUB_APP_PRIVATE_KEY: privateKey, + DATABASE_URL: "postgres://user:super-secret-db@/gittensory", + }); + expect(JSON.stringify(secretBearing)).not.toContain("super-secret-db"); + }); + + it("flags blank values and invalid DATABASE_URL while never echoing supplied secrets", () => { + const result = preflightEnv({ + REDIS_URL: " ", + SELFHOST_SETUP_TOKEN: "secret-setup-token", + PUBLIC_API_ORIGIN: "https://selfhost.example", + DATABASE_URL: "sqlite:///tmp/gittensory.sqlite?password=super-secret-db", + }); + + expect(result).toEqual({ + ok: false, + problems: [ + expect.objectContaining({ var: "REDIS_URL" }), + expect.objectContaining({ var: "DATABASE_URL" }), + ], + }); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("secret-setup-token"); + expect(serialized).not.toContain("super-secret-db"); + expect(serialized).not.toContain("sqlite:///tmp"); + }); + + it("formats all problems with names and actionable hints", () => { + const problems: SelfHostPreflightProblem[] = [ + { var: "REDIS_URL", message: "Set REDIS_URL to Redis." }, + { var: "PUBLIC_API_ORIGIN", message: "Set PUBLIC_API_ORIGIN to HTTPS." }, + ]; + + expect(formatSelfHostPreflightError(problems)).toBe( + "Self-host environment preflight failed:\n" + + "- REDIS_URL: Set REDIS_URL to Redis.\n" + + "- PUBLIC_API_ORIGIN: Set PUBLIC_API_ORIGIN to HTTPS.", + ); + }); + + it("asserts the preflight result for the boot path", () => { + expect(() => + assertSelfHostPreflight({ + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "123", + GITHUB_APP_PRIVATE_KEY: privateKey, + }), + ).not.toThrow(); + + expect(() => assertSelfHostPreflight({ DATABASE_URL: "mysql://db/app" })).toThrow( + /Self-host environment preflight failed:\n- REDIS_URL: .*SELFHOST_SETUP_TOKEN.*PUBLIC_API_ORIGIN.*DATABASE_URL:/s, + ); + }); +});