feat(db): онлайн-бэкап SQLite, CLI backup, ротация и восстановление
This commit is contained in:
1 parent
8298454c63
commit
8cba7a5c41
9 files changed
+345
-2
No files matched your search
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: '[email protected]',
|
||||
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()
|
||||
})
|
||||
})
|
||||
+12
-2
@@ -211,14 +211,24 @@ Portal — IdP; Technitium — RP. После `docker compose up`:
|
||||
## Бэкап
|
||||
|
||||
```bash
|
||||
# SQLite
|
||||
cp /opt/auth-portal/data/app.db /opt/auth-portal/data/app.db.bak-$(date +%F)
|
||||
# SQLite: согласованная онлайн-копия (WAL учитывается, сервис не останавливается),
|
||||
# проверка integrity_check и ротация (BACKUP_KEEP, по умолчанию 7) → /data/backups/
|
||||
docker exec auth-portal node dist/server.js backup
|
||||
|
||||
# Либо встроенный планировщик: BACKUP_INTERVAL_HOURS=24 в .env (0 = выключен).
|
||||
# Каталог: BACKUP_DIR (по умолчанию <каталог БД>/backups). Не копируйте app.db
|
||||
# через `cp` на живой БД — без -wal/-shm копия может быть неполной.
|
||||
|
||||
# ACME
|
||||
docker run --rm -v auth_portal_traefik_letsencrypt:/data -v "$PWD:/backup" alpine \
|
||||
tar czf /backup/traefik-acme-$(date +%F).tgz -C /data .
|
||||
```
|
||||
|
||||
**Восстановление:** `docker compose stop app`, затем в `./data` удалить
|
||||
`app.db-wal`/`app.db-shm`, скопировать нужный `backups/app-*.db` как `app.db`,
|
||||
`chown 1000:1000`, `docker compose up -d`. OIDC-ключ подписи хранится в этой же
|
||||
БД, если не задан `OIDC_RSA_PRIVATE_KEY` — храните ключ отдельным секретом.
|
||||
|
||||
Откат образа: в `.env` `AUTH_IMAGE_TAG=vX.Y.Z` → `docker compose pull && docker compose up -d`.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import type { Sqlite } from './index.js'
|
||||
|
||||
const BACKUP_RE = /^app-\d{8}-\d{6}-\d{3}\.db$/
|
||||
|
||||
function stamp(d: Date): string {
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||||
return (
|
||||
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
|
||||
`-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}` +
|
||||
`-${p(d.getUTCMilliseconds(), 3)}`
|
||||
)
|
||||
}
|
||||
|
||||
/** PRAGMA integrity_check для файла БД; true, если результат 'ok'. */
|
||||
export function checkIntegrity(path: string): boolean {
|
||||
const db = new Database(path, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
return db.pragma('integrity_check', { simple: true }) === 'ok'
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function listBackups(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter((f) => BACKUP_RE.test(f))
|
||||
.sort()
|
||||
.reverse()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Согласованный онлайн-бэкап (better-sqlite3 backup API работает с WAL без
|
||||
* остановки сервиса), проверка integrity_check, ротация по числу копий.
|
||||
*/
|
||||
export async function backupDatabase(
|
||||
sqlite: Sqlite,
|
||||
opts: { dir: string; keep: number; now?: Date },
|
||||
): Promise<{ file: string; removed: string[] }> {
|
||||
mkdirSync(opts.dir, { recursive: true })
|
||||
const name = `app-${stamp(opts.now ?? new Date())}.db`
|
||||
const finalPath = join(opts.dir, name)
|
||||
const partial = `${finalPath}.partial`
|
||||
try {
|
||||
await sqlite.backup(partial)
|
||||
if (!checkIntegrity(partial)) {
|
||||
throw new Error('backup integrity_check failed')
|
||||
}
|
||||
renameSync(partial, finalPath)
|
||||
} catch (err) {
|
||||
rmSync(partial, { force: true })
|
||||
throw err
|
||||
}
|
||||
const removed: string[] = []
|
||||
for (const old of listBackups(opts.dir).slice(Math.max(1, opts.keep))) {
|
||||
rmSync(join(opts.dir, old), { force: true })
|
||||
removed.push(old)
|
||||
}
|
||||
return { file: finalPath, removed }
|
||||
}
|
||||
@@ -270,6 +270,7 @@ export function healthCheck(sqlite: Sqlite): void {
|
||||
|
||||
export * from './schema/index.js'
|
||||
export * from './tx.js'
|
||||
export * from './backup.js'
|
||||
export * from './generation.js'
|
||||
export * from './housekeeping.js'
|
||||
export * from './users.js'
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
backupDatabase,
|
||||
checkIntegrity,
|
||||
closeDb,
|
||||
createDb,
|
||||
createUser,
|
||||
getUserByEmail,
|
||||
listBackups,
|
||||
migrateSchema,
|
||||
} from '../src/index.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
const dbs: Array<ReturnType<typeof createDb>['sqlite']> = []
|
||||
afterEach(() => {
|
||||
for (const s of dbs.splice(0)) closeDb(s)
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fileDb() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-bk-'))
|
||||
dirs.push(dir)
|
||||
const r = createDb(join(dir, 'app.db'))
|
||||
migrateSchema(r.sqlite)
|
||||
dbs.push(r.sqlite)
|
||||
return { dir, ...r }
|
||||
}
|
||||
|
||||
describe('backupDatabase', () => {
|
||||
it('создаёт консистентную копию с данными из WAL', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
createUser(db, { email: '[email protected]', name: 'A', passwordHash: 'h', isAdmin: false })
|
||||
const res = await backupDatabase(sqlite, { dir: join(dir, 'backups'), keep: 3 })
|
||||
expect(existsSync(res.file)).toBe(true)
|
||||
expect(checkIntegrity(res.file)).toBe(true)
|
||||
|
||||
const copy = new Database(res.file, { readonly: true })
|
||||
const row = copy.prepare('SELECT email FROM users').get()
|
||||
copy.close()
|
||||
expect(row).toEqual({ email: '[email protected]' })
|
||||
})
|
||||
|
||||
it('бэкап во время записи остаётся валидным', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
const writer = setInterval(() => {
|
||||
try {
|
||||
createUser(db, {
|
||||
email: `u${Math.random()}@x.io`,
|
||||
name: 'U',
|
||||
passwordHash: 'h',
|
||||
isAdmin: false,
|
||||
})
|
||||
} catch {
|
||||
/* БД могла закрыться */
|
||||
}
|
||||
}, 1)
|
||||
const res = await backupDatabase(sqlite, { dir, keep: 3 })
|
||||
clearInterval(writer)
|
||||
expect(checkIntegrity(res.file)).toBe(true)
|
||||
})
|
||||
|
||||
it('ротация оставляет keep последних', async () => {
|
||||
const { dir, sqlite } = fileDb()
|
||||
const bdir = join(dir, 'b')
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await backupDatabase(sqlite, {
|
||||
dir: bdir,
|
||||
keep: 2,
|
||||
now: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
|
||||
})
|
||||
}
|
||||
const left = listBackups(bdir)
|
||||
expect(left).toHaveLength(2)
|
||||
expect(left[0]).toContain('000004')
|
||||
})
|
||||
|
||||
it('восстановленная копия открывается и содержит данные', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
createUser(db, { email: '[email protected]', name: 'R', passwordHash: 'h', isAdmin: false })
|
||||
const res = await backupDatabase(sqlite, { dir, keep: 1 })
|
||||
const restored = createDb(res.file)
|
||||
dbs.push(restored.sqlite)
|
||||
migrateSchema(restored.sqlite)
|
||||
expect(getUserByEmail(restored.db, '[email protected]')?.name).toBe('R')
|
||||
})
|
||||
|
||||
it('битый файл не проходит integrity_check', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-bad-'))
|
||||
dirs.push(dir)
|
||||
const p = join(dir, 'bad.db')
|
||||
writeFileSync(p, 'not a sqlite file'.repeat(100))
|
||||
expect(() => checkIntegrity(p)).toThrow()
|
||||
})
|
||||
|
||||
it('ошибка бэкапа не оставляет .partial', async () => {
|
||||
const { dir, sqlite } = fileDb()
|
||||
closeDb(sqlite)
|
||||
await expect(backupDatabase(sqlite, { dir: join(dir, 'x'), keep: 1 })).rejects.toThrow()
|
||||
expect(listBackups(join(dir, 'x'))).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user