187 lines
5.8 KiB
JavaScript
187 lines
5.8 KiB
JavaScript
import dotenv from "dotenv";
|
|
dotenv.config();
|
|
|
|
import express from "express";
|
|
import morgan from "morgan";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
|
|
import giteaWebhook from "./routes/giteaWebhook.js";
|
|
import issueProcessor, { isRunning as workerIsRunning } from "./workers/issueProcessor.js";
|
|
import errorHandler from "./middleware/errorHandler.js";
|
|
import logger from "./lib/logger.js";
|
|
import config from "./config/index.js";
|
|
import sheetsAdapter from "./adapters/sheetsAdapter.js";
|
|
|
|
const app = express();
|
|
|
|
// use morgan but route logs through structured logger
|
|
app.use(morgan("combined", { stream: { write: (s) => logger.info(s.trim()) } }));
|
|
|
|
// capture raw body for webhook signature verification on the gitea webhook route
|
|
app.use(
|
|
"/webhooks/gitea",
|
|
express.json({ verify: (req, res, buf) => (req.rawBody = buf) })
|
|
);
|
|
|
|
// regular JSON parser for other routes
|
|
app.use(express.json());
|
|
|
|
app.use("/webhooks/gitea", giteaWebhook);
|
|
|
|
app.get("/health", (req, res) => {
|
|
res.json({ status: "ok", uptime: process.uptime(), pid: process.pid, timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
app.get("/ready", async (req, res) => {
|
|
const missing = [];
|
|
|
|
// config checks
|
|
if (!config.giteaWebhookSecret) missing.push("GITEA_WEBHOOK_SECRET");
|
|
if (!config.googleSheetId) missing.push("GOOGLE_SHEET_ID");
|
|
|
|
const hasGoogleCreds = !!(
|
|
process.env.GOOGLE_SERVICE_ACCOUNT_JSON ||
|
|
process.env.GOOGLE_APPLICATION_CREDENTIALS ||
|
|
(process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) ||
|
|
fs.existsSync(path.resolve(process.cwd(), "service-account.json"))
|
|
);
|
|
|
|
if (!hasGoogleCreds) missing.push("Google service account credentials");
|
|
|
|
if (missing.length) {
|
|
logger.warn("Readiness check failed (missing config)", { missing });
|
|
return res.status(503).json({ ready: false, missing });
|
|
}
|
|
|
|
// active adapter readiness probe
|
|
try {
|
|
const ok = await sheetsAdapter.readinessProbe();
|
|
if (!ok) {
|
|
logger.warn("Readiness probe: sheets adapter not ready");
|
|
return res.status(503).json({ ready: false, missing: ["google_sheets_unreachable"] });
|
|
}
|
|
} catch (err) {
|
|
logger.warn("Readiness probe error", { err: err && err.message ? err.message : err });
|
|
return res.status(503).json({ ready: false, missing: ["google_sheets_unreachable"] });
|
|
}
|
|
|
|
// worker status
|
|
if (!workerIsRunning()) {
|
|
logger.warn("Readiness check failed: worker not running");
|
|
return res.status(503).json({ ready: false, missing: ["worker_not_running"] });
|
|
}
|
|
|
|
return res.json({ ready: true, uptime: process.uptime(), timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
const PORT = config.port;
|
|
function validateConfig() {
|
|
const missing = [];
|
|
|
|
if (!config.giteaWebhookSecret) missing.push("GITEA_WEBHOOK_SECRET");
|
|
if (!config.googleSheetId) missing.push("GOOGLE_SHEET_ID");
|
|
|
|
const hasGoogleCreds = !!(
|
|
process.env.GOOGLE_SERVICE_ACCOUNT_JSON ||
|
|
process.env.GOOGLE_APPLICATION_CREDENTIALS ||
|
|
(process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) ||
|
|
fs.existsSync(path.resolve(process.cwd(), "service-account.json"))
|
|
);
|
|
|
|
if (!hasGoogleCreds) missing.push("Google service account credentials");
|
|
|
|
if (missing.length) {
|
|
logger.warn("Startup config warning: missing env vars:", { missing });
|
|
logger.warn("See SECRETS.md for secure configuration instructions.");
|
|
}
|
|
}
|
|
|
|
validateConfig();
|
|
|
|
// Start background workers (no business logic implemented yet)
|
|
issueProcessor.start();
|
|
|
|
// Central error handler
|
|
app.use(errorHandler);
|
|
|
|
const server = app.listen(PORT, "0.0.0.0", () => {
|
|
logger.info("Server running on port", { port: PORT });
|
|
});
|
|
|
|
// track active connections so we can force-close them on shutdown
|
|
const connections = new Set();
|
|
server.on("connection", (socket) => {
|
|
connections.add(socket);
|
|
socket.on("close", () => connections.delete(socket));
|
|
});
|
|
|
|
const SHUTDOWN_GRACE_MS = process.env.SHUTDOWN_GRACE_MS ? Number(process.env.SHUTDOWN_GRACE_MS) : 10000;
|
|
|
|
let shuttingDown = false;
|
|
async function shutdown(signal) {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
logger.info("Shutdown initiated", { signal });
|
|
|
|
// stop accepting new connections
|
|
const closeServer = new Promise((resolve) => {
|
|
try {
|
|
server.close((err) => {
|
|
if (err) logger.warn("Error closing server", { err: err && err.message ? err.message : err });
|
|
else logger.info("HTTP server closed");
|
|
resolve();
|
|
});
|
|
} catch (e) {
|
|
logger.warn("Error during server.close", { err: e && e.message ? e.message : e });
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
// schedule forced socket destruction after grace period
|
|
const forceTimeout = setTimeout(() => {
|
|
logger.warn("Forcing destruction of active connections after grace period");
|
|
for (const s of connections) {
|
|
try {
|
|
s.destroy();
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
}, SHUTDOWN_GRACE_MS);
|
|
|
|
await closeServer;
|
|
|
|
// stop workers and drain adapter queues
|
|
try {
|
|
await issueProcessor.stop(SHUTDOWN_GRACE_MS);
|
|
} catch (e) {
|
|
logger.warn("Error stopping workers", { err: e && e.message ? e.message : e });
|
|
}
|
|
|
|
// final attempt to wait for adapter
|
|
try {
|
|
await sheetsAdapter.waitForIdle(SHUTDOWN_GRACE_MS);
|
|
} catch (e) {
|
|
logger.warn("Timeout waiting for adapter to become idle", { err: e && e.message ? e.message : e });
|
|
}
|
|
|
|
clearTimeout(forceTimeout);
|
|
|
|
logger.info("Shutdown complete, exiting");
|
|
// give a moment for logs to flush
|
|
setTimeout(() => process.exit(0), 100);
|
|
}
|
|
|
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
|
|
process.on("unhandledRejection", (reason) => {
|
|
logger.error("UnhandledRejection", { reason });
|
|
shutdown("unhandledRejection");
|
|
});
|
|
|
|
process.on("uncaughtException", (err) => {
|
|
logger.error("UncaughtException", { err: err && err.stack ? err.stack : err });
|
|
shutdown("uncaughtException");
|
|
}); |