fix(backups): сохранять запись при неполной очистке файлов
This commit is contained in:
1 parent
4c5604bf87
commit
af86d2c3cd
10 files changed
+88
-19
No files matched your search
@@ -24,7 +24,7 @@
|
||||
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts",
|
||||
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
|
||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||
"test:backups": "tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
|
||||
"test:backups": "tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
|
||||
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
|
||||
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates",
|
||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
|
||||
|
||||
@@ -5,8 +5,9 @@ import { backupJobResponseSchema } from "@mmapp/contracts/backups"
|
||||
import { createBackupsRoutes } from "./backups.js"
|
||||
import { permissionForRequest } from "../lib/permissions.js"
|
||||
const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
||||
let deleteOutcome = "success", deleted = 0
|
||||
let created = 0, restored = 0, eventCalls = 0, restoreFail = false
|
||||
await app.register(createBackupsRoutes({ listServers: async () => [{ id: 1 }, { id: 2 }], create: async id => { created++; if (id === "2") throw new Error("RouterOS unavailable"); return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } }, restore: async () => { restored++; if (restoreFail) throw new Error("import failed"); return { filename: "config.rsc", serverName: "router" } }, appendEvent: async () => { eventCalls++; throw new Error("event DB unavailable") } }), { prefix: "/api" })
|
||||
await app.register(createBackupsRoutes({ delete: async () => { deleted++; if (deleteOutcome === "error") throw new Error("S3 unavailable"); if (deleteOutcome === "missing") return null; return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "both" } }, listServers: async () => [{ id: 1 }, { id: 2 }], create: async id => { created++; if (id === "2") throw new Error("RouterOS unavailable"); return { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } }, restore: async () => { restored++; if (restoreFail) throw new Error("import failed"); return { filename: "config.rsc", serverName: "router" } }, appendEvent: async () => { eventCalls++; throw new Error("event DB unavailable") } }), { prefix: "/api" })
|
||||
const post = (payload: unknown) => app.inject({ method: "POST", url: "/api/backups/create", payload: payload as Record<string, unknown> })
|
||||
for (const serverIds of [[], ["1", "missing"], ["1", "3"], ["1", "1"], ["0"], ["1junk"], [9007199254740992]]) assert.equal((await post({ serverIds })).statusCode, 400)
|
||||
assert.equal(created, 0)
|
||||
@@ -29,4 +30,8 @@ assert.equal((await post({ requestId, serverIds: ["1"], notes: "other" })).statu
|
||||
const success = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(success.statusCode, 200); assert.deepEqual(success.json(), { filename: "config.rsc", serverName: "router" }); assert.equal(restored, 1)
|
||||
restoreFail = true; const failure = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(failure.statusCode, 400); assert.equal(failure.json().error, "import failed")
|
||||
assert.equal(permissionForRequest("POST", "/api/backups/create"), "mm:backups:write"); assert.equal(permissionForRequest("POST", "/api/backups/backup-1/restore"), "mm:backups:write"); assert.equal(permissionForRequest("GET", "/api/backups/jobs/job"), "mm:backups:read")
|
||||
assert.equal((await app.inject({ method: "DELETE", url: "/api/backups/backup-1" })).statusCode, 204)
|
||||
deleteOutcome = "error"; assert.equal((await app.inject({ method: "DELETE", url: "/api/backups/backup-1" })).statusCode, 500)
|
||||
deleteOutcome = "missing"; assert.equal((await app.inject({ method: "DELETE", url: "/api/backups/missing" })).statusCode, 404); assert.equal(deleted, 3)
|
||||
assert.equal(permissionForRequest("DELETE", "/api/backups/backup-1"), "mm:backups:write")
|
||||
await app.close(); console.log("Backups HTTP: event failure does not relabel created/restore, invalid targets have no side effects, partial results and RBAC")
|
||||
@@ -36,7 +36,7 @@ const BackupJobIdParamSchema = z.object({
|
||||
jobId: z.string().min(1),
|
||||
})
|
||||
|
||||
export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{ id: string | number }>>; create?: typeof runBackupForServer; restore?: typeof restoreBackupToDevice; appendEvent?: typeof appendEvent } = {}): FastifyPluginAsyncZod {
|
||||
export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{ id: string | number }>>; create?: typeof runBackupForServer; restore?: typeof restoreBackupToDevice; delete?: typeof deleteBackupRecord; appendEvent?: typeof appendEvent } = {}): FastifyPluginAsyncZod {
|
||||
return async (app) => {
|
||||
const backupJobs = new Map<string, BackupJob>()
|
||||
const event = (input: Parameters<typeof appendEvent>[0]) => recordBackupEvent(input, deps.appendEvent ?? appendEvent, error => app.log.warn({ err: error }, "Не удалось записать событие резервных копий"))
|
||||
@@ -207,7 +207,7 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
|
||||
})
|
||||
|
||||
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
const hit = await deleteBackupRecord(req.params.id)
|
||||
const hit = await (deps.delete ?? deleteBackupRecord)(req.params.id)
|
||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import path from "node:path"
|
||||
import { backupDeletionPath, deleteStoredBackup } from "./backup-deletion.js"
|
||||
const directory = path.resolve("storage", "backups"), entry = { id: "backup-1", filename: "config.rsc", s3Key: "object" }
|
||||
|
||||
test("storage acknowledgements precede catalog deletion, local-only skips S3", async () => {
|
||||
for (const remote of [true, false]) {
|
||||
const calls: string[] = []
|
||||
await deleteStoredBackup({ ...entry, s3Key: remote ? entry.s3Key : null }, directory, { removeS3: async key => { assert.equal(key, "object"); calls.push("s3") }, removeLocal: async file => { assert.equal(file, path.join(directory, entry.filename)); calls.push("local") }, removeRecord: async id => { assert.equal(id, entry.id); calls.push("db") } })
|
||||
assert.deepEqual(calls, remote ? ["s3", "local", "db"] : ["local", "db"])
|
||||
}
|
||||
})
|
||||
test("remote, local and DB errors propagate; partial cleanup can be retried", async () => {
|
||||
for (const failing of ["s3", "local", "db"]) {
|
||||
const calls: string[] = []; let fail = true
|
||||
const step = async (name: string) => { calls.push(name); if (fail && name === failing) throw new Error(name + " unavailable") }
|
||||
const deps = { removeS3: () => step("s3"), removeLocal: () => step("local"), removeRecord: () => step("db") }
|
||||
await assert.rejects(deleteStoredBackup(entry, directory, deps), new RegExp(failing + " unavailable"))
|
||||
assert.deepEqual(calls, ["s3", "local", "db"].slice(0, ["s3", "local", "db"].indexOf(failing) + 1))
|
||||
fail = false; calls.length = 0; await deleteStoredBackup(entry, directory, deps); assert.deepEqual(calls, ["s3", "local", "db"])
|
||||
}
|
||||
})
|
||||
test("unsafe filename rejects before any storage mutation", async () => {
|
||||
for (const filename of ["", ".", "..", "../config.rsc", "..\\config.rsc", "/config.rsc", "C:\\config.rsc", "nested/config.rsc"]) {
|
||||
assert.throws(() => backupDeletionPath(directory, filename)); let mutated = false
|
||||
const mutate = async () => { mutated = true }
|
||||
await assert.rejects(deleteStoredBackup({ ...entry, filename }, directory, { removeS3: mutate, removeLocal: mutate, removeRecord: mutate })); assert.equal(mutated, false)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import path from "node:path"
|
||||
|
||||
export function backupDeletionPath(directory: string, filename: string): string {
|
||||
if (!filename || filename === "." || filename === ".." || /[\\/]/.test(filename) || path.isAbsolute(filename)) throw new Error("Некорректное имя файла резервной копии")
|
||||
const target = path.resolve(directory, filename), relative = path.relative(path.resolve(directory), target)
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Файл резервной копии вне хранилища")
|
||||
return target
|
||||
}
|
||||
|
||||
/** Keep the catalog entry until every configured storage cleanup has acknowledged success. */
|
||||
export async function deleteStoredBackup(entry: { id: string; filename: string; s3Key?: string | null }, directory: string, deps: {
|
||||
removeS3: (key: string) => Promise<void>
|
||||
removeLocal: (file: string) => Promise<void>
|
||||
removeRecord: (id: string) => Promise<void>
|
||||
}): Promise<void> {
|
||||
const file = backupDeletionPath(directory, entry.filename)
|
||||
if (entry.s3Key) await deps.removeS3(entry.s3Key)
|
||||
await deps.removeLocal(file)
|
||||
await deps.removeRecord(entry.id)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
PutBackupStorageSettings,
|
||||
} from "@mmapp/contracts/backups"
|
||||
import { mapBackupEntry } from "./backup-dto.js"
|
||||
import { deleteStoredBackup } from "./backup-deletion.js"
|
||||
import { planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { db } from "../db/index.js"
|
||||
import { parseJsonArray } from "../db/json.js"
|
||||
@@ -88,19 +89,15 @@ export async function deleteBackupRecord(id: string): Promise<BackupMeta | null>
|
||||
const row = await getBackupRow(id)
|
||||
if (!row) return null
|
||||
const meta = rowToMeta(row)
|
||||
if (row.s3Key) {
|
||||
try {
|
||||
await deleteStoredBackup(row, BACKUPS_DIR, {
|
||||
removeS3: async key => {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
if (cfg) {
|
||||
const client = createS3ClientFromConfig(cfg)
|
||||
await s3DeleteObject(client, cfg.bucket, row.s3Key)
|
||||
}
|
||||
} catch {
|
||||
/* объект мог уже отсутствовать */
|
||||
}
|
||||
}
|
||||
await db.delete(backupEntries).where(eq(backupEntries.id, id))
|
||||
await rm(path.join(BACKUPS_DIR, row.filename), { force: true })
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user