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
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ services:
qdrant:
condition: service_healthy
required: false
# Same pattern for --profile postgres / --profile pgbouncer: without this, compose starts gittensory
# in parallel with a cold Postgres initdb instead of waiting for it, and the app's own waitForPostgres()
# fallback (~30s hard cap) commonly loses that race on a slow disk/first pull, crash-looping the app via
# restart: unless-stopped before Postgres is reachable. required:false keeps the default SQLite path
# (no profile active) unaffected.
postgres:
condition: service_healthy
required: false
pgbouncer:
condition: service_healthy
required: false
healthcheck:
# Probe /ready (not /health): /health is a liveness stub that is 200 even when the DB is down,
# whereas /ready returns 503 until the DB answers AND migrations are applied — so dependents that
Expand Down Expand Up @@ -161,6 +172,15 @@ services:
MAX_CLIENT_CONN: "200"
DEFAULT_POOL_SIZE: "20"
AUTH_TYPE: md5
# The image ships the full postgresql-client toolchain (Alpine-based), so the same probe postgres itself
# uses works here: pg_isready only checks that the server accepts the startup packet, not that auth
# succeeds, so it reports "accepting connections" even before the upstream Postgres is reachable through
# the pooler — exactly the pooler-layer liveness signal gittensory's depends_on needs.
healthcheck:
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "5432"]
interval: 10s
timeout: 5s
retries: 5

# Postgres internals exporter. Starts with the Postgres profiles so Prometheus can scrape it when
# observability is enabled, without starting against a missing DB on SQLite-only installs.
Expand Down
52 changes: 52 additions & 0 deletions test/unit/selfhost-compose-db-health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { describe, expect, it } from "vitest";

function readYaml(path: string): Record<string, unknown> {
const value = parse(readFileSync(path, "utf8"));
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${path} must be a YAML object`);
}
return value as Record<string, unknown>;
}

// Pure structural checks only (no `docker` CLI invocation): the self-hosted runner container this actually
// runs on does not have Docker-in-Docker access, so a test that shells out to `docker compose config`
// would be unreliable/environment-dependent here (same constraint as docker-compose-override-example.test.ts).
describe("docker-compose.yml — postgres/pgbouncer startup ordering (#2500, #2503)", () => {
it("gates the core app on a healthy postgres and pgbouncer without requiring either profile", () => {
const compose = readYaml("docker-compose.yml");
const services = (compose.services as Record<string, Record<string, unknown>>) ?? {};
const app = services.gittensory ?? {};
const dependsOn = app.depends_on as Record<string, { condition?: string; required?: boolean }>;

// redis stays required (always-on service); postgres/pgbouncer/qdrant are all required:false since only
// a subset of profiles start them — a default (no-profile) SQLite deployment must not wait on any of them.
expect(dependsOn.redis).toEqual({ condition: "service_healthy" });
expect(dependsOn.postgres).toEqual({ condition: "service_healthy", required: false });
expect(dependsOn.pgbouncer).toEqual({ condition: "service_healthy", required: false });
expect(dependsOn.qdrant).toEqual({ condition: "service_healthy", required: false });
});

it("gives pgbouncer a healthcheck so gittensory's depends_on has a real readiness signal to gate on", () => {
const compose = readYaml("docker-compose.yml");
const services = (compose.services as Record<string, Record<string, unknown>>) ?? {};
const pgbouncer = services.pgbouncer ?? {};
const healthcheck = pgbouncer.healthcheck as { test?: unknown[]; interval?: string; retries?: number };

// pg_isready only confirms the server accepts the startup packet, not that auth succeeds against the
// real upstream postgres -- correct for a pooler-layer liveness probe, mirroring the postgres service's
// own healthcheck shape (also pg_isready-based).
expect(healthcheck.test).toEqual(["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "5432"]);
expect(healthcheck.retries).toBeGreaterThan(0);
});

it("still starts pgbouncer only after a healthy postgres (unaffected by the new gittensory dependency)", () => {
const compose = readYaml("docker-compose.yml");
const services = (compose.services as Record<string, Record<string, unknown>>) ?? {};
const pgbouncer = services.pgbouncer ?? {};
const dependsOn = pgbouncer.depends_on as Record<string, { condition?: string }>;

expect(dependsOn.postgres).toEqual({ condition: "service_healthy" });
});
});
Loading