From 92b65a30cee5ccc978a486033c44ce58780ec385 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 16:43:52 +0700 Subject: [PATCH] =?UTF-8?q?fix(settings):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B0=D1=82=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B1=D0=B0=D0=B7=D1=8B=20=D0=BF=D1=80=D0=B8=20=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=87=D0=BD=D1=8B=D1=85=20=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/settings/page.tsx | 5 +-- backend/package.json | 2 +- backend/src/db/operation-lock.ts | 6 ++-- backend/src/index.ts | 1 + .../src/plugins/auth.system-database.test.ts | 16 ++++++++++ backend/src/routes/system-database.test.ts | 17 ++++++++++ backend/src/routes/system-database.ts | 31 ++++++++++++++----- .../src/services/system-database-backup.ts | 28 ++++++++--------- .../system-database-operation.test.ts | 23 ++++++++++++++ .../src/services/system-database-operation.ts | 23 ++++++++++++++ docs/ux-ui-plan.md | 4 ++- docs/ux-ui-progress.md | 10 +++++- docs/ux-ui-stage-11b.md | 2 ++ shared/api/system-database.test.ts | 12 +++++++ shared/api/system-database.ts | 11 +++++-- 15 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 backend/src/plugins/auth.system-database.test.ts create mode 100644 backend/src/routes/system-database.test.ts create mode 100644 backend/src/services/system-database-operation.test.ts create mode 100644 backend/src/services/system-database-operation.ts diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index ac7a3ebe..efb076be 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -105,14 +105,15 @@ export default function SettingsPage() { if (!systemDbAvailable) return setDbBackupBusy(true) try { - const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl) + const { blob, filename, warnings } = await downloadSystemDatabaseBackup(backendUrl) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = filename a.click() URL.revokeObjectURL(url) - toast.success("Бэкап базы приложения скачан") + toast.success("Бэкап базы приложения подготовлен к скачиванию") + for (const warning of Object.values(warnings)) if (warning) toast.warning(warning) } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап") } finally { diff --git a/backend/package.json b/backend/package.json index 53b40a63..2dcbb698 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,7 +35,7 @@ "test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts", "test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts", "test:certificates": "tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts", - "test:system-database": "tsx src/services/system-database-restore-execution.test.ts" + "test:system-database": "tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/backend/src/db/operation-lock.ts b/backend/src/db/operation-lock.ts index 692f619d..e750e507 100644 --- a/backend/src/db/operation-lock.ts +++ b/backend/src/db/operation-lock.ts @@ -3,13 +3,13 @@ type Client = { release: (destroy?: boolean) => void } /** Nonblocking session lock: a second destructive operation is rejected rather than queued. */ -export async function withTrafficFlowPurgeLock(connect: () => Promise, key: number, run: () => Promise): Promise { +export async function withOperationLock(connect: () => Promise, key: number, run: () => Promise, busyMessage = "Сброс NetFlow уже выполняется"): Promise { const client = await connect() let owned = false, destroy = false try { destroy = true const result = await client.query("SELECT pg_try_advisory_lock($1) AS acquired", [key]) - if (result.rows[0]?.acquired === false) { destroy = false; throw new Error("Сброс NetFlow уже выполняется") } + if (result.rows[0]?.acquired === false) { destroy = false; throw new Error(busyMessage) } if (result.rows[0]?.acquired !== true) throw new Error("Блокировка сброса NetFlow не подтверждена") owned = true; destroy = false return await run() @@ -24,3 +24,5 @@ export async function withTrafficFlowPurgeLock(connect: () => Promise client.release(destroy) } } + +export const withTrafficFlowPurgeLock = withOperationLock diff --git a/backend/src/index.ts b/backend/src/index.ts index 9316d05e..eac81e44 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -86,6 +86,7 @@ export async function buildApp(opts?: { await app.register(cors, { origin: env.CORS_ORIGIN, + exposedHeaders: ["Content-Disposition", "X-System-Database-Outcome"], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], }) diff --git a/backend/src/plugins/auth.system-database.test.ts b/backend/src/plugins/auth.system-database.test.ts new file mode 100644 index 00000000..2fbafb25 --- /dev/null +++ b/backend/src/plugins/auth.system-database.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {SignJWT} from "jose" +process.env.AUTH_REQUIRED="true";process.env.AUTH_JWT_SECRET="evolution-fixture-secret";process.env.AUTH_ISSUER="https://auth.test.local";process.env.NODE_ENV="test" +const {default:authPlugin,requireAuth}=await import("./auth.js") +const {createSystemDatabaseRoutes}=await import("../routes/system-database.js") +const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth) +let exports=0,restores=0 +await app.register(createSystemDatabaseRoutes({exportBackup:async()=>{exports++;return {filename:"fixture.dump",buffer:Buffer.from("PGDMPfixture")}},restoreBackup:async()=>{restores++;return {}},event:async()=>undefined as never}),{prefix:"/api"}) +for(const permission of ["mm:settings:admin","mm:settings:write","mm:traffic:admin","mm:dashboard:read"]){ + const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret")) + for(const [method,url] of [["GET","/api/system/database/backup"],["POST","/api/system/database/restore"]] as const){const response=await app.inject({method,url,headers:{authorization:"Bearer "+token,"content-type":"application/octet-stream"},...(method==="POST"?{payload:Buffer.from("PGDMPfixture")}:{})});assert.equal(response.statusCode,permission==="mm:settings:admin"?200:403)} +} +assert.equal(exports,1);assert.equal(restores,1) +for(const [method,url] of [["GET","/api/system/database/backup"],["POST","/api/system/database/restore"]] as const)assert.equal((await app.inject({method,url})).statusCode,401) +assert.equal(exports,1);assert.equal(restores,1);await app.close();console.log("System DB JWT: actual backup/restore handlers require settings admin before operations") diff --git a/backend/src/routes/system-database.test.ts b/backend/src/routes/system-database.test.ts new file mode 100644 index 00000000..35644cd0 --- /dev/null +++ b/backend/src/routes/system-database.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {createSystemDatabaseRoutes} from "./system-database.js" +import {withOperationLock} from "../db/operation-lock.js" +const app=Fastify(),dump=Buffer.from("PGDMPfixture") +let exports=0,restores=0,events=0,eventFails=true,coreFails=false,held=false +let gate:Promise|undefined +let entered:(()=>void)|undefined +const lock=async()=>({query:async(sql:string)=>{if(sql.includes("pg_try")){const acquired=!held;if(acquired)held=true;return {rows:[{acquired}]}}held=false;return {rows:[{released:true}]}},release:()=>{}}) +await app.register(createSystemDatabaseRoutes({exportBackup:async()=>{exports++;if(coreFails)throw Error("dump failed");return {filename:"fixture.dump",buffer:dump,listenerError:"listener warning"}},restoreBackup:async()=>withOperationLock(lock,1,async()=>{restores++;entered?.();if(gate)await gate;if(coreFails)throw Error("restore failed");return {schedulerError:"scheduler warning",cleanupError:"cleanup warning"}},"Операция с базой приложения уже выполняется"),event:async()=>{events++;if(eventFails)throw Error("event fixture secret");return undefined as never}}),{prefix:"/api"}) +const restore=()=>app.inject({method:"POST",url:"/api/system/database/restore",headers:{"content-type":"application/octet-stream"},payload:dump}) +let response=await app.inject({method:"GET",url:"/api/system/database/backup"});assert.equal(response.statusCode,200);assert.deepEqual(response.rawPayload,dump);let warnings=JSON.parse(decodeURIComponent(response.headers["x-system-database-outcome"] as string));assert.equal(warnings.listenerError,"listener warning");assert.match(warnings.eventError,/событие/);assert.equal(response.body.includes("secret"),false) +response=await restore();assert.equal(response.statusCode,200);assert.equal(response.json().ok,true);assert.equal(response.json().cleanupError,"cleanup warning");assert.match(response.json().eventError,/событие/) +coreFails=true;const before=events;response=await restore();assert.equal(response.statusCode,400);assert.equal(response.json().ok,undefined);assert.equal(events,before);response=await app.inject({method:"GET",url:"/api/system/database/backup"});assert.equal(response.statusCode,500);assert.equal(events,before) +coreFails=false;eventFails=false;let release!:()=>void;gate=new Promise(resolve=>release=resolve);const started=new Promise(resolve=>entered=resolve);const first=restore();await started;const runs=restores;response=await restore();assert.equal(response.statusCode,409);assert.equal(restores,runs);release();assert.equal((await first).statusCode,200);gate=undefined +response=await app.inject({method:"POST",url:"/api/system/database/restore",headers:{"content-type":"application/octet-stream"},payload:Buffer.alloc(0)});assert.equal(response.statusCode,400);assert.equal(restores,runs) +assert.equal(exports,2);await app.close();console.log("System DB HTTP: confirmed binary/restore, separate audit warnings, failures, nonqueued conflict and no retry") diff --git a/backend/src/routes/system-database.ts b/backend/src/routes/system-database.ts index 8382bc7d..e6e7b81b 100644 --- a/backend/src/routes/system-database.ts +++ b/backend/src/routes/system-database.ts @@ -5,7 +5,12 @@ import { restoreSystemDatabaseBackup, } from "../services/system-database-backup.js" -const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { +interface Dependencies { + exportBackup: typeof exportSystemDatabaseBackup + restoreBackup: typeof restoreSystemDatabaseBackup + event: typeof appendEvent +} +export function createSystemDatabaseRoutes(deps: Dependencies): FastifyPluginAsync { return async (app) => { app.addContentTypeParser( "application/octet-stream", { parseAs: "buffer", bodyLimit: 512 * 1024 * 1024 }, @@ -16,8 +21,10 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { app.get("/system/database/backup", async (_req, reply) => { try { - const { filename, buffer } = await exportSystemDatabaseBackup() - await appendEvent({ + const { filename, buffer, ...warnings } = await deps.exportBackup() + let eventError: string | undefined + try { + await deps.event({ level: "info", eventType: "system.database.backup", sourceModule: "system", @@ -30,12 +37,15 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { sizeBytes: buffer.length, }, }) + } catch { eventError = "Не удалось записать событие резервного копирования" } + const outcome = { ...warnings, ...(eventError ? { eventError } : {}) } + reply.header("X-System-Database-Outcome", encodeURIComponent(JSON.stringify(outcome))) reply.header("Content-Type", "application/octet-stream") reply.header("Content-Disposition", `attachment; filename="${filename}"`) return reply.send(buffer) } catch (err) { const message = err instanceof Error ? err.message : String(err) - return reply.status(500).send({ error: message }) + return reply.status(message.includes("уже выполняется") ? 409 : 500).send({ error: message }) } }) @@ -45,8 +55,10 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { return reply.status(400).send({ error: "Ожидается тело запроса с файлом pg_dump (custom, PGDMP)" }) } try { - const outcome = await restoreSystemDatabaseBackup(body) - await appendEvent({ + const outcome = await deps.restoreBackup(body) + let eventError: string | undefined + try { + await deps.event({ level: "warning", eventType: "system.database.restore", sourceModule: "system", @@ -58,7 +70,8 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { sizeBytes: body.length, }, }) - return reply.send({ ok: true, ...outcome }) + } catch { eventError = "Не удалось записать событие восстановления" } + return reply.send({ ok: true, ...outcome, ...(eventError ? { eventError } : {}) }) } catch (err) { const message = err instanceof Error ? err.message : String(err) const status = message.includes("уже выполняется") ? 409 : 400 @@ -67,4 +80,6 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { }) } -export default systemDatabaseRoutes +} + +export default createSystemDatabaseRoutes({ exportBackup: exportSystemDatabaseBackup, restoreBackup: restoreSystemDatabaseBackup, event: appendEvent }) diff --git a/backend/src/services/system-database-backup.ts b/backend/src/services/system-database-backup.ts index 5efa784f..21591c3c 100644 --- a/backend/src/services/system-database-backup.ts +++ b/backend/src/services/system-database-backup.ts @@ -5,7 +5,9 @@ import os from "node:os" import path from "node:path" import { executeSystemDatabaseRestore, validateSystemDatabaseRestoreArchive, type SystemDatabaseRestoreOutcome } from "./system-database-restore-execution.js" import { env } from "../config.js" -import { withAdvisoryLock } from "../db/index.js" +import { pool } from "../db/index.js" +import { withOperationLock } from "../db/operation-lock.js" +import { executeDatabaseOperation, type DatabaseOperationWarnings } from "./system-database-operation.js" import { refreshScheduler, stopScheduler } from "./scheduler.js" import { startTrafficFlowListener, @@ -34,7 +36,7 @@ function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer child.on("error", reject) child.on("close", (code) => { if (code !== 0) { - reject(new Error(err.trim() || `${command} завершился с кодом ${code}`)) + reject(new Error(err.trim().replaceAll(env.DATABASE_URL, "[DATABASE_URL]") || `${command} завершился с кодом ${code}`)) return } resolve(Buffer.concat(chunks)) @@ -44,20 +46,16 @@ function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer }) } -async function withDatabaseOperation(fn: () => Promise): Promise { - return await withAdvisoryLock(BACKUP_LOCK_KEY, async () => { - await stopTrafficFlowListener() - stopScheduler() - try { - return await fn() - } finally { - await startTrafficFlowListener() - await refreshScheduler() - } - }) +async function withDatabaseOperation(fn: () => Promise): Promise { + return withOperationLock(() => pool.connect(), BACKUP_LOCK_KEY, () => executeDatabaseOperation(fn, { + stopListener: stopTrafficFlowListener, + stopScheduler, + startListener: startTrafficFlowListener, + startScheduler: refreshScheduler, + }), "Операция с базой приложения уже выполняется") } -export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer }> { +export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer } & DatabaseOperationWarnings> { return await withDatabaseOperation(async () => { const buffer = await spawnCollected("pg_dump", ["-Fc", env.DATABASE_URL]) return { @@ -67,7 +65,7 @@ export async function exportSystemDatabaseBackup(): Promise<{ filename: string; }) } -export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise { +export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise { validateSystemDatabaseRestoreArchive(buffer) return await withDatabaseOperation(() => executeSystemDatabaseRestore(buffer, env.DATABASE_URL, { allocate: async () => { diff --git a/backend/src/services/system-database-operation.test.ts b/backend/src/services/system-database-operation.test.ts new file mode 100644 index 00000000..3b28202d --- /dev/null +++ b/backend/src/services/system-database-operation.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {executeDatabaseOperation} from "./system-database-operation.js" +function fixture(fail: string[] = []) { + const calls:string[]=[] + const step=(name:string)=>async()=>{calls.push(name);if(fail.includes(name))throw Error(name+" failed")} + return {calls,deps:{stopListener:step("stop listener"),stopScheduler:step("stop scheduler"),startListener:step("start listener"),startScheduler:step("start scheduler")},run:async()=>{await step("command")();return {cleanupError:"cleanup warning"}}} +} +test("confirmed command survives independent restart failures",async()=>{ + for(const fail of [[],["start listener"],["start scheduler"],["start listener","start scheduler"]]){ + const f=fixture(fail);const result=await executeDatabaseOperation(f.run,f.deps) + assert.equal(result.cleanupError,"cleanup warning");assert.equal(!!result.listenerError,fail.includes("start listener"));assert.equal(!!result.schedulerError,fail.includes("start scheduler")) + assert.deepEqual(f.calls,["stop listener","stop scheduler","command","start listener","start scheduler"]) + } +}) +test("core failure survives restart failures without command retry",async()=>{ + const f=fixture(["command","start listener","start scheduler"]) + await assert.rejects(executeDatabaseOperation(f.run,f.deps),/command failed/) + assert.equal(f.calls.filter(s=>s==="command").length,1);assert.deepEqual(f.calls.slice(-2),["start listener","start scheduler"]) +}) +test("stop failure prevents command and still attempts both restarts",async()=>{ + for(const step of ["stop listener","stop scheduler"]){const f=fixture([step,"start listener"]);await assert.rejects(executeDatabaseOperation(f.run,f.deps),new RegExp(step+" failed"));assert.equal(f.calls.includes("command"),false);assert.deepEqual(f.calls.slice(-2),["start listener","start scheduler"])} +}) diff --git a/backend/src/services/system-database-operation.ts b/backend/src/services/system-database-operation.ts new file mode 100644 index 00000000..44f9220a --- /dev/null +++ b/backend/src/services/system-database-operation.ts @@ -0,0 +1,23 @@ +export type DatabaseOperationWarnings = { listenerError?: string; schedulerError?: string } +interface Dependencies { + stopListener: () => Promise | void + stopScheduler: () => Promise | void + startListener: () => Promise | void + startScheduler: () => Promise | void +} +/** Restart both consumers independently; secondary errors cannot erase a completed command. */ +export async function executeDatabaseOperation(run: () => Promise, deps: Dependencies): Promise { + let result: T | undefined, failed = false, failure: unknown + const warnings: DatabaseOperationWarnings = {} + try { + await deps.stopListener() + await deps.stopScheduler() + result = await run() + } catch (error) { failed = true; failure = error } + finally { + try { await deps.startListener() } catch { warnings.listenerError = "Не удалось перезапустить приём NetFlow" } + try { await deps.startScheduler() } catch { warnings.schedulerError = "Не удалось перезапустить планировщик" } + } + if (failed) throw failure + return { ...result!, ...warnings } +} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index e1471a35..e6bf20af 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.2.1 (af5dbb0). Следующий блок 11Б.3.6.2.2.2: отделить подтверждённый результат backup/restore от ошибок перезапуска listener/scheduler и appendEvent; проверить HTTP/JWT/concurrency через DI. Далее 11Б.3.6.2.3: отдельная форма DB с source/active guards, синхронным lock и review неизвестного результата. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. +Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.2.2. Следующий блок 11Б.3.6.2.3: отдельная форма DB с source/active guards, синхронным lock и review неизвестного результата. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). @@ -298,3 +298,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма. Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI. + +2026-10-03: завершён 11Б.3.6.2.2.2, подтверждённый backup/restore отделён от restart/audit warnings; nonqueued concurrency, HTTP/JWT/API/UI проверки прошли локально. Далее DB форма 11Б.3.6.2.3. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 373105b2..07deca34 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.2.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.2.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.6.2.2.2 — подтверждённая операция и вторичные сбои (2026-10-03) + +- Core backup/restore отделён от restart listener/scheduler: оба restart выполняются независимо, их errors — warnings после подтверждённой команды; первичная ошибка не подменяется вторичной. Ошибка stop предотвращает команду и запускает попытки восстановления потребителей. Cleanup warning сохранён. +- appendEvent failure не отменяет готовый бинарный backup или ok:true restore. Restore возвращает eventError, backup — percent-encoded strict metadata header; CORS exposes filename/outcome. Client читает warnings отдельно, malformed metadata не уничтожает подтверждённый PGDMP и даёт предупреждение. UI сообщает подготовку скачивания, не подтверждает сохранение файла на диск. +- System DB теперь использует nonblocking pg_try_advisory_lock вместо общей blocking функции: конкурентный запрос получает 409 и не ставится в очередь. Общий helper получил нейтральное имя withOperationLock, NetFlow alias/default сохранены. DATABASE_URL редактируется из точного совпадения stderr команды; прежние event rows не изменялись. +- 4 restore DI + 3 lifecycle DI, HTTP binary/audit/core failure/concurrency, реальные signed JWT gates, 4 API unit группы и 2 browser receipt сценария прошли; backend build, typecheck и профильный lint прошли. Реальный PG dump/restore/lock overlap и UDP stop acknowledgment не проверены; тесты не обращались к реальной БД. +- Следующий блок 11Б.3.6.2.3: отдельная форма system DB, admin/source/active guards, общий синхронный lock, review неизвестного результата, адаптивность. Затем Alerts/Releases/probe scheduling и приёмка 11Б/12. + ## Блок 11Б.3.6.2.2.1 — выполнение восстановления PostgreSQL (2026-10-03) - Restore executor вынесен в pure DI helper. Production pg_restore получает --single-transaction вместе с --clean/--if-exists; validate size/PGDMP prefix выполняется до lock/остановки сборщиков и повторно в executor. Команда не повторяется, временный файл удаляется при write/run error. Cleanup failure после успешной команды — отдельный cleanupError, не отменяет её подтверждённый результат; основная ошибка не подменяется cleanup error. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index cdd6971c..df0a6e4f 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -224,3 +224,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма. Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI. + +Завершён 11Б.3.6.2.2.2: core/restart/event outcomes, nonqueued lock/409, DI HTTP/JWT/concurrency/API и browser checks прошли; реальная БД не запускалась. Далее 11Б.3.6.2.3 — отдельная DB форма с source/active/admin/sync lock/unknown review. diff --git a/shared/api/system-database.test.ts b/shared/api/system-database.test.ts index 6bc80a3c..be0c95dc 100644 --- a/shared/api/system-database.test.ts +++ b/shared/api/system-database.test.ts @@ -26,3 +26,15 @@ test("restore needs literal receipt; malformed/false/204/HTTP error never retry" calls=0;globalThis.fetch=async()=>{calls++;return Response.json({error:"restore unknown"},{status:503})};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/restore unknown/);assert.equal(calls,1) }finally{globalThis.fetch=original} }) + +test("backup warning header is strict and malformed metadata preserves valid archive with warning",async()=>{ + const original=globalThis.fetch;try{ + const warnings={listenerError:"listener warning",schedulerError:"scheduler warning",eventError:"event warning"} + globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"X-System-Database-Outcome":encodeURIComponent(JSON.stringify(warnings))}}) + assert.deepEqual((await downloadSystemDatabaseBackup("https://backend.test")).warnings,warnings) + for(const header of ["%","false",encodeURIComponent(JSON.stringify({listenerError:4})),encodeURIComponent(JSON.stringify({error:"failed"}))]){ + globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"X-System-Database-Outcome":header}}) + const result=await downloadSystemDatabaseBackup("https://backend.test");assert.equal(await result.blob.text(),"PGDMPfixture");assert.match(result.warnings.eventError!,/не подтверждены/) + } + }finally{globalThis.fetch=original} +}) diff --git a/shared/api/system-database.ts b/shared/api/system-database.ts index 78ed96a9..4cfb97bb 100644 --- a/shared/api/system-database.ts +++ b/shared/api/system-database.ts @@ -4,6 +4,8 @@ const MAX_RESTORE_BYTES=512*1024*1024 const magic=[80,71,68,77,80] const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).optional(),listenerError:z.string().min(1).optional(),schedulerError:z.string().min(1).optional(),cleanupError:z.string().min(1).optional()}).strict() export type SystemDatabaseRestoreReceipt=z.output +const warningSchema=receiptSchema.omit({ok:true}) +export type SystemDatabaseWarnings=z.output type Options={signal?:AbortSignal} const signal=(options?:Options)=>options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(600_000)]):AbortSignal.timeout(600_000) function parseFilename(value:string|null):string{ @@ -21,12 +23,17 @@ async function confirmDump(blob:Blob):Promise{ const head=new Uint8Array(await blob.slice(0,magic.length).arrayBuffer()) if(!magic.every((value,index)=>head[index]===value))throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)") } -export async function downloadSystemDatabaseBackup(baseUrl:string,options?:Options):Promise<{blob:Blob;filename:string}>{ +export async function downloadSystemDatabaseBackup(baseUrl:string,options?:Options):Promise<{blob:Blob;filename:string;warnings:SystemDatabaseWarnings}>{ const activeSignal=signal(options) const response=await requestBlob(baseUrl,"/api/system/database/backup",{signal:activeSignal}) const blob=await response.blob() await confirmDump(blob);activeSignal.throwIfAborted() - return {blob,filename:parseFilename(response.headers.get("Content-Disposition"))} + const rawWarnings=response.headers.get("X-System-Database-Outcome") + let warnings:SystemDatabaseWarnings={} + if(rawWarnings){ + try{warnings=warningSchema.parse(JSON.parse(decodeURIComponent(rawWarnings)))}catch{warnings={eventError:"Бэкап подготовлен, но дополнительные результаты не подтверждены"}} + } + return {blob,filename:parseFilename(response.headers.get("Content-Disposition")),warnings} } export async function restoreSystemDatabaseBackup(baseUrl:string,file:File,options?:Options):Promise{ if(file.size>MAX_RESTORE_BYTES)throw new ApiClientError("Файл больше 512 МБ — уменьшите бэкап или обратитесь к администратору",413)