Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump. Co-authored-by: Cursor <[email protected]>
102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
import { randomUUID } from "node:crypto"
|
|
import { spawn } from "node:child_process"
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { env } from "../config.js"
|
|
import { withAdvisoryLock } from "../db/index.js"
|
|
import { refreshScheduler, stopScheduler } from "./scheduler.js"
|
|
import {
|
|
startTrafficFlowListener,
|
|
stopTrafficFlowListener,
|
|
} from "./traffic-flow-ingest.js"
|
|
|
|
const PG_DUMP_MAGIC = Buffer.from("PGDMP")
|
|
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
|
const BACKUP_LOCK_KEY = 8_723_102
|
|
|
|
function fmtTimestamp(date = new Date()): string {
|
|
const pad = (n: number) => String(n).padStart(2, "0")
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
|
|
}
|
|
|
|
function assertPgDumpFile(buffer: Buffer): void {
|
|
if (buffer.length < PG_DUMP_MAGIC.length) {
|
|
throw new Error("Файл слишком маленький для дампа PostgreSQL")
|
|
}
|
|
if (!buffer.subarray(0, PG_DUMP_MAGIC.length).equals(PG_DUMP_MAGIC)) {
|
|
throw new Error("Файл не похож на резервную копию PostgreSQL (ожидается формат custom, PGDMP)")
|
|
}
|
|
}
|
|
|
|
function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer }): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
env: { ...process.env, DATABASE_URL: env.DATABASE_URL },
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
})
|
|
const chunks: Buffer[] = []
|
|
let err = ""
|
|
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk))
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
err += chunk.toString("utf8")
|
|
})
|
|
child.on("error", reject)
|
|
child.on("close", (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(err.trim() || `${command} завершился с кодом ${code}`))
|
|
return
|
|
}
|
|
resolve(Buffer.concat(chunks))
|
|
})
|
|
if (opts?.stdin) child.stdin.end(opts.stdin)
|
|
else child.stdin.end()
|
|
})
|
|
}
|
|
|
|
async function withDatabaseOperation<T>(fn: () => Promise<T>): Promise<T> {
|
|
return await withAdvisoryLock(BACKUP_LOCK_KEY, async () => {
|
|
stopTrafficFlowListener()
|
|
stopScheduler()
|
|
try {
|
|
return await fn()
|
|
} finally {
|
|
await startTrafficFlowListener()
|
|
await refreshScheduler()
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer }> {
|
|
return await withDatabaseOperation(async () => {
|
|
const buffer = await spawnCollected("pg_dump", ["-Fc", env.DATABASE_URL])
|
|
return {
|
|
filename: `mikrotik-manager_${fmtTimestamp()}.dump`,
|
|
buffer,
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<void> {
|
|
if (buffer.length > MAX_RESTORE_BYTES) {
|
|
throw new Error("Файл бэкапа слишком большой")
|
|
}
|
|
assertPgDumpFile(buffer)
|
|
|
|
await withDatabaseOperation(async () => {
|
|
const tempDir = path.join(os.tmpdir(), "mmapp-db-restore")
|
|
await mkdir(tempDir, { recursive: true })
|
|
const tempPath = path.join(tempDir, `restore-${randomUUID()}.dump`)
|
|
try {
|
|
await writeFile(tempPath, buffer)
|
|
await spawnCollected("pg_restore", ["--clean", "--if-exists", "-d", env.DATABASE_URL, tempPath])
|
|
} finally {
|
|
await rm(tempPath, { force: true })
|
|
}
|
|
})
|
|
}
|
|
|
|
export function getSystemDatabasePath(): string {
|
|
return env.DATABASE_URL
|
|
}
|