Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions src/selfhost/preflight.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;

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));
}
3 changes: 3 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
makeLocalManifestReader,
makeLocalReviewContextReader,
} from "./selfhost/private-config";
import { assertSelfHostPreflight } from "./selfhost/preflight";
import {
buildSentryOpenTelemetryBridge,
captureError,
Expand Down Expand Up @@ -256,6 +257,8 @@ function buildSqliteBackend(

async function main(): Promise<void> {
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.
Expand Down
Loading
Loading