feat(db): онлайн-бэкап SQLite, CLI backup, ротация и восстановление

This commit is contained in:
Denozordec committed 2026-10-23 17:02:50 +07:00
1 parent 8298454c63
commit 8cba7a5c41
9 files changed
+345 -2

No files matched your search

+9
View File
@@ -35,6 +35,7 @@ import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { startHousekeepingJob } from './services/housekeeping.js'
import { startBackupJob } from './services/backup.js'
import {
ensureOidcSigningKey,
isOidcKeyReady,
@@ -196,10 +197,18 @@ export async function buildApp(opts: {
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
const stopHousekeeping = startHousekeepingJob(app)
const stopBackup = startBackupJob(app)
app.addHook('onClose', async () => {
stopRetention()
stopHousekeeping()
stopBackup()
})
if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) {
app.log.warn(
'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' +
'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron',
)
}
}
if (config.staticDir && existsSync(config.staticDir)) {
+9
View File
@@ -33,6 +33,12 @@ export const configSchema = z.object({
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
/** Каталог бэкапов SQLite; по умолчанию <каталог БД>/backups. */
backupDir: z.string().optional(),
/** Сколько последних копий хранить. */
backupKeep: z.coerce.number().int().positive().default(7),
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
backupIntervalHours: z.coerce.number().min(0).default(0),
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
@@ -79,6 +85,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
backupDir: env.BACKUP_DIR || undefined,
backupKeep: env.BACKUP_KEEP ?? 7,
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
+18
View File
@@ -30,6 +30,24 @@ for (const path of [
}
const config = loadConfig()
// `node dist/server.js backup` — разовый онлайн-бэкап без запуска сервера
// (для cron / `docker exec auth-portal node dist/server.js backup`).
if (process.argv[2] === 'backup') {
const { createDb, closeDb } = await import('@authportal/db')
const { runBackup } = await import('./services/backup.js')
const { sqlite } = createDb(config.databaseUrl)
try {
const res = await runBackup(sqlite, config)
console.log(`backup ok: ${res.file} (removed ${res.removed.length} old)`)
closeDb(sqlite)
process.exit(0)
} catch (err) {
console.error('backup failed:', err)
process.exit(1)
}
}
const app = await buildApp({ config })
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
+43
View File
@@ -0,0 +1,43 @@
import { dirname, join, resolve } from 'node:path'
import type { FastifyInstance } from 'fastify'
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
import type { AppConfig } from '../config.js'
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
if (config.backupDir) return resolve(config.backupDir)
const path = resolveDatabasePath(databaseUrl)
return join(dirname(resolve(path)), 'backups')
}
export async function runBackup(
sqlite: Sqlite,
config: AppConfig,
databaseUrl = config.databaseUrl,
) {
return backupDatabase(sqlite, {
dir: backupDirFor(config, databaseUrl),
keep: config.backupKeep,
})
}
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
export function startBackupJob(app: FastifyInstance): () => void {
const hours = app.config.backupIntervalHours
if (hours <= 0 || app.sqlite.memory) return () => {}
let running = false
const run = async () => {
if (running) return
running = true
try {
const res = await runBackup(app.sqlite, app.config)
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
} catch (err) {
app.log.error({ err }, 'db backup failed')
} finally {
running = false
}
}
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
interval.unref()
return () => clearInterval(interval)
}
+82
View File
@@ -0,0 +1,82 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listBackups } from '@authportal/db'
import { loadConfig } from '../src/config.js'
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
import { buildApp } from '../src/app.js'
import { adminToken } from './helpers.js'
const dirs: string[] = []
afterEach(() => {
vi.useRealTimers()
for (const d of dirs.splice(0)) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* Windows может держать файл БД до GC — временный каталог не критичен */
}
}
})
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
return loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: 'admin@test.local',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
NODE_ENV: 'test',
...extra,
})
}
describe('backup', () => {
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
const cfg = env('/data/x')
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
})
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_KEEP: '2' })
const app = await buildApp({ config })
const res = await runBackup(app.sqlite, config)
expect(listBackups(backupDirFor(config))).toHaveLength(1)
await app.close()
const restored = await buildApp({
config: env(dir),
databaseUrl: `sqlite:${res.file}`,
})
const token = await adminToken(restored as never)
expect(token).toBeTruthy()
await restored.close()
})
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
const app = await buildApp({ config })
const base = vi.getTimerCount()
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
expect(vi.getTimerCount()).toBe(base)
off()
const stop = startBackupJob(app)
expect(vi.getTimerCount()).toBe(base + 1)
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
vi.useRealTimers()
await vi.waitFor(() =>
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
)
stop()
await app.close()
})
})