diff --git a/backend/src/index.ts b/backend/src/index.ts index ec7120e0..7f28ad17 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -98,10 +98,6 @@ async function main() { done() }) - app.get('/health', async (req, res) => { - return res.send({ status: 'ok' }) - }) - //Plugin nur auf bestimmten Routes await app.register(queryConfigPlugin, { routes: ['/api/resource/:resource/paginated'] diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 24c97e97..d6edca97 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -2,6 +2,16 @@ import { FastifyInstance } from "fastify"; import { getBuildInfo } from "../utils/buildInfo"; export default async function routes(server: FastifyInstance) { + server.get("/health", async (_request, reply) => { + reply.header("Cache-Control", "no-store"); + try { + await server.db.execute("SELECT 1"); + return { status: "ok", ...getBuildInfo() }; + } catch { + return reply.code(503).send({ status: "unavailable" }); + } + }); + server.get("/version", async (_request, reply) => { reply.header("Cache-Control", "no-store"); return getBuildInfo(); diff --git a/backend/tests/buildInfo.test.ts b/backend/tests/buildInfo.test.ts index ab35e8f7..26cd75cf 100644 --- a/backend/tests/buildInfo.test.ts +++ b/backend/tests/buildInfo.test.ts @@ -3,6 +3,28 @@ import { test } from 'node:test'; import Fastify from 'fastify'; import healthRoutes from '../src/routes/health'; +test('health checks the database and reports a failure without leaking connection details', async () => { + const app = Fastify(); + let available = true; + app.decorate('db', { execute: async () => { + if (!available) throw new Error('postgres://secret@database'); + return { rows: [{ result: 1 }] }; + } } as any); + try { + await app.register(healthRoutes); + const healthy = await app.inject('/health'); + assert.equal(healthy.statusCode, 200); + assert.equal(healthy.json().status, 'ok'); + assert.equal(healthy.headers['cache-control'], 'no-store'); + available = false; + const unhealthy = await app.inject('/health'); + assert.equal(unhealthy.statusCode, 503); + assert.deepEqual(unhealthy.json(), { status: 'unavailable' }); + } finally { + await app.close(); + } +}); + test('version endpoint reports image metadata without a database or authentication', async () => { const previousVersion = process.env.FEDEO_VERSION; const previousRevision = process.env.FEDEO_REVISION; diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 3dd5591f..30121951 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -48,7 +48,7 @@ services: - internal minio: - image: minio/minio:latest + image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e container_name: fedeo-minio restart: unless-stopped command: server /data --console-address ":9001" @@ -66,13 +66,13 @@ services: - internal createbuckets: - image: minio/mc:latest + image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 container_name: fedeo-minio-init depends_on: minio: condition: service_healthy entrypoint: > - /bin/sh -c " + /bin/sh -ec " mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD}; mc mb --ignore-existing local/${MINIO_BUCKET}; mc anonymous set private local/${MINIO_BUCKET}; @@ -143,6 +143,12 @@ services: FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO} FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO} NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100} + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || '3100') + '/health', {signal: AbortSignal.timeout(4000)}).then(r => {if (!r.ok) process.exit(1)}).catch(() => process.exit(1))"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 120s labels: - traefik.enable=true - traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`) @@ -178,11 +184,18 @@ services: container_name: fedeo-frontend restart: unless-stopped depends_on: - - backend + backend: + condition: service_healthy environment: NODE_ENV: production NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE} + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health', {signal: AbortSignal.timeout(4000)}).then(r => {if (!r.ok) process.exit(1)}).catch(() => process.exit(1))"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s labels: - traefik.enable=true - traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) diff --git a/frontend/server/api/health.get.ts b/frontend/server/api/health.get.ts new file mode 100644 index 00000000..468cd819 --- /dev/null +++ b/frontend/server/api/health.get.ts @@ -0,0 +1,10 @@ +export default defineEventHandler((event) => { + setHeader(event, 'Cache-Control', 'no-store') + const config = useRuntimeConfig(event) + return { + status: 'ok', + product: 'FEDEO', + version: config.public.fedeoVersion, + revision: config.public.fedeoRevision, + } +})