From a869269591f3aaf90b0fe2419e519f539834d98b Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:40:17 +0200 Subject: [PATCH] fix(deploy): follow PORT in every probe, wait for a slow boot, and probe the database Changing PORT left the Dockerfile and compose healthchecks, the chart's containerPort, Service target and probes on 3000, so the container went unhealthy and Kubernetes restarted it in a loop. All of them follow PORT now. The chart gains a startup probe (ten minutes for a first boot, a long migration or a restore at boot, which liveness used to kill after about 105 s), a readiness probe on the new /api/health/ready that asks the database, extraEnv and extraEnvFrom for the settings the chart does not list, slots for the four secrets it had none for, and the DEFAULT_LANGUAGE and MCP_MAX_SESSION_PER_USER values it documented but never set. The Dockerfile no longer hides a failed npm ci behind a ';', pins gosu, and the language lists name all 27 locales. --- Dockerfile | 8 +-- charts/trek/templates/configmap.yaml | 6 +++ charts/trek/templates/deployment.yaml | 39 ++++++++++++++- charts/trek/templates/secret.yaml | 24 +++++++++ charts/trek/templates/service.yaml | 2 +- charts/trek/values.yaml | 49 ++++++++++++++----- docker-compose.yml | 5 +- .../db/repositories/MaintenanceRepository.ts | 6 +++ server/src/middleware/globalMiddleware.ts | 4 +- .../src/nest/common/validate-route-guards.ts | 3 +- server/src/nest/health/features.controller.ts | 20 ++++++++ .../unit/nest/features.controller.test.ts | 29 ++++++++++- unraid-template.xml | 2 +- 13 files changed, 172 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index 70b939e57..31f72ffaf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,9 @@ # Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners). # The binary and its runtime behaviour are identical to the apt package. FROM golang:1.25-alpine AS gosu-build -RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest +# Pinned to the commit of gosu's 1.19 tag: @latest made every build pick up whatever +# was pushed last, and the tag itself is not a semver version go install accepts. +RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@6456aaa0f3c854d199d0f037f068eb97515b7513 # ── Stage 1: shared ────────────────────────────────────────────────────────── FROM node:24-alpine AS shared-builder @@ -71,7 +73,7 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \ libkitinerary-bin libsqlite3-0 && \ npm ci --workspace=server --omit=dev --ignore-scripts && \ - ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ + { ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor || true; } && \ rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \ chown -R node:node /app @@ -146,7 +148,7 @@ LABEL org.opencontainers.image.title="TREK" \ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD wget -qO- http://localhost:3000/api/health || exit 1 + CMD wget -qO- "http://localhost:${PORT:-3000}/api/health" || exit 1 # Start-up lives in a script, not an inline `sh -c` string: container management # UIs re-tokenise Config.Cmd when you edit a container in place, and a command diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index 2961ca1b5..5e249f8a1 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -16,6 +16,12 @@ data: {{- if .Values.env.TREK_WIKI_DIR }} TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }} {{- end }} + {{- if .Values.env.DEFAULT_LANGUAGE }} + DEFAULT_LANGUAGE: {{ .Values.env.DEFAULT_LANGUAGE | quote }} + {{- end }} + {{- if .Values.env.MCP_MAX_SESSION_PER_USER }} + MCP_MAX_SESSION_PER_USER: {{ .Values.env.MCP_MAX_SESSION_PER_USER | quote }} + {{- end }} {{- if .Values.env.ALLOWED_ORIGINS }} ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }} {{- end }} diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index 3b91d8964..e37ff5d67 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -9,7 +9,7 @@ spec: # TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default # RollingUpdate would start a second pod holding the same volume before the old one # exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears - # the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume. + # the old pod down first. Keep it, see updateStrategy in values.yaml. strategy: type: {{ .Values.updateStrategy | default "Recreate" }} selector: @@ -40,10 +40,14 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} ports: - - containerPort: 3000 + - name: http + containerPort: {{ .Values.env.PORT | default 3000 }} envFrom: - configMapRef: name: {{ include "trek.fullname" . }}-config + {{- with .Values.extraEnvFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} env: - name: ENCRYPTION_KEY valueFrom: @@ -99,11 +103,42 @@ spec: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: VAPID_PRIVATE_KEY optional: true + - name: SMTP_PASS + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: SMTP_PASS + optional: true + - name: MAPBOX_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: MAPBOX_ACCESS_TOKEN + optional: true + - name: CARTO_API_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: CARTO_API_KEY + optional: true + - name: DEMO_ADMIN_PASS + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: DEMO_ADMIN_PASS + optional: true + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} volumeMounts: - name: data mountPath: /app/data - name: uploads mountPath: /app/uploads + {{- with (default dict .Values.probes).startup }} + startupProbe: + {{- toYaml . | nindent 12 }} + {{- end }} {{- with (default dict .Values.probes).liveness }} livenessProbe: {{- toYaml . | nindent 12 }} diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml index de5ce8222..fd02abf56 100644 --- a/charts/trek/templates/secret.yaml +++ b/charts/trek/templates/secret.yaml @@ -32,6 +32,18 @@ data: {{- if .Values.secretEnv.VAPID_PRIVATE_KEY }} VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }} {{- end }} + {{- if .Values.secretEnv.SMTP_PASS }} + SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | b64enc | quote }} + {{- end }} + {{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }} + MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | b64enc | quote }} + {{- end }} + {{- if .Values.secretEnv.CARTO_API_KEY }} + CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | b64enc | quote }} + {{- end }} + {{- if .Values.secretEnv.DEMO_ADMIN_PASS }} + DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | b64enc | quote }} + {{- end }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} @@ -74,4 +86,16 @@ stringData: {{- if .Values.secretEnv.VAPID_PRIVATE_KEY }} VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | quote }} {{- end }} + {{- if .Values.secretEnv.SMTP_PASS }} + SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | quote }} + {{- end }} + {{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }} + MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | quote }} + {{- end }} + {{- if .Values.secretEnv.CARTO_API_KEY }} + CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | quote }} + {{- end }} + {{- if .Values.secretEnv.DEMO_ADMIN_PASS }} + DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | quote }} + {{- end }} {{- end }} diff --git a/charts/trek/templates/service.yaml b/charts/trek/templates/service.yaml index f63e56fe3..0226f2e43 100644 --- a/charts/trek/templates/service.yaml +++ b/charts/trek/templates/service.yaml @@ -8,7 +8,7 @@ spec: type: {{ .Values.service.type }} ports: - port: {{ .Values.service.port }} - targetPort: 3000 + targetPort: http protocol: TCP name: http selector: diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 64f74c24d..4286d2cc1 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -4,9 +4,10 @@ image: # tag: latest pullPolicy: IfNotPresent -# Deployment update strategy. Recreate is the safe default for the single-writer SQLite -# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts). -# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage. +# Deployment update strategy. Keep Recreate: TREK is a single-writer SQLite app, and +# during a RollingUpdate the new pod migrates the database while the old one still +# writes to it. A ReadWriteMany volume does not make that safe; SQLite's WAL needs both +# processes on one host, which network filesystems do not provide. updateStrategy: Recreate # Optional image pull secrets for private registries @@ -33,7 +34,7 @@ env: # DEFAULT_LANGUAGE: "en" # Default language on the login page for users with no saved preference. # Browser/OS language is auto-detected first; this is the fallback when no match is found. - # Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar + # Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW # ALLOWED_ORIGINS: "" # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. # APP_URL: "https://trek.example.com" @@ -168,15 +169,19 @@ secretEnv: # Leave both empty to let TREK keep a generated pair in its database. VAPID_PRIVATE_KEY: "" -# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades +# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades. +# Only with helm install/upgrade against the cluster: under `helm template`, ArgoCD or Flux +# the chart cannot read the existing secret and renders a new key every time. TREK then +# refuses to start rather than lose its secrets; use existingSecret in those setups. generateEncryptionKey: false # If set, the chart renders no Secret of its own and instead reads every secret env # var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via # existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET, # UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET, -# VAPID_PRIVATE_KEY. Missing keys are simply left unset (they are optional), so only -# provide the ones your setup needs. +# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS. +# Missing keys are simply left unset (they are optional), so only provide the ones your +# setup needs. existingSecret: "" existingSecretKey: ENCRYPTION_KEY @@ -202,20 +207,40 @@ persistence: # httpGet handler. Switching handler means clearing the old one in the same override # (httpGet: null together with tcpSocket), otherwise the API rejects the pod with # "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe. +# +# The startup probe gives a first boot, a long migration or a restore at boot up to ten +# minutes before liveness takes over; liveness alone killed such a boot after about +# 105 s, and the next start began it again. Readiness asks the database, so a pod whose +# database does not answer stops getting traffic without being restarted. probes: + startup: + httpGet: + path: /api/health + port: http + periodSeconds: 10 + failureThreshold: 60 liveness: httpGet: path: /api/health - port: 3000 - initialDelaySeconds: 15 + port: http periodSeconds: 30 readiness: httpGet: - path: /api/health - port: 3000 - initialDelaySeconds: 5 + path: /api/health/ready + port: http periodSeconds: 10 +# Further environment variables, for any setting the env block above does not list. +# extraEnv: +# - name: SESSION_DURATION +# value: 7d +extraEnv: [] +# Whole ConfigMaps or Secrets: +# extraEnvFrom: +# - secretRef: +# name: trek-smtp +extraEnvFrom: [] + resources: requests: cpu: 100m diff --git a/docker-compose.yml b/docker-compose.yml index 169e5c6b9..51580510d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details # - TREK_WIKI_DIR=/app/wiki # Where the in-app Help pages (/help) read their content from. Leave unset: the image ships the wiki at /app/wiki and finds it automatically. Only set this to serve your own docs, mounted into the container. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release rather than your version). -# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar +# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW # - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h # - SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login: longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links @@ -69,7 +69,8 @@ services: # - ./storage-config.json:/app/data/storage-config.json:ro # optional seed-once storage provisioning (wiki: Admin-Storage) restart: unless-stopped healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] + # Through a shell so a changed PORT is followed. + test: ["CMD-SHELL", "wget -qO- http://localhost:$${PORT:-3000}/api/health || exit 1"] interval: 30s timeout: 10s retries: 3 diff --git a/server/src/db/repositories/MaintenanceRepository.ts b/server/src/db/repositories/MaintenanceRepository.ts index c420d222f..3f2877024 100644 --- a/server/src/db/repositories/MaintenanceRepository.ts +++ b/server/src/db/repositories/MaintenanceRepository.ts @@ -39,6 +39,12 @@ export class MaintenanceRepository { this.em.getContext(); } + /** `SELECT 1`: whether the database answers at all, for the readiness probe. */ + async ping(): Promise { + this.validateRequestContext(); + await this.em.getConnection().execute('SELECT 1'); + } + /** * `PRAGMA wal_checkpoint(TRUNCATE)` — rendered text pinned, identical to * the legacy `db.exec('PRAGMA wal_checkpoint(TRUNCATE)')` it replaces at diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 21c872a89..e6e67656a 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -348,7 +348,7 @@ export function applyGlobalMiddleware( if (shouldForceHttps) { app.use((req: Request, res: Response, next: NextFunction) => { - if (req.path === '/api/health') return next(); + if (req.path === '/api/health' || req.path === '/api/health/ready') return next(); if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next(); res.redirect(301, 'https://' + (configuredHost ?? req.headers.host) + req.url); }); @@ -358,7 +358,7 @@ export function applyGlobalMiddleware( // Request logging with sensitive field redaction (SENSITIVE_KEYS/redact above) app.use((req: Request, res: Response, next: NextFunction) => { - if (req.path === '/api/health') return next(); + if (req.path === '/api/health' || req.path === '/api/health/ready') return next(); const startedAt = Date.now(); res.on('finish', () => { const ms = Date.now() - startedAt; diff --git a/server/src/nest/common/validate-route-guards.ts b/server/src/nest/common/validate-route-guards.ts index 033c30c3e..2432214b7 100644 --- a/server/src/nest/common/validate-route-guards.ts +++ b/server/src/nest/common/validate-route-guards.ts @@ -114,8 +114,9 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [ // run. It never reads the request body as data. 'DocSyncWebhookController.nudge', 'FeaturesController.features', - // The container/uptime probe. + // The container/uptime probe, and the readiness probe beside it. 'FeaturesController.health', + 'FeaturesController.ready', // Subscribable ICS feeds. The token in the path is the credential, and the // calendar client polling it has no TREK session to send. 'FeedsPublicController.tripFeed', diff --git a/server/src/nest/health/features.controller.ts b/server/src/nest/health/features.controller.ts index a13a2d6a1..a7eb06fa5 100644 --- a/server/src/nest/health/features.controller.ts +++ b/server/src/nest/health/features.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Res } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/core'; import type { Response } from 'express'; +import { MaintenanceRepository } from '../../db/repositories/MaintenanceRepository'; import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service'; import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; @@ -13,6 +15,7 @@ export class FeaturesController { constructor( private readonly extractor: KitineraryExtractorService, private readonly addons: AddonsService, + private readonly em: EntityManager, ) {} /** The container/uptime probe. The forced-HTTPS redirect and HSTS exempt this @@ -24,6 +27,23 @@ export class FeaturesController { res.json({ status: 'ok' }); } + /** + * The readiness probe: 503 while the database does not answer (a restore + * swapping it, a boot still migrating), so an orchestrator stops sending + * traffic without killing the process. Liveness stays on the plain probe + * above, which a long restore must not fail. + */ + @Get('ready') + async ready(@Res() res: Response): Promise { + res.setHeader('Cache-Control', 'no-store, must-revalidate'); + try { + await new MaintenanceRepository(this.em).ping(); + res.json({ status: 'ready' }); + } catch { + res.status(503).json({ status: 'unavailable' }); + } + } + @Get('features') async features() { return { diff --git a/server/tests/unit/nest/features.controller.test.ts b/server/tests/unit/nest/features.controller.test.ts index 7dfd2abcb..8c513713d 100644 --- a/server/tests/unit/nest/features.controller.test.ts +++ b/server/tests/unit/nest/features.controller.test.ts @@ -16,7 +16,7 @@ function make(available: boolean, aiEnabled: boolean) { return { extractor, addons, - controller: new FeaturesController(extractor as Extractor as KitineraryExtractorService, addons as never), + controller: new FeaturesController(extractor as Extractor as KitineraryExtractorService, addons as never, {} as never), }; } @@ -83,4 +83,31 @@ describe('FeaturesController (GET /api/health/features)', () => { expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate'); expect(res.body).toEqual({ status: 'ok' }); }); + + describe('the readiness probe (GET /api/health/ready)', () => { + function response() { + const res = { setHeader: vi.fn(), status: vi.fn(), json: vi.fn() }; + res.status.mockReturnValue(res); + return res; + } + + it('FEAT-010: answers ready while the database answers, and 503 when it does not', async () => { + const execute = vi.fn().mockResolvedValue([]); + const em = { getConnection: () => ({ execute }), getContext: () => em }; + const extractor = { isAvailable: vi.fn(() => true) }; + const controller = new FeaturesController(extractor as Extractor as KitineraryExtractorService, {} as never, em as never); + + const ok = response(); + await controller.ready(ok as never); + expect(execute).toHaveBeenCalledWith('SELECT 1'); + expect(ok.json).toHaveBeenCalledWith({ status: 'ready' }); + expect(ok.status).not.toHaveBeenCalled(); + + execute.mockRejectedValueOnce(new Error('connection is not available (restore in progress?)')); + const down = response(); + await controller.ready(down as never); + expect(down.status).toHaveBeenCalledWith(503); + expect(down.json).toHaveBeenCalledWith({ status: 'unavailable' }); + }); + }); }); diff --git a/unraid-template.xml b/unraid-template.xml index e036f7a9f..cfea2ce7d 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -34,7 +34,7 @@ UTC info - en + en false