feat: podpora high-availability a multi-replica nasazení

- Socket.io Redis adapter pro sdílený stav přes repliky
- graceful shutdown serveru
- WATCH/MULTI v updateData pro race-condition-safe aktualizace
- lease mechanismus pro push reminder (zabrání duplicitnímu odesílání)
- k8s/ manifesty pro testovací kind cluster
- Dockerfile: opraven EXPOSE port na 3001
- .gitignore: ignorovány Claude pracovní soubory
This commit is contained in:
2026-06-10 11:23:20 +02:00
committed by batmanisko
parent 17132d4124
commit df5423511f
31 changed files with 1252 additions and 510 deletions
+79 -34
View File
@@ -9,9 +9,11 @@ import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth";
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
import { getPendingQrs } from "./pizza";
import { initWebsocket, getWebsocket } from "./websocket";
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
import { storageReady } from "./storage";
import getStorage from "./storage";
import { shutdownRedisStorage } from "./storage/redis";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import suggestionRoutes from "./routes/suggestionRoutes";
@@ -27,23 +29,24 @@ import storeRoutes from "./routes/storeRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
const app = express();
const server = require("http").createServer(app);
// Tune keep-alive timeouts to outlive Traefik's 60s idle timeout.
// headersTimeout must be strictly greater than keepAliveTimeout.
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
server.requestTimeout = 30_000;
initWebsocket(server);
// Body-parser middleware for parsing JSON
app.use(bodyParser.json());
app.use(cors({ origin: '*' }));
app.use(cors({
origin: '*'
}));
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) {
@@ -51,19 +54,69 @@ if (HTTP_REMOTE_USER_ENABLED) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
}
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
//TODO: nevim jak udelat console.log pouze pro "debug"
//console.log("Budu věřit hlavičkám z: " + HTTP_REMOTE_TRUSTED_IPS);
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
console.log('Zapnutý login přes hlavičky z proxy.');
}
// ─── Shutdown state ──────────────────────────────────────────────────────────
// ----------- Metody nevyžadující token --------------
let shuttingDown = false;
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal} received — initiating graceful shutdown`);
// Hard-exit failsafe: fires before terminationGracePeriodSeconds (30s)
setTimeout(() => {
console.error('Graceful shutdown timed out, forcing exit');
process.exit(1);
}, 25_000).unref();
// Disconnect WebSocket clients so they reconnect to another pod
const io = getWebsocket();
io?.disconnectSockets(true);
// Stop accepting new HTTP connections and drain in-flight requests
(server as any).closeIdleConnections?.();
await new Promise<void>(resolve => server.close(() => resolve()));
// Stop reminder scheduler and release leader lease
stopReminderScheduler();
await releaseReminderLease();
// Shut down Redis pub/sub clients (Socket.io adapter)
await shutdownWebsocketClients();
// Shut down main Redis storage client
if (process.env.STORAGE?.toLowerCase() === 'redis') {
await shutdownRedisStorage();
}
console.log('Graceful shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// ─── Routes — no auth required ───────────────────────────────────────────────
/** Liveness probe — cheap, no external deps. */
app.get("/api/health", (_req, res) => {
res.status(200).json({ ok: true });
});
/** Readiness probe — verifies Redis connectivity and rejects traffic during shutdown. */
app.get("/api/health/ready", async (_req, res) => {
if (shuttingDown) {
return res.status(503).json({ ok: false, reason: 'shutting down' });
}
const healthy = await getStorage().healthCheck?.() ?? true;
if (!healthy) return res.status(503).json({ ok: false, reason: 'storage unavailable' });
res.status(200).json({ ok: true });
});
app.get("/api/whoami", (req, res) => {
if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
@@ -76,21 +129,17 @@ app.get("/api/whoami", (req, res) => {
})
app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// Autentizace pomocí trusted headers
if (HTTP_REMOTE_USER_ENABLED) {
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
//const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
} else {
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
}
} else {
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) {
throw new Error("Nebyl předán login");
}
// TODO zavést podmínky pro délku loginu (min i max)
res.status(200).json(generateToken(req.body.login, false));
}
});
@@ -111,12 +160,10 @@ app.get("/api/qr", async (req, res) => {
res.end(img);
});
// ----------------------------------------------------
// ─── Semi-public routes ───────────────────────────────────────────────────────
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT)
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
try {
const { login, token } = req.body ?? {};
@@ -132,10 +179,10 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
} catch (e: any) { next(e); }
});
/** Middleware ověřující JWT token */
// ─── Auth middleware ──────────────────────────────────────────────────────────
app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) {
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"]
@@ -158,7 +205,8 @@ app.use("/api/", (req, res, next) => {
next();
});
/** Vrátí data pro aktuální den. */
// ─── Authenticated routes ─────────────────────────────────────────────────────
app.get("/api/data", async (req, res) => {
let date = undefined;
if (req.query.date != null && typeof req.query.date === 'string') {
@@ -175,7 +223,6 @@ app.get("/api/data", async (req, res) => {
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
}
} else if (getIsWeekend(getToday())) {
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
date = getDateForWeekIndex(4);
}
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
@@ -183,7 +230,6 @@ app.get("/api/data", async (req, res) => {
return res.status(400).json({ error: 'Neplatný slot' });
}
const data = await getData(date, slotParam);
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
try {
const login = getLogin(parseToken(req));
const pendingQrs = await getPendingQrs(login);
@@ -196,7 +242,6 @@ app.get("/api/data", async (req, res) => {
res.status(200).json(data);
});
// Ostatní routes
app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes);
app.use("/api/suggestions", suggestionRoutes);
@@ -214,7 +259,7 @@ app.get('*splat', (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
// Middleware pro zpracování chyb
// Error handling middleware
app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message })
@@ -226,18 +271,18 @@ app.use((err: any, req: any, res: any, next: any) => {
next();
});
// ─── Bootstrap ────────────────────────────────────────────────────────────────
const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST ?? '0.0.0.0';
storageReady.then(() => {
storageReady.then(async () => {
// Init Redis adapter after storage is connected (only in Redis mode)
if (process.env.STORAGE?.toLowerCase() === 'redis') {
await initRedisAdapter();
}
server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`);
startReminderScheduler();
});
});
// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí
process.on('SIGINT', function () {
console.log("\nSIGINT (Ctrl-C), vypínám server");
process.exit(0);
});