585 lines
22 KiB
TypeScript
585 lines
22 KiB
TypeScript
import { randomUUID } from "node:crypto"
|
|
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { and, desc, eq, isNotNull } from "drizzle-orm"
|
|
import type {
|
|
BackupScheduleSettingsDto,
|
|
BackupStorageSettingsDto,
|
|
PutBackupStorageSettings,
|
|
BackupLocalFileStatus,
|
|
} from "@mmapp/contracts/backups"
|
|
import { mapBackupEntry } from "./backup-dto.js"
|
|
import { pruneBackupRecords } from "./backup-prune.js"
|
|
import { assertRestorableBackupScript, importBackupOnce } from "./backup-restore.js"
|
|
import { backupDeletionPath, deleteStoredBackup } from "./backup-deletion.js"
|
|
import { withBackupLocalFileStates } from "./backup-local-file-state.js"
|
|
import { assertBackupStorageConnectionChange, planBackupStorageUpdate } from "./backup-storage-update.js"
|
|
import { BackupStorageConflictError, withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.js"
|
|
import { db } from "../db/index.js"
|
|
import { parseJsonArray } from "../db/json.js"
|
|
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
|
|
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
|
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
|
import { MikrotikClient } from "./mikrotik.js"
|
|
import {
|
|
buildS3ObjectKey,
|
|
createS3ClientFromConfig,
|
|
parseS3ObjectKey,
|
|
sanitizeServerName,
|
|
s3DeleteObject,
|
|
s3GetObject,
|
|
s3ListObjects,
|
|
s3PutObject,
|
|
s3TestConnection,
|
|
type S3BackupConfig,
|
|
} from "./s3-backup-client.js"
|
|
|
|
const SETTINGS_ID = 1
|
|
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
|
|
|
export type BackupMeta = {
|
|
id: string
|
|
serverId: number | null
|
|
serverName: string
|
|
filename: string
|
|
sizeBytes: number
|
|
createdAt: string
|
|
kind: "manual" | "auto"
|
|
notes?: string
|
|
storage: "local" | "s3" | "both"
|
|
s3Key?: string | null
|
|
uploadError?: string | null
|
|
localFileStatus?: BackupLocalFileStatus
|
|
}
|
|
|
|
type BackupRow = typeof backupEntries.$inferSelect
|
|
|
|
function rowToMeta(row: BackupRow): BackupMeta { return mapBackupEntry(row) }
|
|
|
|
export async function ensureBackupStorage(): Promise<void> {
|
|
await mkdir(BACKUPS_DIR, { recursive: true })
|
|
}
|
|
|
|
export async function listBackups(): Promise<BackupMeta[]> {
|
|
const rows = await db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt))
|
|
return withBackupLocalFileStates(rows.map(rowToMeta), BACKUPS_DIR)
|
|
}
|
|
|
|
export async function getBackupById(id: string): Promise<BackupMeta | null> {
|
|
const row = (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
|
|
return row ? (await withBackupLocalFileStates([rowToMeta(row)], BACKUPS_DIR))[0] : null
|
|
}
|
|
|
|
export async function insertBackup(meta: BackupMeta): Promise<void> {
|
|
return withBackupStorageOperation(() => insertBackupRecord(meta))
|
|
}
|
|
|
|
async function insertBackupRecord(meta: BackupMeta): Promise<void> {
|
|
await db.insert(backupEntries).values({
|
|
id: meta.id,
|
|
serverId: meta.serverId,
|
|
serverName: meta.serverName,
|
|
filename: meta.filename,
|
|
sizeBytes: meta.sizeBytes,
|
|
kind: meta.kind,
|
|
notes: meta.notes ?? null,
|
|
storage: meta.storage,
|
|
s3Key: meta.s3Key ?? null,
|
|
s3Etag: null,
|
|
uploadError: meta.uploadError ?? null,
|
|
createdAt: meta.createdAt,
|
|
})
|
|
}
|
|
|
|
async function getBackupRow(id: string): Promise<BackupRow | undefined> {
|
|
return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
|
|
}
|
|
|
|
export async function deleteBackupRecord(id: string, options: { recordOnly?: boolean } = {}): Promise<BackupMeta | null> {
|
|
return withBackupStorageOperation(() => deleteStoredBackupRecord(id, Boolean(options.recordOnly)))
|
|
}
|
|
|
|
async function deleteStoredBackupRecord(id: string, recordOnly: boolean): Promise<BackupMeta | null> {
|
|
const row = await getBackupRow(id)
|
|
if (!row) return null
|
|
const meta = rowToMeta(row)
|
|
if (recordOnly) {
|
|
const [current] = await withBackupLocalFileStates([meta], BACKUPS_DIR)
|
|
if (row.storage !== "local" || row.s3Key || current.localFileStatus !== "missing") {
|
|
throw new BackupStorageConflictError("Файл больше не подтверждён отсутствующим. Обновите историю перед удалением записи.")
|
|
}
|
|
// Never remove a file in this mode, even if an external writer recreates it after the check.
|
|
await db.delete(backupEntries).where(eq(backupEntries.id, id))
|
|
return meta
|
|
}
|
|
await deleteStoredBackup(row, BACKUPS_DIR, {
|
|
removeS3: async key => {
|
|
const cfg = await getS3ConfigIfEnabled()
|
|
if (!cfg) throw new Error("Очистка S3 не подтверждена: восстановите подключение к хранилищу резервной копии")
|
|
await s3DeleteObject(createS3ClientFromConfig(cfg), cfg.bucket, key)
|
|
},
|
|
removeLocal: file => rm(file, { force: true }),
|
|
removeRecord: async backupId => { await db.delete(backupEntries).where(eq(backupEntries.id, backupId)) },
|
|
})
|
|
return meta
|
|
}
|
|
|
|
function fmtTs(d = new Date()): string {
|
|
const p = (n: number) => String(n).padStart(2, "0")
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
|
|
}
|
|
|
|
function parseServerIds(raw: unknown): string[] {
|
|
return parseJsonArray(raw).map(String).filter(Boolean)
|
|
}
|
|
|
|
async function getBackupScheduleSettingsRow() {
|
|
return (await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]
|
|
?? {
|
|
id: SETTINGS_ID,
|
|
enabled: true,
|
|
frequency: "daily" as const,
|
|
hour: 3,
|
|
minute: 0,
|
|
weekDay: 0,
|
|
monthDay: 1,
|
|
keepCount: 7,
|
|
format: "rsc" as const,
|
|
serverIdsJson: [],
|
|
lastRunAt: null,
|
|
lastDurationMs: null,
|
|
lastError: null,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
export async function getBackupScheduleSettings(): Promise<BackupScheduleSettingsDto> {
|
|
const row = await getBackupScheduleSettingsRow()
|
|
return {
|
|
enabled: row.enabled,
|
|
frequency: row.frequency,
|
|
hour: row.hour,
|
|
minute: row.minute,
|
|
weekDay: row.weekDay,
|
|
monthDay: row.monthDay,
|
|
keepCount: row.keepCount,
|
|
format: row.format,
|
|
serverIds: parseServerIds(row.serverIdsJson),
|
|
lastRunAt: row.lastRunAt ?? null,
|
|
lastDurationMs: row.lastDurationMs ?? null,
|
|
lastError: row.lastError ?? null,
|
|
updatedAt: row.updatedAt,
|
|
}
|
|
}
|
|
|
|
export async function updateBackupScheduleSettings(patch: Partial<{
|
|
enabled: boolean
|
|
frequency: "daily" | "weekly" | "monthly"
|
|
hour: number
|
|
minute: number
|
|
weekDay: number
|
|
monthDay: number
|
|
keepCount: number
|
|
format: "rsc" | "backup"
|
|
serverIds: string[]
|
|
}>) {
|
|
const prev = await getBackupScheduleSettingsRow()
|
|
const now = new Date().toISOString()
|
|
const next = {
|
|
enabled: patch.enabled ?? prev.enabled,
|
|
frequency: patch.frequency ?? prev.frequency,
|
|
hour: patch.hour ?? prev.hour,
|
|
minute: patch.minute ?? prev.minute,
|
|
weekDay: patch.weekDay ?? prev.weekDay,
|
|
monthDay: patch.monthDay ?? prev.monthDay,
|
|
keepCount: patch.keepCount ?? prev.keepCount,
|
|
format: patch.format ?? prev.format,
|
|
serverIdsJson: patch.serverIds ?? prev.serverIdsJson,
|
|
updatedAt: now,
|
|
}
|
|
if ((await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]) {
|
|
await db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID))
|
|
} else {
|
|
await db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next })
|
|
}
|
|
return await getBackupScheduleSettings()
|
|
}
|
|
|
|
export async function touchBackupScheduleRunMeta(patch: {
|
|
lastRunAt?: string
|
|
lastDurationMs?: number
|
|
lastError?: string | null
|
|
}) {
|
|
const prev = await getBackupScheduleSettingsRow()
|
|
await db.update(backupScheduleSettings).set({
|
|
lastRunAt: patch.lastRunAt ?? prev.lastRunAt,
|
|
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
|
|
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
|
|
updatedAt: new Date().toISOString(),
|
|
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
|
|
}
|
|
|
|
function defaultStorageRow() {
|
|
return {
|
|
id: SETTINGS_ID,
|
|
provider: "local" as const,
|
|
s3Endpoint: "",
|
|
s3Region: "us-east-1",
|
|
s3Bucket: "",
|
|
s3Prefix: "mikrotik",
|
|
s3AccessKeyId: "",
|
|
s3SecretAccessKey: "",
|
|
s3ForcePathStyle: true,
|
|
keepLocalCopy: true,
|
|
lastTestAt: null as string | null,
|
|
lastTestError: null as string | null,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
async function getBackupStorageSettingsRow() {
|
|
return (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
|
|
?? defaultStorageRow()
|
|
}
|
|
|
|
function toStorageDto(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): BackupStorageSettingsDto {
|
|
return {
|
|
provider: row.provider,
|
|
s3Endpoint: row.s3Endpoint,
|
|
s3Region: row.s3Region,
|
|
s3Bucket: row.s3Bucket,
|
|
s3Prefix: row.s3Prefix,
|
|
s3AccessKeyId: row.s3AccessKeyId,
|
|
secretConfigured: Boolean(row.s3SecretAccessKey),
|
|
s3ForcePathStyle: row.s3ForcePathStyle,
|
|
keepLocalCopy: row.keepLocalCopy,
|
|
lastTestAt: row.lastTestAt ?? null,
|
|
lastTestError: row.lastTestError ?? null,
|
|
updatedAt: row.updatedAt,
|
|
}
|
|
}
|
|
|
|
export async function getBackupStorageSettings(): Promise<BackupStorageSettingsDto> {
|
|
return toStorageDto(await getBackupStorageSettingsRow())
|
|
}
|
|
|
|
export async function updateBackupStorageSettings(
|
|
patch: PutBackupStorageSettings,
|
|
): Promise<BackupStorageSettingsDto> {
|
|
return withBackupStorageSettingsUpdate(() => updateStorageSettings(patch))
|
|
}
|
|
|
|
async function updateStorageSettings(patch: PutBackupStorageSettings): Promise<BackupStorageSettingsDto> {
|
|
const prev = await getBackupStorageSettingsRow()
|
|
const now = new Date().toISOString()
|
|
const next = planBackupStorageUpdate(prev, patch, now)
|
|
const remote = await db.select({ id: backupEntries.id }).from(backupEntries).where(isNotNull(backupEntries.s3Key)).limit(1)
|
|
assertBackupStorageConnectionChange(prev, next, remote.length > 0)
|
|
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
|
|
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
|
|
} else {
|
|
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...next })
|
|
}
|
|
return await getBackupStorageSettings()
|
|
}
|
|
|
|
function rowToS3Config(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): S3BackupConfig | null {
|
|
if (row.provider !== "s3") return null
|
|
if (!row.s3Bucket.trim() || !row.s3AccessKeyId.trim() || !row.s3SecretAccessKey) return null
|
|
return {
|
|
endpoint: row.s3Endpoint,
|
|
region: row.s3Region,
|
|
bucket: row.s3Bucket.trim(),
|
|
prefix: row.s3Prefix,
|
|
accessKeyId: row.s3AccessKeyId,
|
|
secretAccessKey: row.s3SecretAccessKey,
|
|
forcePathStyle: row.s3ForcePathStyle,
|
|
}
|
|
}
|
|
|
|
async function getS3ConfigIfEnabled(): Promise<S3BackupConfig | null> {
|
|
return rowToS3Config(await getBackupStorageSettingsRow())
|
|
}
|
|
|
|
export async function testBackupStorageConnection(): Promise<BackupStorageSettingsDto> {
|
|
const row = await getBackupStorageSettingsRow()
|
|
const cfg = rowToS3Config(row)
|
|
const now = new Date().toISOString()
|
|
if (!cfg) {
|
|
const error = row.provider === "s3"
|
|
? "Заполните bucket, ключ доступа и секрет"
|
|
: "S3 не выбран"
|
|
await persistStorageTest(now, error, row.updatedAt)
|
|
throw new Error(error)
|
|
}
|
|
let error: string | null = null
|
|
try { await s3TestConnection(createS3ClientFromConfig(cfg), cfg.bucket) }
|
|
catch (reason) { error = reason instanceof Error ? reason.message : String(reason) }
|
|
await persistStorageTest(now, error, row.updatedAt)
|
|
if (error) throw new Error(error)
|
|
const confirmed = await getBackupStorageSettings()
|
|
if (!confirmed.updatedAt || Date.parse(confirmed.updatedAt) !== Date.parse(now)) throw new Error("Настройки изменились во время проверки S3; повторите проверку")
|
|
return confirmed
|
|
}
|
|
|
|
async function persistStorageTest(at: string, error: string | null, expectedUpdatedAt: string) {
|
|
const exists = (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
|
|
const patch = { lastTestAt: at, lastTestError: error, updatedAt: at }
|
|
if (exists) {
|
|
const rows = await db.update(backupStorageSettings).set(patch).where(and(eq(backupStorageSettings.id, SETTINGS_ID), eq(backupStorageSettings.updatedAt, expectedUpdatedAt))).returning({ id: backupStorageSettings.id })
|
|
if (!rows.length) throw new Error("Настройки изменились во время проверки S3; повторите проверку")
|
|
} else {
|
|
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...patch })
|
|
}
|
|
}
|
|
|
|
export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise<string[]> {
|
|
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
|
|
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
|
|
return [...new Set(requested)].filter((id) => enabled.has(id))
|
|
}
|
|
|
|
function scheduledSlotForDate(now: Date, settings: BackupScheduleSettingsDto): Date | null {
|
|
if (settings.frequency === "weekly") {
|
|
const currentDow = (now.getDay() + 6) % 7
|
|
if (currentDow !== settings.weekDay) return null
|
|
} else if (settings.frequency === "monthly") {
|
|
if (now.getDate() !== settings.monthDay) return null
|
|
}
|
|
|
|
const slot = new Date(now)
|
|
slot.setSeconds(0, 0)
|
|
slot.setMilliseconds(0)
|
|
slot.setHours(settings.hour, settings.minute, 0, 0)
|
|
return slot
|
|
}
|
|
|
|
function sameLocalMinute(a: Date, b: Date): boolean {
|
|
return a.getFullYear() === b.getFullYear()
|
|
&& a.getMonth() === b.getMonth()
|
|
&& a.getDate() === b.getDate()
|
|
&& a.getHours() === b.getHours()
|
|
&& a.getMinutes() === b.getMinutes()
|
|
}
|
|
|
|
/** Срабатывает только в минуту расписания; 60 с в UI — интервал проверки, не частота бэкапа. */
|
|
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
|
|
if (!settings.enabled) return false
|
|
|
|
const slot = scheduledSlotForDate(now, settings)
|
|
if (!slot) return false
|
|
if (now < slot) return false
|
|
if (!sameLocalMinute(now, slot)) return false
|
|
|
|
if (lastRunAt) {
|
|
const prev = new Date(lastRunAt)
|
|
if (Number.isNaN(prev.getTime())) return true
|
|
if (sameLocalMinute(prev, slot)) return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
async function uploadBackupToS3(params: {
|
|
serverName: string
|
|
filename: string
|
|
body: string
|
|
}): Promise<{ key: string; etag?: string } | { error: string }> {
|
|
const cfg = await getS3ConfigIfEnabled()
|
|
if (!cfg) return { error: "S3 не настроен" }
|
|
try {
|
|
const client = createS3ClientFromConfig(cfg)
|
|
const key = buildS3ObjectKey(cfg.prefix, sanitizeServerName(params.serverName), params.filename)
|
|
const put = await s3PutObject(client, cfg.bucket, key, params.body)
|
|
return { key, etag: put.etag }
|
|
} catch (err) {
|
|
return { error: err instanceof Error ? err.message : String(err) }
|
|
}
|
|
}
|
|
|
|
export async function runBackupForServer(
|
|
id: string,
|
|
kind: BackupMeta["kind"],
|
|
notes?: string,
|
|
): Promise<BackupMeta> {
|
|
return withBackupStorageOperation(() => createBackupForServer(id, kind, notes))
|
|
}
|
|
|
|
async function createBackupForServer(id: string, kind: BackupMeta["kind"], notes?: string): Promise<BackupMeta> {
|
|
const serverIdNum = Number.parseInt(id, 10)
|
|
if (!Number.isFinite(serverIdNum)) {
|
|
throw new Error("Невалидный id сервера")
|
|
}
|
|
const row = await getServerRowById(serverIdNum)
|
|
if (!row) {
|
|
throw new Error("Сервер не найден")
|
|
}
|
|
const client = MikrotikClient.fromServer(row)
|
|
const script = await client.exportConfigScript()
|
|
const ts = fmtTs()
|
|
const safeServer = sanitizeServerName(row.name)
|
|
const filename = `${safeServer}_${ts}.rsc`
|
|
const filePath = path.join(BACKUPS_DIR, filename)
|
|
await ensureBackupStorage()
|
|
await writeFile(filePath, script, "utf8")
|
|
const st = await stat(filePath)
|
|
const storageRow = await getBackupStorageSettingsRow()
|
|
let storage: BackupMeta["storage"] = "local"
|
|
let s3Key: string | null = null
|
|
let s3Etag: string | null = null
|
|
let uploadError: string | null = null
|
|
|
|
if (storageRow.provider === "s3") {
|
|
const uploaded = await uploadBackupToS3({ serverName: row.name, filename, body: script })
|
|
if ("key" in uploaded) {
|
|
s3Key = uploaded.key
|
|
s3Etag = uploaded.etag ?? null
|
|
storage = storageRow.keepLocalCopy ? "both" : "s3"
|
|
if (!storageRow.keepLocalCopy) {
|
|
await rm(filePath, { force: true })
|
|
}
|
|
} else {
|
|
uploadError = uploaded.error
|
|
storage = "local"
|
|
}
|
|
}
|
|
|
|
const meta: BackupMeta = {
|
|
id: randomUUID(),
|
|
serverId: row.id,
|
|
serverName: row.name,
|
|
filename,
|
|
sizeBytes: st.size,
|
|
createdAt: new Date().toISOString(),
|
|
kind,
|
|
notes,
|
|
storage,
|
|
s3Key,
|
|
uploadError,
|
|
}
|
|
await db.insert(backupEntries).values({
|
|
id: meta.id,
|
|
serverId: meta.serverId,
|
|
serverName: meta.serverName,
|
|
filename: meta.filename,
|
|
sizeBytes: meta.sizeBytes,
|
|
kind: meta.kind,
|
|
notes: meta.notes ?? null,
|
|
storage: meta.storage,
|
|
s3Key: meta.s3Key ?? null,
|
|
s3Etag,
|
|
uploadError: meta.uploadError ?? null,
|
|
createdAt: meta.createdAt,
|
|
})
|
|
return meta
|
|
}
|
|
|
|
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
|
|
const rows = await db.select().from(backupEntries)
|
|
.where(eq(backupEntries.serverId, Number(serverId)))
|
|
.orderBy(desc(backupEntries.createdAt))
|
|
if (rows.length <= keepCount) return 0
|
|
const toDelete = rows.slice(keepCount)
|
|
return pruneBackupRecords(toDelete.map(row => row.id), deleteBackupRecord)
|
|
}
|
|
|
|
export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
|
|
return withBackupStorageOperation(async () => {
|
|
// The route may have read metadata before a concurrent deletion changed the connection.
|
|
const current = await getBackupById(meta.id)
|
|
if (!current) throw new Error("Бэкап не найден")
|
|
return readStoredBackupContent(current)
|
|
})
|
|
}
|
|
|
|
async function readStoredBackupContent(meta: BackupMeta): Promise<Buffer> {
|
|
let remoteUnavailable = false
|
|
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
|
|
try {
|
|
const cfg = await getS3ConfigIfEnabled()
|
|
if (cfg) {
|
|
const client = createS3ClientFromConfig(cfg)
|
|
return await s3GetObject(client, cfg.bucket, meta.s3Key)
|
|
}
|
|
remoteUnavailable = true
|
|
} catch {
|
|
remoteUnavailable = true
|
|
/* fallback: локальная копия, если есть */
|
|
}
|
|
}
|
|
try {
|
|
return await readFile(backupDeletionPath(BACKUPS_DIR, meta.filename))
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code
|
|
if (code === "ENOENT" || code === "ENOTDIR") throw new Error(remoteUnavailable
|
|
? "Файл недоступен в S3, локальная копия отсутствует. Проверьте подключение к хранилищу."
|
|
: "Локальный файл резервной копии отсутствует. Создайте новую копию или восстановите файл в хранилище.")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function restoreBackupToDevice(id: string): Promise<{ backupId: string; serverId: number; filename: string; serverName: string }> {
|
|
const meta = await getBackupById(id)
|
|
if (!meta) throw new Error("Бэкап не найден")
|
|
if (!meta.serverId) throw new Error("Сервер бэкапа удалён — восстановить нельзя")
|
|
const row = await getServerRowById(meta.serverId)
|
|
if (!row) throw new Error("Сервер не найден")
|
|
const content = await readBackupContent(meta)
|
|
assertRestorableBackupScript(content.toString("utf8"))
|
|
const client = MikrotikClient.fromServer(row)
|
|
const uploaded = await client.uploadTextFile(meta.filename, content.toString("utf8"), 60_000)
|
|
await importBackupOnce(uploaded, file => client.importUploadedFile(file))
|
|
return { backupId: meta.id, serverId: row.id, filename: meta.filename, serverName: row.name }
|
|
}
|
|
|
|
export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
|
|
return withBackupStorageOperation(syncStoredBackupsFromS3)
|
|
}
|
|
|
|
async function syncStoredBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
|
|
const cfg = await getS3ConfigIfEnabled()
|
|
if (!cfg) throw new Error("S3 не настроен")
|
|
const client = createS3ClientFromConfig(cfg)
|
|
const objects = await s3ListObjects(client, cfg.bucket, cfg.prefix)
|
|
const existing = new Set(
|
|
(await db.select({ filename: backupEntries.filename, s3Key: backupEntries.s3Key }).from(backupEntries))
|
|
.flatMap((row) => [row.filename, row.s3Key].filter((v): v is string => Boolean(v))),
|
|
)
|
|
let imported = 0
|
|
let skipped = 0
|
|
for (const obj of objects) {
|
|
if (!obj.key.toLowerCase().endsWith(".rsc")) {
|
|
skipped += 1
|
|
continue
|
|
}
|
|
const parsed = parseS3ObjectKey(obj.key)
|
|
if (existing.has(obj.key) || existing.has(parsed.filename)) {
|
|
skipped += 1
|
|
continue
|
|
}
|
|
const createdAt = obj.lastModified ?? new Date().toISOString()
|
|
await db.insert(backupEntries).values({
|
|
id: randomUUID(),
|
|
serverId: null,
|
|
serverName: parsed.serverName,
|
|
filename: parsed.filename,
|
|
sizeBytes: obj.size,
|
|
kind: "auto",
|
|
notes: "Импорт из S3",
|
|
storage: "s3",
|
|
s3Key: obj.key,
|
|
s3Etag: null,
|
|
uploadError: null,
|
|
createdAt,
|
|
})
|
|
existing.add(obj.key)
|
|
existing.add(parsed.filename)
|
|
imported += 1
|
|
}
|
|
return { imported, skipped }
|
|
}
|
|
|
|
export function getBackupsDir(): string {
|
|
return BACKUPS_DIR
|
|
}
|