Author SHA1 Message Date
naeel 932e5ca5b6 Fix: таймаут с диагностикой PG (URL + статус БД). Версия 0.1.25 2026-07-09 10:37:32 +04:00
naeel bf24d1577f Fix: таймаут 10с на все запросы — не висим, не даём nginx 503. Версия 0.1.25 2026-07-09 10:36:17 +04:00
naeel e5b536c3ce Fix: connectionTimeoutMillis=5s, idleTimeoutMillis=10s — пул не виснет. Версия 0.1.24 2026-07-09 07:57:06 +04:00
naeel 8e88d6eed7 Миграции: JS-строки, транзакции, _migrations таблица. Без fs/path. Версия 0.1.23 2026-07-09 07:32:50 +04:00
naeel 9557e83d47 /v2/db/query — SELECT SQL-endpoint для ADMIN_EMAIL. Версия 0.1.22 2026-07-09 07:30:11 +04:00
naeel 5d33887165 Док: консультация с Соннетом — EPERM не PG, проблема платформы 2026-07-09 07:24:08 +04:00
naeel b3f5c4903e Чистый ensureSchema: только CREATE TABLE IF NOT EXISTS, без миграций. Версия 0.1.21 2026-07-09 00:27:53 +04:00
naeel 3e15fd4973 Fix: DO$$ блок проверяет RESTORE перед DROP — идемпотентно. Версия 0.1.20 2026-07-09 00:26:31 +04:00
naeel 2390c655f9 Док: разбор инцидента 2026-07-08 (PG OOM + EPERM) 2026-07-09 00:21:31 +04:00
naeel 2a19195dc7 Расширенная диагностика PG: /v2/db-status — DNS, коннект, таблицы, пул. Версия 0.1.19 2026-07-09 00:02:10 +04:00
naeel a3aaeef5d1 /v2/db-status — проверка DNS и подключения к PG. Версия 0.1.18 2026-07-09 00:00:30 +04:00
naeel 9dec159e3d Fix: errorPage undefined — заменён на inline HTML. Версия 0.1.17 2026-07-08 22:22:02 +04:00
naeel 158de70e4e Fix: /ready всегда 200 — nginx не блокирует трафик. Версия 0.1.16 2026-07-08 22:20:09 +04:00
naeel cfdbae0653 Версия 0.1.15 2026-07-08 21:29:43 +04:00
naeel 036441a9f1 Fix: ошибки показывают реальную причину, не скрытую 2026-07-08 21:29:28 +04:00
naeel 1b902c5540 Fix: убран process.exit(1) — приложение не падает, показывает сообщение об ошибке. Версия 0.1.14 2026-07-08 21:20:43 +04:00
naeel 771473c97a Откат миграций: старый ensureSchema без applyMigrations 2026-07-08 15:09:26 +04:00
10 changed files with 381 additions and 107 deletions
+88
View File
@@ -0,0 +1,88 @@
# Инцидент 2026-07-08: PostgreSQL OOM + EPERM
## Итоговая цепочка
```
0.1.13 деплой
→ fs.readdirSync throws ENOENT (sql/migrations/ не в образе)
→ process.exit(1)
→ k8s CrashLoopBackoff, rapid restarts
→ каждый рестарт: новый pool + тяжёлый DO$$ блок на PG
→ 20-30 concurrent connections + ALTER locks
→ PG 512 MB → 134% OOM → PG крашится
→ NodeJS теряет PG → дальнейшие рестарты уже без PG
→ после recovery PG: stale iptables на ноде NodeJS
→ EPERM до полного обновления kube-proxy
→ редеплой NodeJS → чистый pod → работает
```
---
## Детальный разбор
### 1. Почему PG ушёл в OOM?
**512 MB = катастрофически мало для PG 17 с этой нагрузкой.** Zalando operator выставляет `shared_buffers = 25% RAM = 128 MB`. Каждое подключение стоит ~5–10 MB.
Триггеры при деплое 0.1.13:
**a) Rolling update → двойное число подключений.** Пока k8s поднимал новый под, старый ещё жил. Новый под открывает pool (10 соединений) плюс старый не успел корректно закрыть свой (`process.exit(1)` без `pool.end()`). PG видел 20–30 соединений → +100-200 MB.
**b) DO $$ блок в `schema.js`.** При каждом старте делал `ALTER TABLE ... DROP CONSTRAINT` + `ADD CONSTRAINT` (AccessExclusiveLock + rewrite). Несколько подов подряд → очередь блокировок → спайк памяти.
**c) connect-pg-simple + `saveUninitialized: true`.** Health-check'и создавали пустые сессии (вторичный фактор).
### 2. EPERM после recovery
`EPERM` на TCP-коннекте — ошибка **ядра** на уровне iptables/netfilter.
После recovery PG-под получил новый Pod IP. k8s Service IP (`10.102.125.70`) не менялся, но `kube-proxy` должен был обновить iptables-правила — процесс асинхронный.
NodeJS делал `connect(10.102.125.70:5432)`, iptables пытался перенаправить на старый Pod IP PG (уже не существующий) → ядро возвращало `EPERM`.
DNS здесь ни при чём — `svc.cluster.local` резолвился правильно.
### 3. Как правильно делать readiness probe?
**`/ready` всегда 200 — правильное решение.** Если `/ready` возвращает 503:
1. Все поды → NotReady
2. nginx → 503
3. k8s перезапускает поды
4. Новые поды создают соединения к нагруженному PG
5. **Каскадный сбой**
Модель:
```
/healthz → всегда 200 (liveness)
/ready → всегда 200 (readiness)
+ страницы показывают user-friendly ошибку
```
### 4. 6202 записей session — нормально ли?
Да. 6202 / 8 часов = ~775/час = ~13/минута. Для многокомпанийного сервиса разумно.
`saveUninitialized: true` создаёт сессии от ботов/сканеров. Для PG не критично: 6202 × ~5 KB ≈ 31 MB.
### 5. `fs.readdirSync` в managed k8s
`fs` доступен, но SQL-файлы должны быть в Docker-образе. Если `.dockerignore` их исключил → `ENOENT` → crash → k8s рестарты → OOM PG.
---
## Что починили
1. `process.exit(1)` убран везде — приложение не падает
2. `/ready` всегда 200 — nginx не блокирует трафик
3. Ошибки показываются на странице
4. PG: 512 MB → 2048 MB
---
## Рекомендации
1. **DO $$ DROP+ADD CONSTRAINT** — запускать один раз, проверять через `pg_constraint`
2. **PG memory** — минимум 1 GB для production
3. **`saveUninitialized: false`** для публичных роутов
4. **SQL-миграции** — включить в образ явно или встроить в js-строки
5. **`pool.end()`** — уже реализован в SIGTERM, но при `process.exit(1)` без SIGTERM этого не было
+33
View File
@@ -0,0 +1,33 @@
# Консультация с Соннетом: инцидент 2026-07-08 (продолжение)
## Вопрос 1: Может ли PG быть повреждён после OOM при нормальных метриках?
Да, может. Сценарий:
- OOM при активной записи → WAL-запись оборвана на середине
- PG поднялся, прошёл recovery по WAL, считает себя здоровым
- Grafana показывает метрики процесса PG — они нормальные
- Но на уровне отдельных relation-файлов могут быть битые страницы, которые проявляются только при обращении к конкретной таблице/индексу
EPERM при `connect()` — это не PG-уровневая ошибка, это OS/сеть. PG повреждение дало бы `ECONNRESET`, `53300` (too many connections), или ошибку внутри сессии. Если EPERM именно на `connect()` — смотри в сторону сети/платформы, не PG internals.
## Вопрос 2: Достаточно ли разницы PG для объяснения?
Нет. EPERM на `connect()` — это не симптом повреждённого PG. Повреждённый PG дал бы:
- `FATAL: could not open file`
- `ERROR: invalid page in block`
- Или просто отклонял бы соединения с ошибкой pg-level
EPERM — это ядро Linux отказывает в системном вызове `connect()`. PG здесь ни при чём. Ищи причину на стороне Node.js процесса или платформы.
## Вопрос 3: Мог ли managed-сервис перегенерировать credentials при recovery?
Да, это распространённая практика у managed PG — при `recovery` (особенно из backup/snapshot) могут:
- Сброситься пароли к дефолтным
- Создаться новый пользователь вместо старого
- Измениться SSL CA-сертификат
Как проверить без прямого доступа — через `/v2/db-status` (уже есть в коде)
## Ключевой вывод
EPERM на connect() — проблема сети/платформы, не PG и не нашего кода.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ipwhitelist", "name": "ipwhitelist",
"version": "0.1.13", "version": "0.1.25",
"description": "IP WhiteList microservice for cloud provider", "description": "IP WhiteList microservice for cloud provider",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+58 -27
View File
@@ -57,27 +57,45 @@ app.use(cookieParser());
async function start() { async function start() {
const auth = await initAuth(); const auth = await initAuth();
// ── Стоп-кран: если запрос висит > 10 сек — проверяем PG и показываем детали
app.use((req, res, next) => {
const timer = setTimeout(async () => {
if (!res.headersSent) {
let diag = '';
try {
const { pool } = require('./src/db');
const start = Date.now();
await pool.query('SELECT 1');
diag = 'PG OK (' + (Date.now() - start) + 'ms)';
} catch (e) {
diag = 'PG ERROR: ' + e.message;
}
res.status(503).type('html').send(
'<h2>Сервис временно недоступен</h2>'
+ '<p>Таймаут запроса: 10 сек</p>'
+ '<p>URL: ' + req.method + ' ' + req.url + '</p>'
+ '<p>' + diag + '</p>'
);
}
}, 10000);
res.on('finish', () => clearTimeout(timer));
next();
});
// ── Сессии ──────────────────────────────────────────────────────────────── // ── Сессии ────────────────────────────────────────────────────────────────
const { pool } = require('./src/db'); const { pool } = require('./src/db');
app.use(createSessionMiddleware(pool)); app.use(createSessionMiddleware(pool));
// ── Fail-fast в production ──────────────────────────────────────────────── // ── Fail-fast в production (без exit — сообщение в лог) ────────────────────
// Если секреты не заданы — приложение в проде немедленно падает. const DEFAULT_SESSION = 'dev-session-secret-change-me';
// Дефолтные значения — только для локальной разработки. let fatalError = null;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
const DEFAULT_SESSION = 'dev-session-secret-change-me'; if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION)
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION) { fatalError = 'SESSION_SECRET не задан или равен дефолту в production';
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!'); else if (!process.env.DB_PASS)
process.exit(1); fatalError = 'DB_PASS не задан в production';
} else if (!process.env.CSRF_SECRET || process.env.CSRF_SECRET === 'dev-csrf-secret-change-in-prod')
if (!process.env.DB_PASS) { fatalError = 'CSRF_SECRET не задан или равен дефолту в production';
console.error('FATAL: DB_PASS не задан в production!');
process.exit(1);
}
if (!process.env.CSRF_SECRET || process.env.CSRF_SECRET === 'dev-csrf-secret-change-in-prod') {
console.error('FATAL: CSRF_SECRET не задан или равен дефолту в production!');
process.exit(1);
}
} }
// k8s liveness + readiness probes // k8s liveness + readiness probes
@@ -85,12 +103,21 @@ async function start() {
app.get('/ready', async (req, res) => { app.get('/ready', async (req, res) => {
try { try {
await pool.query('SELECT 1'); await pool.query('SELECT 1');
res.send('OK');
} catch (e) { } catch (e) {
res.status(503).send('DB not ready'); // БД не отвечает — но приложение открыто, покажет ошибку на страницах
} }
res.send('OK');
}); });
// Если фатальная ошибка конфигурации — показываем на всех страницах сообщение
if (fatalError) {
console.error(fatalError);
app.use((req, res) => {
res.status(503).type('html').send('<html><head><meta charset="utf-8"><title>Авария</title><style>body{font-family:sans-serif;padding:40px;text-align:center}h1{color:#c00}</style></head><body><h1>⚙️ Ошибка конфигурации</h1><p>' + fatalError + '</p></body></html>');
});
return app;
}
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме) // JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler); if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
@@ -121,7 +148,7 @@ async function start() {
? (err.stack || err.message || String(err)) ? (err.stack || err.message || String(err))
: JSON.stringify(err); : JSON.stringify(err);
console.error('Unhandled error:', ctx, detail); console.error('Unhandled error:', ctx, detail);
res.status(500).send('Внутренняя ошибка сервера'); res.status(500).send('<h2>Ошибка</h2><pre>' + detail + '</pre>');
}); });
// ── Старт сервера ───────────────────────────────────────────────────────────── // ── Старт сервера ─────────────────────────────────────────────────────────────
@@ -136,20 +163,24 @@ async function start() {
if (require.main === module) { if (require.main === module) {
start() start()
.then(a => { .then(a => {
const server = a.listen(PORT, () => console.log('Server on port ' + PORT)); a.listen(PORT, () => console.log('Server on port ' + PORT));
// Graceful shutdown по SIGTERM (k8s) // Graceful shutdown по SIGTERM (k8s)
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
console.log('SIGTERM — shutting down...'); console.log('SIGTERM — shutting down...');
server.close(() => { const { pool } = require('./src/db');
console.log('HTTP closed'); pool.end().then(() => { console.log('DB pool closed'); process.exit(0); });
const { pool } = require('./src/db');
pool.end().then(() => { console.log('DB pool closed'); process.exit(0); });
});
setTimeout(() => { console.log('Forced exit'); process.exit(0); }, 10000); setTimeout(() => { console.log('Forced exit'); process.exit(0); }, 10000);
}); });
return server;
}) })
.catch(e => { console.error('Startup error:', e); process.exit(1); }); .catch(e => {
console.error('Startup error:', e.message);
// Не падаем — запускаем HTTP с сообщением об ошибке
const errorApp = require('express')();
errorApp.use((req, res) => {
res.status(503).type('html').send('<html><head><meta charset="utf-8"><title>Авария</title></head><body style="font-family:sans-serif;padding:40px;text-align:center"><h1>⚙️ Сервис временно недоступен</h1><p>Причина: ' + (e.message || 'неизвестна') + '</p><p>Попробуйте обновить страницу через минуту</p></body></html>');
});
errorApp.listen(PORT, () => console.log('Server on port ' + PORT + ' (ERROR MODE)'));
});
} }
module.exports = { start }; module.exports = { start };
+2 -1
View File
@@ -9,7 +9,8 @@ const pool = new Pool({
password: process.env.DB_PASS, password: process.env.DB_PASS,
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false, ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
max: 10, max: 10,
idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000,
idleTimeoutMillis: 10000,
}); });
pool.on('error', (err) => { pool.on('error', (err) => {
+156
View File
@@ -65,6 +65,101 @@ function createV2Router({ generateCsrfToken, doubleCsrfProtection } = {}) {
} }
}); });
// ── GET /v2/db-status — диагностика подключения к PG ─────────────
router.get('/db-status', async (req, res) => {
const dns = require('dns').promises;
const { pool } = require('./src/db');
const host = process.env.DB_HOST || 'localhost';
const port = process.env.DB_PORT || 5432;
const db = process.env.DB_NAME || 'ipwhitelist';
const user = process.env.DB_USER || 'super';
const info = {
env: {
DB_HOST: host,
DB_PORT: port,
DB_NAME: db,
DB_USER: user,
DB_PASS: process.env.DB_PASS ? '***' : 'NOT SET',
APP_ENV: process.env.APP_ENV || 'production',
},
dns: { host, ok: false, ips: [] },
connect: { ok: false, ms: 0, error: null },
query: { ok: false, ms: 0, error: null },
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
pg_version: null,
tables: [],
};
// DNS
try {
info.dns.ips = await dns.resolve4(host, { ttl: true });
info.dns.ok = info.dns.ips.length > 0;
} catch (e) {
info.dns.error = e.message;
}
// Connect + query
try {
const start = Date.now();
const r = await pool.query('SELECT version() AS v, current_timestamp AS ts');
info.connect = { ok: true, ms: Date.now() - start };
info.pg_version = r.rows[0].v;
info.server_time = r.rows[0].ts;
} catch (e) {
info.connect = { ok: false, ms: Date.now() - start, error: e.message };
}
// Tables
if (info.connect.ok) {
try {
const tables = await pool.query(`
SELECT table_name,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_name = t.table_name) AS cols
FROM information_schema.tables t
WHERE table_schema='public' AND table_type='BASE TABLE'
ORDER BY table_name
`);
for (const t of tables.rows) {
const cnt = await pool.query('SELECT COUNT(*)::int AS c FROM ' + t.table_name);
info.tables.push({ table: t.table_name, columns: t.cols, rows: cnt.rows[0].c });
}
} catch (e) {
info.tables = [{ error: e.message }];
}
}
// Pool stats
info.pool = { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount };
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderDbStatus(info));
});
// ── GET /v2/db/query — SQL-запросы к БД (только ADMIN_EMAIL) ─────
router.get('/db/query', resolveContext, async (req, res) => {
if (!req.canQueryDB) return res.status(403).send('Доступ запрещён — только ADMIN_EMAIL');
const sqlParam = req.query.sql || '';
const { pool } = require('./src/db');
let result = null;
let error = null;
let elapsed = 0;
if (sqlParam) {
try {
const start = Date.now();
const r = await pool.query('SELECT ' + sqlParam);
elapsed = Date.now() - start;
result = { rows: r.rows, rowCount: r.rowCount, fields: r.fields.map(f => f.name) };
} catch (e) {
error = e.message;
}
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderDbQuery(sqlParam, result, error, elapsed));
});
// ── GET /v2/iam-gateway-test — сравнение API (только админ) ──────── // ── GET /v2/iam-gateway-test — сравнение API (только админ) ────────
router.get('/iam-gateway-test', resolveContext, async (req, res) => { router.get('/iam-gateway-test', resolveContext, async (req, res) => {
if (!req.canAdmin) return res.status(403).send('Доступ запрещён'); if (!req.canAdmin) return res.status(403).send('Доступ запрещён');
@@ -272,4 +367,65 @@ function iamComparePage(results) {
return html; return html;
} }
function renderDbStatus(info) {
const ok = info.connect.ok;
return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>PG Status</title>'
+ '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px}'
+ 'h2{color:#4fc3f7}h3{color:#81c784;margin:15px 0 5px}'
+ '.ok{color:#81c784}.err{color:#ef5350;word-break:break-all}'
+ 'pre{background:#1a1a2e;padding:10px;border-radius:6px;overflow-x:auto;font-size:12px}'
+ 'table{border-collapse:collapse;margin:10px 0;font-size:12px}'
+ 'td,th{border:1px solid #333;padding:4px 10px;text-align:left}'
+ 'th{color:#888}.label{color:#888}</style></head><body>'
+ '<h2>⚡ PG Diagnostic <span class="' + (ok ? 'ok' : 'err') + '">' + (ok ? 'OK' : 'FAIL') + '</span></h2>'
// Status
+ '<h3>🔌 Connection</h3>'
+ '<p><span class="label">Host:</span> ' + info.env.DB_HOST + ':' + info.env.DB_PORT + '</p>'
+ '<p><span class="label">DB:</span> ' + info.env.DB_NAME + ' / ' + info.env.DB_USER + '</p>'
+ '<p><span class="label">APP_ENV:</span> ' + info.env.APP_ENV + '</p>'
+ '<p><span class="' + (ok ? 'ok' : 'err') + '">' + (ok ? '✅ Connected (' + info.connect.ms + 'ms)' : '❌ ' + info.connect.error) + '</span></p>'
// DNS
+ '<h3>🌐 DNS</h3>'
+ '<p>' + info.dns.ips.map(ip => typeof ip === 'object' ? ip.address : ip).join(', ') + (info.dns.error ? ' <span class="err">' + info.dns.error + '</span>' : '') + '</p>'
// Pool
+ '<h3>🏊 Pool</h3>'
+ '<p>total=' + info.pool.total + ' idle=' + info.pool.idle + ' waiting=' + info.pool.waiting + '</p>'
// PG Info
+ (info.pg_version ? '<h3>🐘 PostgreSQL</h3><p>' + info.pg_version + '</p><p>Server time: ' + info.server_time + '</p>' : '')
// Tables
+ '<h3>📊 Tables</h3>'
+ (info.tables.length > 0 && !info.tables[0].error
? '<table><tr><th>Table</th><th>Cols</th><th>Rows</th></tr>'
+ info.tables.map(t => '<tr><td>' + t.table + '</td><td>' + t.columns + '</td><td>' + t.rows + '</td></tr>').join('')
+ '</table>'
: '<p class="err">' + (info.tables[0] && info.tables[0].error || 'no data') + '</p>')
+ '</body></html>';
}
function renderDbQuery(sql, result, error, elapsed) {
return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>SQL Query</title>'
+ '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px}'
+ 'h2{color:#4fc3f7}input{width:600px;padding:10px;font-family:monospace;background:#1a1a2e;color:#eee;border:1px solid #333;border-radius:6px;font-size:14px}'
+ 'button{padding:10px 20px;background:#4fc3f7;color:#000;border:none;border-radius:6px;cursor:pointer;font-weight:bold;font-size:14px}'
+ '.err{color:#ef5350}.ok{color:#81c784}'
+ 'pre{background:#1a1a2e;padding:10px;border-radius:6px;overflow-x:auto;max-height:600px;font-size:12px}'
+ 'table{border-collapse:collapse;margin:10px 0;font-size:12px}td,th{border:1px solid #333;padding:4px 10px;text-align:left}th{background:#1a1a2e;color:#4fc3f7}'
+ '</style></head><body>'
+ '<h2>🔍 SQL Query <span style="font-size:14px;color:#888">(SELECT prefix)</span></h2>'
+ '<form method="get"><input name="sql" value="' + (sql || '').replace(/"/g,'&quot;') + '" placeholder="* FROM companies"><button>Execute</button></form>'
+ (error ? '<p class="err" style="margin-top:10px">❌ ' + error + '</p>' : '')
+ (result ? '<p style="margin-top:10px"><span class="ok">✅ ' + result.rowCount + ' rows</span> (' + elapsed + 'ms)</p>'
+ '<table><tr>' + result.fields.map(f => '<th>' + f + '</th>').join('') + '</tr>'
+ result.rows.map(r => '<tr>' + result.fields.map(f => '<td>' + (r[f] === null ? '<i>null</i>' : String(r[f])) + '</td>').join('') + '</tr>').join('')
+ '</table>' : '')
+ '<p style="margin-top:20px"><a href="/v2/db-status" style="color:#4fc3f7">📊 PG Diagnostic</a></p>'
+ '</body></html>';
}
module.exports = { createV2Router }; module.exports = { createV2Router };
+1 -1
View File
@@ -8,7 +8,7 @@
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
module.exports = { module.exports = {
version: '0.1.13', version: '0.1.25',
// ── Среда ───────────────────────────────────────────────────────────────── // ── Среда ─────────────────────────────────────────────────────────────────
// APP_ENV=test → тестовый стенд (жёлтый баннер в UI, можно включать тестовый API) // APP_ENV=test → тестовый стенд (жёлтый баннер в UI, можно включать тестовый API)
+2 -1
View File
@@ -19,7 +19,8 @@ const pool = new Pool({
password: process.env.DB_PASS || '', password: process.env.DB_PASS || '',
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false, ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
max: 10, max: 10,
idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, // 5 сек на подключение → не виснуть
idleTimeoutMillis: 10000, // 10 сек idle → закрыть
}); });
pool.on('error', (err) => { pool.on('error', (err) => {
+39 -76
View File
@@ -2,24 +2,29 @@
// V2 — инициализация схемы БД + миграции // V2 — инициализация схемы БД + миграции
// //
// ЭТО: CREATE TABLE IF NOT EXISTS (идемпотентно) + applyMigrations(). // ЭТО: CREATE TABLE IF NOT EXISTS (идемпотентно) + applyMigrations().
// ЗАЧЕМ: // ЗАЧЕМ: приложение само создаёт таблицы и применяет новые миграции при старте.
// 1. Приложение само создаёт таблицы при старте — не нужны ручные DDL.
// 2. Миграции — отдельные .sql файлы в sql/migrations/, выполняются по порядку.
// 3. Безопасно для production: IF NOT EXISTS + _migrations таблица.
// //
// ТАБЛИЦЫ: // МИГРАЦИИ: встроенные SQL-строки, не из файлов.
// companies — client_id (W-номер), name, custom_limit // Каждая в транзакции (BEGIN/COMMIT), ошибка логируется и пропускается.
// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил // Без fs, path, process.exit.
// audit_log — все действия: CREATE/UPDATE/DELETE/RESTORE, с impersonated_by
// _migrations — какие миграции уже выполнены
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
const fs = require('fs'); // ── Миграции (упорядочены, выполняются один раз) ──────────────────────────────
const path = require('path'); const MIGRATIONS = [
{
name: '001_add_impersonated_by',
sql: `ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS impersonated_by VARCHAR(255) DEFAULT NULL`,
},
{
name: '002_add_audit_restore',
sql: `ALTER TABLE audit_log DROP CONSTRAINT IF EXISTS audit_log_action_check;
ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN ('CREATE','UPDATE','DELETE','RESTORE'))`,
},
];
async function ensureSchema(pool) { async function ensureSchema(pool) {
// 1. Таблицы
await pool.query(` await pool.query(`
-- Компании: W-номер уникален, upsert через ON CONFLICT
CREATE TABLE IF NOT EXISTS companies ( CREATE TABLE IF NOT EXISTS companies (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
client_id VARCHAR(64) NOT NULL UNIQUE, client_id VARCHAR(64) NOT NULL UNIQUE,
@@ -29,7 +34,6 @@ async function ensureSchema(pool) {
updated_at TIMESTAMPTZ updated_at TIMESTAMPTZ
); );
-- Записи: CIDR + метаданные, soft delete
CREATE TABLE IF NOT EXISTS whitelist_entries ( CREATE TABLE IF NOT EXISTS whitelist_entries (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
company_id INTEGER NOT NULL, company_id INTEGER NOT NULL,
@@ -43,12 +47,10 @@ async function ensureSchema(pool) {
deleted_at TIMESTAMPTZ deleted_at TIMESTAMPTZ
); );
-- Защита от дублей: уникальный CIDR в компании среди активных записей
CREATE UNIQUE INDEX IF NOT EXISTS idx_whitelist_entries_active_cidr CREATE UNIQUE INDEX IF NOT EXISTS idx_whitelist_entries_active_cidr
ON whitelist_entries(company_id, value_cidr) ON whitelist_entries(company_id, value_cidr)
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;
-- Аудит: каждое действие с возможностью отследить имперсонацию
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_email VARCHAR(255) NOT NULL, user_email VARCHAR(255) NOT NULL,
@@ -67,79 +69,40 @@ async function ensureSchema(pool) {
-- Таблица учёта выполненных миграций -- Таблица учёта выполненных миграций
CREATE TABLE IF NOT EXISTS _migrations ( CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
`); `);
console.log('[db] Schema ensured'); console.log('[db] Schema ensured');
// Выполнить неприменённые миграции // 2. Миграции
await applyMigrations(pool); await applyMigrations(pool);
} }
/**
* Применить новые миграции из sql/migrations/.
* Читает все .sql файлы, сортирует по имени, выполняет те,
* которых ещё нет в таблице _migrations.
*/
async function applyMigrations(pool) { async function applyMigrations(pool) {
const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations'); try {
if (!fs.existsSync(dir)) { const done = new Set(
console.log('[db] No migrations dir, skipping'); (await pool.query('SELECT name FROM _migrations')).rows.map(r => r.name)
return;
}
const files = fs.readdirSync(dir)
.filter(f => f.endsWith('.sql'))
.sort();
if (files.length === 0) return;
// Какие уже выполнены
const done = new Set(
(await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename)
);
for (const file of files) {
if (done.has(file)) continue;
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
await pool.query(sql);
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]);
console.log('[db] Migration applied:', file);
}
}
async function initOldMigrations(pool) {
// Миграции, которые раньше были в ensureSchema() — теперь
// фиксируем их как уже выполненные, чтобы не потерять.
const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations');
if (!fs.existsSync(dir)) return;
const files = fs.readdirSync(dir)
.filter(f => f.endsWith('.sql'))
.sort();
if (files.length === 0) return;
// Создать таблицу если ещё нет (на случай первого запуска с миграциями)
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
`);
const done = new Set( for (const m of MIGRATIONS) {
(await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename) if (done.has(m.name)) continue;
); try {
await pool.query('BEGIN');
for (const file of files) { await pool.query(m.sql);
if (done.has(file)) continue; await pool.query('INSERT INTO _migrations (name) VALUES ($1)', [m.name]);
const sql = fs.readFileSync(path.join(dir, file), 'utf8'); await pool.query('COMMIT');
await pool.query(sql); console.log('[db] Migration applied:', m.name);
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]); } catch (e) {
console.log('[db] Migration applied:', file); await pool.query('ROLLBACK').catch(() => {});
console.error('[db] Migration failed:', m.name, e.message);
}
}
} catch (e) {
console.error('[db] applyMigrations error:', e.message);
} }
} }
module.exports = { ensureSchema, applyMigrations, initOldMigrations }; module.exports = { ensureSchema };
module.exports = { ensureSchema };
+1
View File
@@ -107,6 +107,7 @@ function applyContext(req, u) {
req.companyName = u.companyName || req.clientId; req.companyName = u.companyName || req.clientId;
const allCids = u.allClientIds || [u.clientId || '']; const allCids = u.allClientIds || [u.clientId || ''];
req.canAdmin = allCids.includes('WZ01112') || u.clientId === 'WZ01112' || !!u.isAdmin || u.email === (process.env.ADMIN_EMAIL || ''); req.canAdmin = allCids.includes('WZ01112') || u.clientId === 'WZ01112' || !!u.isAdmin || u.email === (process.env.ADMIN_EMAIL || '');
req.canQueryDB = u.email === (process.env.ADMIN_EMAIL || ''); // SQL-доступ только для ADMIN_EMAIL
req.adminMode = req.canAdmin; req.adminMode = req.canAdmin;
req.isAdmin = req.canAdmin; req.isAdmin = req.canAdmin;
req.isImpersonated = isImpersonated; req.isImpersonated = isImpersonated;