fix(api): проверять исходные шаблоны и план оптимизации OSPF
This commit is contained in:
1 parent
c88e1400bf
commit
0ee7f02e87
7 files changed
+98
-22
No files matched your search
@@ -24,8 +24,9 @@
|
||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||
"test:backups": "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: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:recursive-routes && npm run test:config-sync && npm run test:ros-file",
|
||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
|
||||
"test": "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",
|
||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
|
||||
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod"
|
||||
import {createOspfRoutes} from "./ospf.js"
|
||||
import type {servers} from "../db/schema.js"
|
||||
const row={id:"*1",interface:"ether1",currentCost:10,optimalCost:20,pingMs:0,dlMbps:0,ulMbps:0,score:0}
|
||||
let plan:unknown=[row],writes=0,enabled=true,exists=true,failWrite=false
|
||||
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(createOspfRoutes({
|
||||
listEnabledServers:async()=>[],readCatalog:async()=>{throw new Error("unused")},
|
||||
getOptimizationServer:async()=>exists?({id:1,name:"Роутер",host:"192.0.2.1",enabled} as typeof servers.$inferSelect):undefined,
|
||||
buildOptimizationPlan:async()=>plan as typeof row[],
|
||||
setTemplateCost:async()=>{writes++;if(failWrite)throw new Error("Частичный сетевой отказ")},
|
||||
}),{prefix:"/api"})
|
||||
const send=(preview=false,body:unknown={pingWeight:60})=>app.inject({method:"POST",url:"/api/servers/1/ospf/optimize"+(preview?"/preview":""),payload:body})
|
||||
const preview=await send(true);assert.equal(preview.statusCode,200);assert.equal(preview.json().changedCount,1);assert.equal(preview.json().pingWeight,60);assert.equal(writes,0)
|
||||
const applied=await send();assert.equal(applied.statusCode,200);assert.equal(applied.json().optimizedCount,1);assert.equal(applied.json().applied.length,1);assert.equal(writes,1)
|
||||
for(const invalid of [null,{},[row,row],[{...row,currentCost:0}],[{...row,optimalCost:65536}],[{...row,id:""}]]){
|
||||
plan=invalid;const before=writes;assert.equal((await send()).statusCode,500);assert.equal(writes,before)
|
||||
}
|
||||
plan=[row];enabled=false;assert.equal((await send()).statusCode,409);assert.equal((await send(true)).statusCode,409)
|
||||
enabled=true;exists=false;assert.equal((await send()).statusCode,404);exists=true
|
||||
const before=writes;assert.equal((await send(false,{pingWeight:101})).statusCode,400);assert.equal(writes,before)
|
||||
failWrite=true;const failed=await send();assert.equal(failed.statusCode,500);assert.ok(failed.json().error.includes("Частичный сетевой отказ"))
|
||||
failWrite=false;plan=[];assert.equal((await send()).json().optimizedCount,0)
|
||||
await app.close();console.log("OSPF optimizer HTTP: invalid before write, readonly preview, disabled/missing/bad weight, network error and known empty; no DB/RouterOS")
|
||||
+16
-19
@@ -11,6 +11,7 @@ import type {
|
||||
} from "../types/server.js"
|
||||
import { parseOspfGateway, parseOspfRouteType, parseOspfCatalogRouteType } from "../services/ospf-route-parse.js"
|
||||
import { z } from "zod"
|
||||
import {readEditableOspfTemplates,validateOspfOptimizationPlan} from "../services/ospf-optimizer-plan.js"
|
||||
import { observedInteger, observedDurationMs, observedBoolean } from "../services/ospf-observed-values.js"
|
||||
import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js"
|
||||
|
||||
@@ -291,9 +292,7 @@ function normalizeSpeedMbps(row: TrafficSampleRow | undefined): { dlMbps: number
|
||||
return { dlMbps: dl, ulMbps: ul }
|
||||
}
|
||||
|
||||
function isRefInterfaceName(name: string): boolean {
|
||||
return /^\(ref\s+\*.+\)$/.test(name.trim())
|
||||
}
|
||||
|
||||
|
||||
function parsePingTimeMs(raw: string | undefined): number | null {
|
||||
if (!raw) return null
|
||||
@@ -350,10 +349,8 @@ type OspfRankedInterface = {
|
||||
}
|
||||
|
||||
async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): Promise<OspfRankedInterface[]> {
|
||||
const { ifaceTemplates, areas, instances, neighbors } = await fetchServerOspf(server)
|
||||
const areaMap = buildAreaMap(areas)
|
||||
const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
|
||||
const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface))
|
||||
const { ifaceTemplates, neighbors } = await fetchServerOspf(server)
|
||||
const editable = readEditableOspfTemplates(ifaceTemplates)
|
||||
const localLatencyMs = await getLatestSnapshotLatencyMs(server.id)
|
||||
const latestTraffic = await latestTrafficByInterface(server.id)
|
||||
|
||||
@@ -576,6 +573,9 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
|
||||
interface OspfCatalogDependencies {
|
||||
listEnabledServers: () => Promise<ServerRow[]>
|
||||
readCatalog: (server: ServerRow) => Promise<OspfCatalogCollections>
|
||||
getOptimizationServer?: (id:number)=>Promise<ServerRow | undefined>
|
||||
buildOptimizationPlan?: (server:ServerRow,weight:number)=>Promise<OspfRankedInterface[]>
|
||||
setTemplateCost?: (server:ServerRow,id:string,cost:number)=>Promise<void>
|
||||
}
|
||||
const catalogDefaults: OspfCatalogDependencies = {
|
||||
listEnabledServers: () => db.select().from(servers).where(eq(servers.enabled, true)),
|
||||
@@ -725,16 +725,14 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog
|
||||
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
|
||||
async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = (await db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, params.id))
|
||||
.limit(1))[0]
|
||||
const server = dependencies.getOptimizationServer ? await dependencies.getOptimizationServer(params.id) : (await db.select().from(servers).where(eq(servers.id,params.id)).limit(1))[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"})
|
||||
|
||||
try {
|
||||
const pingWeight = req.body.pingWeight
|
||||
const ranked = await buildOspfOptimizationPlan(server, pingWeight)
|
||||
const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight))
|
||||
const changed = ranked.filter((r) => r.currentCost !== r.optimalCost)
|
||||
return reply.send({
|
||||
serverId: server.id,
|
||||
@@ -757,22 +755,21 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog
|
||||
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
|
||||
async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = (await db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, params.id))
|
||||
.limit(1))[0]
|
||||
const server = dependencies.getOptimizationServer ? await dependencies.getOptimizationServer(params.id) : (await db.select().from(servers).where(eq(servers.id,params.id)).limit(1))[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"})
|
||||
|
||||
try {
|
||||
const pingWeight = req.body.pingWeight
|
||||
const ranked = await buildOspfOptimizationPlan(server, pingWeight)
|
||||
const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight))
|
||||
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const client=dependencies.setTemplateCost?null:MikrotikClient.fromServer(server)
|
||||
const changed = ranked.filter((r) => r.currentCost !== r.optimalCost)
|
||||
const applied: Array<{ interface: string; from: number; to: number }> = []
|
||||
for (const item of changed) {
|
||||
await client.setOspfInterfaceTemplateCost(item.id, item.optimalCost)
|
||||
if(dependencies.setTemplateCost) await dependencies.setTemplateCost(server,item.id,item.optimalCost)
|
||||
else await client!.setOspfInterfaceTemplateCost(item.id,item.optimalCost)
|
||||
applied.push({ interface: item.interface, from: item.currentCost, to: item.optimalCost })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {readEditableOspfTemplates,validateOspfOptimizationPlan} from "./ospf-optimizer-plan.js"
|
||||
const raw={".id":"*1",interfaces:"ether1",disabled:"false",cost:"10"}
|
||||
assert.deepEqual(readEditableOspfTemplates([raw]),[{id:"*1",interface:"ether1",cost:10}])
|
||||
assert.deepEqual(readEditableOspfTemplates([{...raw,disabled:"true",cost:undefined},{...raw,".id":"*2",interfaces:"*ref",cost:undefined}]),[])
|
||||
assert.deepEqual(readEditableOspfTemplates([]),[])
|
||||
for(const payload of [null,{},[null],[raw,raw],[{...raw,cost:undefined}],[{...raw,cost:"10junk"}],[{...raw,cost:"0"}],[{...raw,disabled:undefined}],[{...raw,interfaces:""}],[raw,{...raw,".id":"*2"}]])assert.throws(()=>readEditableOspfTemplates(payload))
|
||||
const row={id:"*1",interface:"ether1",currentCost:10,optimalCost:20,pingMs:0,dlMbps:0,ulMbps:0,score:0}
|
||||
assert.equal(validateOspfOptimizationPlan([row])[0].pingMs,0)
|
||||
assert.deepEqual(validateOspfOptimizationPlan([]),[])
|
||||
for(const payload of [null,{},[row,row],[{...row,currentCost:0}],[{...row,optimalCost:65536}],[{...row,pingMs:-1}],[{...row,score:NaN}],[row,{...row,id:"*2"}]])assert.throws(()=>validateOspfOptimizationPlan(payload))
|
||||
console.log("OSPF optimizer plan: observed cost/disabled/id, ref/disabled skip, duplicate/invalid refusal, zero metrics")
|
||||
@@ -0,0 +1,28 @@
|
||||
import {z} from "zod"
|
||||
import {observedBoolean,observedInteger} from "./ospf-observed-values.js"
|
||||
const templateSchema=z.array(z.object({".id":z.string().trim().min(1),interfaces:z.string().optional(),cost:z.string().optional(),disabled:z.string().optional()}))
|
||||
export function readEditableOspfTemplates(payload:unknown){
|
||||
const parsed=templateSchema.safeParse(payload)
|
||||
if(!parsed.success || new Set(parsed.data.map(row=>row[".id"])).size!==parsed.data.length)throw new Error("Недостоверный каталог шаблонов OSPF")
|
||||
const names=new Set<string>()
|
||||
return parsed.data.flatMap(row=>{
|
||||
const disabled=observedBoolean(row.disabled)
|
||||
if(disabled===null)throw new Error("Неизвестен disabled шаблона OSPF "+row[".id"])
|
||||
if(disabled)return []
|
||||
const name=row.interfaces?.trim()
|
||||
if(!name)throw new Error("Неизвестен интерфейс шаблона OSPF "+row[".id"])
|
||||
if(name.startsWith("*"))return []
|
||||
const cost=observedInteger(row.cost,1,65535)
|
||||
if(cost===null)throw new Error("Неизвестен или неподдерживаем cost шаблона OSPF "+row[".id"])
|
||||
if(names.has(name))throw new Error("Несколько активных шаблонов OSPF для интерфейса "+name)
|
||||
names.add(name)
|
||||
return [{id:row[".id"],interface:name,cost}]
|
||||
})
|
||||
}
|
||||
const measured=z.number().finite().nonnegative()
|
||||
const rowSchema=z.object({id:z.string().trim().min(1),interface:z.string().trim().min(1),currentCost:z.number().int().min(1).max(65535),optimalCost:z.number().int().min(1).max(65535),score:z.number().finite(),pingMs:measured,dlMbps:measured,ulMbps:measured})
|
||||
export function validateOspfOptimizationPlan(payload:unknown){
|
||||
const parsed=z.array(rowSchema).safeParse(payload)
|
||||
if(!parsed.success || new Set(parsed.data.map(row=>row.id)).size!==parsed.data.length || new Set(parsed.data.map(row=>row.interface)).size!==parsed.data.length)throw new Error("Недостоверный план оптимизации OSPF")
|
||||
return parsed.data
|
||||
}
|
||||
@@ -26,11 +26,19 @@
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.32; подтверждённое OSPF apply и RBAC, далее backend provenance |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.33; backend проверяет cost/план OSPF, далее layout/контекст |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 10.33 — достоверность cost и плана OSPF (2026-09-30)
|
||||
|
||||
- Оптимизатор использует отдельный observed selector шаблонов: disabled/id/interface/currentCost должны быть достоверны. Cost не подставляется как 10, disabled не превращается в false. Missing/unsupported cost (включая 0 вне текущей write-policy 1–65535), дубликаты id и несколько активных шаблонов одного интерфейса отклоняются. Известные disabled и reference-интерфейсы сохраняют прежнее исключение из оптимизации.
|
||||
- Весь ranked plan валидируется до первой записи; переполнение optimalCost, плохие метрики/идентификаторы не приводят к частичному apply. Отключённое устройство возвращает 409, неизвестное 404. Формула score, выбор проб и порядок сетевых изменений прежние.
|
||||
- DI оптимизатора позволяет HTTP injection без БД/RouterOS. Проверены malformed plans до write, preview без write, success/empty, disabled/missing/bad weight и сетевой отказ. Unit selector проверяет unknown/default/duplicates и observed zero метрик.
|
||||
- Проверки: test:ospf-optimizer, общий test:live-maps (включая OSPF/BGP/GRE/VXLAN compatibility), backend build прошли. Lint без ошибок; два прежних предупреждения unused and/measurePingByInterface остаются.
|
||||
- Далее 10.34: адаптивность и keyboard/screen оптимизатора; затем URL/source context и различение observed/model метрик. Snapshot/traffic fallback и clamp значений в модели ещё сохранены и требуют отдельного отображения; весь раздел ещё не принят. Реальная RouterOS версия/DB не проверена. Последняя квота: 82% окна, 44% недели использовано. Локальный коммит без push.
|
||||
|
||||
## Блок 10.32 — подтверждение применения OSPF и права (2026-09-30)
|
||||
|
||||
- Apply валидирует существующий ospfOptimizeSchema, serverId и вес запроса. Успех по count/name без списка applied больше не принимается. Последний receipt привязан к контексту и времени получения браузером; отсутствие применения отображается «—».
|
||||
|
||||
@@ -180,3 +180,7 @@ Live таймерный успех убран; demo симуляция явно
|
||||
## 10.32. Подтверждённое OSPF применение и RBAC
|
||||
|
||||
Strict receipt/server/weight, ref lock, readonly/disabled контекст и reread любого результата. 16 E2E, readonly повтор, TS/lint, обе production сборки и auth tests прошли. Specific OSPF rights согласованы: preview read, apply write; signed JWT со stub handlers без RouterOS/DB. Далее backend provenance: legacy cost/metric defaults ещё не приняты как observed данные.
|
||||
|
||||
## 10.33. Backend cost и валидация плана
|
||||
|
||||
Observed selector вместо default cost/disabled; неоднозначные шаблоны отклоняются до write. Полный план валидируется, disabled сервер 409. DI HTTP/unit без RouterOS/DB, live-maps regression/backend build прошли; lint два прежних unused warning. Ранжирование прежнее, model fallback/clamp ещё требуют отдельного отображения. Далее layout/контекст.
|
||||
Reference in new issue
Block a user