mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(docker): harden Postgres readiness checks in compose and startup (#595)
Pin the compose Postgres healthchecks to POSTGRES_DB (pg_isready was defaulting to the username, silently reporting healthy while spamming FATAL logs when USER and DB differ), and make docker/wait-for-postgres.mjs log the target host and error code instead of a silent retry loop. Adds a change-me note next to the default password in README and Docker Hub. Refs #592
This commit is contained in:
@@ -58,6 +58,7 @@ services:
|
|||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: snapotter
|
POSTGRES_USER: snapotter
|
||||||
|
# Change this for any non-local deployment.
|
||||||
POSTGRES_PASSWORD: snapotter
|
POSTGRES_PASSWORD: snapotter
|
||||||
POSTGRES_DB: snapotter
|
POSTGRES_DB: snapotter
|
||||||
volumes: ["SnapOtter-pgdata:/var/lib/postgresql/data"]
|
volumes: ["SnapOtter-pgdata:/var/lib/postgresql/data"]
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ services:
|
|||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: snapotter
|
POSTGRES_USER: snapotter
|
||||||
|
# Change this for any non-local deployment.
|
||||||
POSTGRES_PASSWORD: snapotter
|
POSTGRES_PASSWORD: snapotter
|
||||||
POSTGRES_DB: snapotter
|
POSTGRES_DB: snapotter
|
||||||
volumes: ["SnapOtter-pgdata:/var/lib/postgresql/data"]
|
volumes: ["SnapOtter-pgdata:/var/lib/postgresql/data"]
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ services:
|
|||||||
container_name: SnapOtter-postgres
|
container_name: SnapOtter-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-snapotter}
|
POSTGRES_USER: ${POSTGRES_USER:-snapotter}
|
||||||
# Set a strong password -- CHANGE THIS for any non-local deployment.
|
# Set a strong password. CHANGE THIS for any non-local deployment.
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-snapotter}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-snapotter}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-snapotter}
|
POSTGRES_DB: ${POSTGRES_DB:-snapotter}
|
||||||
volumes:
|
volumes:
|
||||||
@@ -130,7 +130,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter} -d ${POSTGRES_DB:-snapotter}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ services:
|
|||||||
container_name: SnapOtter-postgres
|
container_name: SnapOtter-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-snapotter}
|
POSTGRES_USER: ${POSTGRES_USER:-snapotter}
|
||||||
# Set a strong password -- CHANGE THIS for any non-local deployment.
|
# Set a strong password. CHANGE THIS for any non-local deployment.
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-snapotter}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-snapotter}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-snapotter}
|
POSTGRES_DB: ${POSTGRES_DB:-snapotter}
|
||||||
volumes:
|
volumes:
|
||||||
@@ -122,7 +122,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter} -d ${POSTGRES_DB:-snapotter}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import { connect } from "node:net";
|
import { connect } from "node:net";
|
||||||
|
|
||||||
const url = new URL(process.env.DATABASE_URL);
|
const url = new URL(process.env.DATABASE_URL);
|
||||||
const socket = connect(Number(url.port || 5432), url.hostname, () => {
|
const host = url.hostname;
|
||||||
|
const port = Number(url.port || 5432);
|
||||||
|
|
||||||
|
const socket = connect(port, host, () => {
|
||||||
socket.end();
|
socket.end();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
socket.on("error", () => process.exit(1));
|
|
||||||
setTimeout(() => process.exit(1), 3000).unref();
|
socket.on("error", (err) => {
|
||||||
|
// Surface the actual reason instead of exiting silently, so the container
|
||||||
|
// log distinguishes DNS failure (ENOTFOUND), refused connection
|
||||||
|
// (ECONNREFUSED), and unreachable host instead of just looping on
|
||||||
|
// "Waiting for Postgres...". This is a raw TCP probe, so it cannot report
|
||||||
|
// authentication or "database does not exist" errors; those surface later
|
||||||
|
// when the app's Postgres driver connects.
|
||||||
|
console.error(`Postgres not reachable at ${host}:${port}: ${err.code || err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error(`Postgres connection to ${host}:${port} timed out after 3s`);
|
||||||
|
process.exit(1);
|
||||||
|
}, 3000).unref();
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const script = resolve(here, "../../../docker/wait-for-postgres.mjs");
|
||||||
|
|
||||||
|
function runProbe(databaseUrl: string) {
|
||||||
|
return spawnSync(process.execPath, [script], {
|
||||||
|
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("wait-for-postgres probe", () => {
|
||||||
|
it("names the unreachable target instead of failing silently", () => {
|
||||||
|
// `.invalid` never resolves (RFC 2606), so the probe fails fast rather than
|
||||||
|
// waiting for a real host. The point of the fix is that the container log
|
||||||
|
// now says which host:port could not be reached (DNS failure, refused
|
||||||
|
// connection, or timeout) instead of just "Waiting for Postgres..." forever.
|
||||||
|
const res = runProbe("postgres://user:pass@snapotter-db-nope.invalid:5432/snapotter");
|
||||||
|
expect(res.status).toBe(1);
|
||||||
|
expect(res.stderr).toContain("snapotter-db-nope.invalid:5432");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -150,4 +150,22 @@ describe("Dockerfile build args", () => {
|
|||||||
expect(Number(fallback?.[1])).toBeGreaterThanOrEqual(dockerfileDefault);
|
expect(Number(fallback?.[1])).toBeGreaterThanOrEqual(dockerfileDefault);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("targets the app database (not the role) in the compose Postgres healthchecks", () => {
|
||||||
|
// pg_isready with no -d defaults the probe database to the username. When
|
||||||
|
// POSTGRES_USER and POSTGRES_DB differ, the healthcheck silently keeps
|
||||||
|
// reporting healthy while its underlying query fails, and Postgres logs
|
||||||
|
// `FATAL: database "<user>" does not exist` on a loop. Pin the probe to
|
||||||
|
// POSTGRES_DB so it fails loudly when the database is genuinely missing.
|
||||||
|
for (const [name, compose] of [
|
||||||
|
["docker-compose.yml", composeCpu],
|
||||||
|
["docker-compose-gpu.yml", composeGpu],
|
||||||
|
] as const) {
|
||||||
|
const line = compose.split(/\r?\n/).find((l) => l.includes("pg_isready"));
|
||||||
|
expect(line, `${name} should have a pg_isready healthcheck`).toBeDefined();
|
||||||
|
expect(line, `${name} pg_isready must target POSTGRES_DB with -d`).toContain(
|
||||||
|
"-d ${POSTGRES_DB",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user