fix(ospf): подтвердить права и результат оптимизации на RouterOS
This commit is contained in:
1 parent
3bfd4ae6cf
commit
36c6a21ca5
19 files changed
+1069
-15
No files matched your search
@@ -3,14 +3,16 @@ import Fastify from "fastify"
|
||||
import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod"
|
||||
import {createOspfRoutes} from "./ospf.js"
|
||||
import type {servers} from "../db/schema.js"
|
||||
import {MikrotikError} from "../services/mikrotik.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
|
||||
let failAt:number|null=null,writeError:Error=new Error("Ответ записи потерян")
|
||||
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("Частичный сетевой отказ")},
|
||||
setTemplateCost:async()=>{writes++;if(failWrite)throw new Error("Частичный сетевой отказ");if(writes===failAt)throw writeError},
|
||||
}),{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)
|
||||
@@ -26,6 +28,15 @@ for(const invalid of [null,{},[row,row],[{...row,currentCost:0}],[{...row,optima
|
||||
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=true;const failed=await send();assert.equal(failed.statusCode,502);assert.ok(failed.json().error.includes("Частичный сетевой отказ"));assert.equal(failed.json().state,"unknown");assert.equal(failed.json().confirmedWrites,0);assert.equal(failed.json().needsReview,true)
|
||||
failWrite=false
|
||||
const second={...row,id:"*2",interface:"gre-test",currentCost:30,optimalCost:40}
|
||||
plan=[row,second];failAt=writes+2;writeError=new MikrotikError(403,"/routing/ospf/interface-template/*2","rejected")
|
||||
const partial=await send();assert.equal(partial.statusCode,502);assert.equal(partial.json().state,"partial");assert.equal(partial.json().confirmedWrites,1);assert.deepEqual(partial.json().applied,[{interface:row.interface,from:10,to:20}]);assert.equal(partial.json().failedInterface,second.interface);assert.equal(writes,failAt)
|
||||
failAt=writes+2;writeError=new Error("Ответ второго PATCH потерян")
|
||||
const uncertain=await send();assert.equal(uncertain.json().state,"unknown");assert.equal(uncertain.json().confirmedWrites,1);assert.equal(uncertain.json().applied.length,1);assert.equal(writes,failAt)
|
||||
failAt=writes+1;writeError=new MikrotikError(401,"/routing/ospf/interface-template/*1","unauthorized")
|
||||
const rejected=await send();assert.equal(rejected.json().state,"rejected");assert.equal(rejected.json().confirmedWrites,0);assert.equal(rejected.json().needsReview,false);assert.equal(writes,failAt)
|
||||
failAt=null
|
||||
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")
|
||||
@@ -2,7 +2,7 @@ import { and, desc, eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
|
||||
import type {
|
||||
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
|
||||
@@ -742,17 +742,20 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"})
|
||||
|
||||
const applied: Array<{ interface: string; from: number; to: number }> = []
|
||||
let pending: OspfRankedInterface | undefined
|
||||
try {
|
||||
const pingWeight = req.body.pingWeight
|
||||
const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight))
|
||||
|
||||
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) {
|
||||
pending = item
|
||||
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 })
|
||||
pending = undefined
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
@@ -764,6 +767,19 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog
|
||||
interfaces: ranked,
|
||||
})
|
||||
} catch (err) {
|
||||
// A lost acknowledgement does not prove that RouterOS rejected the write.
|
||||
if (pending) {
|
||||
const rejected = err instanceof MikrotikError && err.statusCode >= 400 && err.statusCode < 500
|
||||
return reply.status(502).send({
|
||||
error: `API не подтвердил завершение оптимизации OSPF. Обновите данные перед повторной попыткой. ${String(err)}`,
|
||||
serverId: server.id,
|
||||
state: rejected ? (applied.length ? "partial" : "rejected") : "unknown",
|
||||
confirmedWrites: applied.length,
|
||||
applied,
|
||||
failedInterface: pending.interface,
|
||||
needsReview: !rejected || applied.length > 0,
|
||||
})
|
||||
}
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import https from "node:https"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import type { Server } from "../db/schema.js"
|
||||
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
||||
import { readOspfEffectiveDisabled } from "./ospf-effective-disabled.js"
|
||||
import type {
|
||||
RosIdentity, RosInterface, RosIpAddress, RosResource,
|
||||
RosBgpSession,
|
||||
@@ -639,15 +640,18 @@ export class MikrotikClient {
|
||||
}
|
||||
|
||||
async getOspfAreas(): Promise<RosOspfArea[]> {
|
||||
return this.get<RosOspfArea[]>("/routing/ospf/area")
|
||||
return readOspfEffectiveDisabled<RosOspfArea>(await this.get<unknown>("/routing/ospf/area"), id =>
|
||||
this.post("/routing/ospf/area/get", { ".id": id, "value-name": "disabled" }))
|
||||
}
|
||||
|
||||
async getOspfInterfaceTemplates(): Promise<RosOspfInterfaceTemplate[]> {
|
||||
return this.get<RosOspfInterfaceTemplate[]>("/routing/ospf/interface-template")
|
||||
return readOspfEffectiveDisabled<RosOspfInterfaceTemplate>(await this.get<unknown>("/routing/ospf/interface-template"), id =>
|
||||
this.post("/routing/ospf/interface-template/get", { ".id": id, "value-name": "disabled" }))
|
||||
}
|
||||
|
||||
async getOspfInstances(): Promise<RosOspfInstance[]> {
|
||||
return this.get<RosOspfInstance[]>("/routing/ospf/instance")
|
||||
return readOspfEffectiveDisabled<RosOspfInstance>(await this.get<unknown>("/routing/ospf/instance"), id =>
|
||||
this.post("/routing/ospf/instance/get", { ".id": id, "value-name": "disabled" }))
|
||||
}
|
||||
|
||||
async getBfdSessions(): Promise<RosBfdSession[]> {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readOspfEffectiveDisabled } from "./ospf-effective-disabled.js"
|
||||
let calls: string[] = []
|
||||
const input = [{ ".id": "*1", disabled: "true" }, { ".id": "*2" }, { ".id": "*3" }]
|
||||
const rows = await readOspfEffectiveDisabled(input, async id => { calls.push(id); return { ret: id === "*2" ? "false" : "true" } })
|
||||
assert.deepEqual(rows.map(row => row.disabled), ["true", "false", "true"])
|
||||
assert.deepEqual(calls.sort(), ["*2", "*3"]); assert.equal("disabled" in input[1]!, false)
|
||||
for (const payload of [null, {}, [null], [{ ".id": "" }], [{ ".id": "*1" }, { ".id": "*1" }]]) {
|
||||
calls = []; await assert.rejects(readOspfEffectiveDisabled(payload, async id => { calls.push(id); return { ret: "false" } })); assert.equal(calls.length, 0)
|
||||
}
|
||||
for (const value of [null, [], {}, { ret: "" }, { ret: "maybe" }, { ret: false }]) await assert.rejects(readOspfEffectiveDisabled([{ ".id": "*1" }], async () => value))
|
||||
await assert.rejects(readOspfEffectiveDisabled([{ ".id": "*1" }], async () => { throw Error("RouterOS недоступен") }), /недоступен/)
|
||||
let active = 0, maximum = 0
|
||||
await readOspfEffectiveDisabled(Array.from({ length: 20 }, (_, i) => ({ ".id": "*" + i })), async () => {
|
||||
maximum = Math.max(maximum, ++active); await new Promise(resolve => setTimeout(resolve, 1)); active--; return { ret: "false" }
|
||||
})
|
||||
assert.equal(maximum, 4)
|
||||
console.log("OSPF omitted disabled: effective native value, no guessed false, malformed/error refusal, bounded reads")
|
||||
@@ -0,0 +1,20 @@
|
||||
import { observedBoolean } from "./ospf-observed-values.js"
|
||||
|
||||
/** RouterOS can omit an unset disabled property while /get returns its effective value. */
|
||||
export async function readOspfEffectiveDisabled<T extends { ".id": string; disabled?: string }>(payload: unknown, readDisabled: (id: string) => Promise<unknown>): Promise<T[]> {
|
||||
if (!Array.isArray(payload) || payload.some(row => !row || typeof row !== "object" || Array.isArray(row) || typeof row[".id"] !== "string" || !row[".id"].trim())) throw new Error("Недостоверный каталог шаблонов OSPF")
|
||||
if (new Set(payload.map(row => row[".id"])).size !== payload.length) throw new Error("Повторяющиеся ID шаблонов OSPF")
|
||||
const rows: T[] = payload.map(row => ({ ...row }))
|
||||
const missing = rows.filter(row => row.disabled === undefined)
|
||||
let cursor = 0
|
||||
await Promise.all(Array.from({ length: Math.min(4, missing.length) }, async () => {
|
||||
while (cursor < missing.length) {
|
||||
const row = missing[cursor++]!
|
||||
const response = await readDisabled(row[".id"])
|
||||
const value = response && typeof response === "object" && "ret" in response ? response.ret : undefined
|
||||
if (typeof value !== "string" || observedBoolean(value) === null) throw new Error("Не подтверждён disabled шаблона OSPF " + row[".id"])
|
||||
row.disabled = observedBoolean(value) ? "true" : "false"
|
||||
}
|
||||
}))
|
||||
return rows
|
||||
}
|
||||
Reference in new issue
Block a user