fix(backups): показать отсутствие файлов и защитить удаление записей

This commit is contained in:
Denozordec committed 2026-10-14 14:30:33 +07:00
1 parent e452e528e1
commit 6617b776bb
26 files changed
+420 -36

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
"test:backups": "tsx src/services/router-config-export.test.ts && tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && 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:backups": "tsx src/services/backup-local-file-state.test.ts && tsx src/services/router-config-export.test.ts && tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && 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:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts",
"test:live-maps": "tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.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 && tsx src/routes/ospf-legacy.test.ts",
"test": "node scripts/test.mjs",
@@ -0,0 +1,3 @@
import assert from'node:assert/strict';import fs from'node:fs';import{createRequire}from'node:module';import{createServer}from'node:http';
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-availability237',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();fs.mkdirSync('storage/backups',{recursive:true});fs.writeFileSync('storage/backups/available237.rsc','# harmless own fixture\n');fs.mkdirSync('storage/backups/directory237.rsc');const fixtures=[['available237','available237.rsc','local',null],['missing237','missing237.rsc','local',null],['both237','both237.rsc','both','own-test/both237.rsc'],['remote237','remote237.rsc','s3','own-test/remote237.rsc'],['directory237','directory237.rsc','local',null]];for(const[id,filename,storage,key]of fixtures)await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES($1,$2,$3,$4,1,$5,$6,$7,now())',[id,server.id,'fixture',filename,'manual',storage,key]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.237',passed:false,checks:[],externalS3Contacts:0,chrContacts:0};try{let r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.statusCode,200);const statuses=Object.fromEntries(r.json().map(r=>[r.id,r.localFileStatus]));assert.deepEqual(statuses,{available237:'available',missing237:'missing',both237:'missing',remote237:'not-stored',directory237:'unknown'});report.checks.push({listStates:statuses});r=await app.inject({method:'GET',url:'/api/backups/available237/download'});assert.equal(r.statusCode,200);assert.equal(r.body,'# harmless own fixture\n');report.checks.push({availableDownload:200});r=await app.inject({method:'GET',url:'/api/backups/missing237/download'});assert.equal(r.statusCode,404);report.checks.push({missingDownload:404});r=await app.inject({method:'POST',url:'/api/backups/missing237/restore'});assert.equal(r.statusCode,400);assert.equal(r.json().outcome,'not-applied');assert.match(r.json().error,/Локальный файл/);assert.equal(nativeRequests,0);report.checks.push({missingRestore:400,outcome:'not-applied',nativeRequests:0});r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.json().length,5);report.checks.push({failurePreservesCatalog:true});fs.writeFileSync('storage/backups/missing237.rsc','# recreated fixture\n');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,409);assert(fs.existsSync('storage/backups/missing237.rsc'));assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='missing237'")).rows[0].n,1);report.checks.push({recreatedFilePreserved:409});fs.unlinkSync('storage/backups/missing237.rsc');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,204);assert.equal(r.headers['x-backup-delete-mode'],'record-only');report.checks.push({missingRecordDeletion:204,mode:'record-only'});report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/backup-availability237.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
+11 -4
View File
@@ -215,10 +215,17 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
}
})
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const hit = await (deps.delete ?? deleteBackupRecord)(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
return reply.status(204).send()
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema, querystring: z.object({ recordOnly: z.enum(["true", "false"]).optional() }) } }, async (req, reply) => {
try {
const recordOnly = req.query.recordOnly === "true"
const hit = await (deps.delete ?? deleteBackupRecord)(req.params.id, { recordOnly })
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
reply.header("X-Backup-Delete-Mode", recordOnly ? "record-only" : "file-and-record")
return reply.status(204).send()
} catch (error) {
if (error instanceof BackupStorageConflictError) return reply.status(409).send({ error: error.message })
throw error
}
})
}
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import { mkdtemp, writeFile, mkdir, rm, lstat } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { withBackupLocalFileStates } from "./backup-local-file-state.js"
import type { BackupItemDto } from "@mmapp/contracts/backups"
const directory = await mkdtemp(path.join(os.tmpdir(), "mm-backup-state-"))
const row = (filename: string, storage: BackupItemDto["storage"] = "local"): BackupItemDto => ({ id: filename, filename, serverId: 1, serverName: "fixture", sizeBytes: 0, createdAt: "2026-10-14T00:00:00Z", kind: "manual", storage })
try {
await writeFile(path.join(directory, "available.rsc"), "# own fixture\n")
await mkdir(path.join(directory, "directory.rsc"))
const rows = [row("available.rsc"), row("missing.rsc"), row("missing-both.rsc", "both"), row("remote.rsc", "s3"), row("directory.rsc"), row("../outside.rsc")]
const result = await withBackupLocalFileStates(rows, directory)
assert.deepEqual(result.map(r => r.localFileStatus), ["available", "missing", "missing", "not-stored", "unknown", "unknown"])
assert(rows.every(r => r.localFileStatus === undefined))
assert.equal((await lstat(path.join(directory, "available.rsc"))).isFile(), true)
const denied = await withBackupLocalFileStates([row("denied.rsc")], directory, (async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }) }) as typeof lstat)
assert.equal(denied[0].localFileStatus, "unknown")
let active = 0, max = 0
const inspect = (async () => { active++; max = Math.max(max, active); await new Promise(r => setTimeout(r, 1)); active--; return lstat(path.join(directory, "available.rsc")) }) as typeof lstat
const many = await withBackupLocalFileStates(Array.from({ length: 100 }, (_, i) => row(`${i}.rsc`)), directory, inspect)
assert.equal(many.length, 100); assert(max <= 8); assert.equal(active, 0)
let touched = false
await withBackupLocalFileStates([row("remote-only.rsc", "s3"), row("../outside.rsc")], directory, (async () => { touched = true; throw Error("unexpected") }) as typeof lstat)
assert.equal(touched, false)
console.log("backup local states: real file/missing/both/remote/directory/path, permission error and bounded100 passed")
} finally { await rm(directory, { recursive: true, force: true }) }
@@ -0,0 +1,28 @@
import { lstat } from "node:fs/promises"
import type { BackupItemDto } from "@mmapp/contracts/backups"
import { backupDeletionPath } from "./backup-deletion.js"
/** Inspect local copies without probing S3; preserve unknown states and bound filesystem work. */
export async function withBackupLocalFileStates<T extends BackupItemDto>(
rows: T[], directory: string, inspect = lstat,
): Promise<T[]> {
const result = [...rows]
let cursor = 0
await Promise.all(Array.from({ length: Math.min(8, rows.length) }, async () => {
while (cursor < rows.length) {
const index = cursor++, row = rows[index]
let status: BackupItemDto["localFileStatus"] = "not-stored"
if (row.storage !== "s3") {
try {
const info = await inspect(backupDeletionPath(directory, row.filename))
status = info.isFile() ? "available" : "unknown"
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
status = code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown"
}
}
result[index] = { ...row, localFileStatus: status }
}
}))
return result
}
+31 -8
View File
@@ -6,13 +6,15 @@ 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 { deleteStoredBackup } from "./backup-deletion.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 { withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.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"
@@ -47,6 +49,7 @@ export type BackupMeta = {
storage: "local" | "s3" | "both"
s3Key?: string | null
uploadError?: string | null
localFileStatus?: BackupLocalFileStatus
}
type BackupRow = typeof backupEntries.$inferSelect
@@ -59,12 +62,12 @@ export async function ensureBackupStorage(): Promise<void> {
export async function listBackups(): Promise<BackupMeta[]> {
const rows = await db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt))
return rows.map(rowToMeta)
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 ? rowToMeta(row) : null
return row ? (await withBackupLocalFileStates([rowToMeta(row)], BACKUPS_DIR))[0] : null
}
export async function insertBackup(meta: BackupMeta): Promise<void> {
@@ -92,14 +95,23 @@ 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): Promise<BackupMeta | null> {
return withBackupStorageOperation(() => deleteStoredBackupRecord(id))
export async function deleteBackupRecord(id: string, options: { recordOnly?: boolean } = {}): Promise<BackupMeta | null> {
return withBackupStorageOperation(() => deleteStoredBackupRecord(id, Boolean(options.recordOnly)))
}
async function deleteStoredBackupRecord(id: string): Promise<BackupMeta | null> {
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()
@@ -481,6 +493,7 @@ export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
}
async function readStoredBackupContent(meta: BackupMeta): Promise<Buffer> {
let remoteUnavailable = false
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
try {
const cfg = await getS3ConfigIfEnabled()
@@ -488,11 +501,21 @@ async function readStoredBackupContent(meta: BackupMeta): Promise<Buffer> {
const client = createS3ClientFromConfig(cfg)
return await s3GetObject(client, cfg.bucket, meta.s3Key)
}
remoteUnavailable = true
} catch {
remoteUnavailable = true
/* fallback: локальная копия, если есть */
}
}
return await readFile(path.join(BACKUPS_DIR, meta.filename))
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 }> {