fix(routes): подтвердить применение и восстановление рекурсивных маршрутов
This commit is contained in:
1 parent
36c6a21ca5
commit
5b3133ce73
19 files changed
+659
-28
No files matched your search
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict"
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
import { executeRecursiveApply, RecursiveApplyError } from "./recursive-apply.js"
|
||||
const owned = { ".id": "*old", static: "true", "dst-address": "198.18.88.0/24", gateway: "192.0.2.1", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", "check-gateway": "none", disabled: "false", comment: "MikrotikManager:recursive test" }
|
||||
let rows: unknown = [owned, { ".id": "*foreign", comment: "Keep default route" }], events: string[] = [], puts = 0
|
||||
let failAt = 0, rejection: Error = new Error("ACK потерян"), badAck = false, failDelete = false, failPersist = false
|
||||
const client: Pick<MikrotikClient,"get"|"put"|"delete"> = {
|
||||
get: async <T>() => rows as T,
|
||||
put: async <T>() => { puts++; events.push("put:"+puts); if(puts===failAt)throw rejection; return (badAck?{}:{".id":"*new"+puts}) as T },
|
||||
delete: async path => { events.push("delete:"+path); if(failDelete)throw new MikrotikError(403,path,"rejected") },
|
||||
}
|
||||
const persist=async()=>{events.push("persist");if(failPersist)throw Error("История недоступна")}
|
||||
const reset=()=>{events=[];puts=0;failAt=0;badAck=false;failDelete=false;failPersist=false;rows=[owned,{".id":"*foreign",comment:"Keep default route"}]}
|
||||
const payloads=[{"dst-address":"198.18.88.1/32"},{"dst-address":"198.18.88.2/32"}]
|
||||
reset();assert.deepEqual(await executeRecursiveApply(client,payloads,persist),{pushed:2,deleted:1});assert.deepEqual(events,["put:1","put:2","delete:/ip/route/*old","persist"])
|
||||
for(const invalid of [{},[owned,owned],[{...owned,static:undefined}],[{...owned,blackhole:"true"}]]){reset();rows=invalid;await assert.rejects(executeRecursiveApply(client,payloads,persist));assert.deepEqual(events,[])}
|
||||
async function expectState(state:string,confirmed:number){await assert.rejects(executeRecursiveApply(client,payloads,persist),(e:unknown)=>{assert(e instanceof RecursiveApplyError);assert.equal(e.receipt.state,state);assert.equal(e.receipt.confirmedWrites,confirmed);assert.equal(e.receipt.metadataRecorded,false);return true})}
|
||||
reset();failAt=1;rejection=new MikrotikError(401,"/ip/route","denied");await expectState("rejected",0);assert.deepEqual(events,["put:1"])
|
||||
reset();failAt=2;rejection=new MikrotikError(403,"/ip/route","denied");await expectState("partial",1);assert.deepEqual(events,["put:1","put:2"])
|
||||
reset();failAt=2;rejection=new Error("ACK второго PUT потерян");await expectState("unknown",1);assert.deepEqual(events,["put:1","put:2"])
|
||||
reset();badAck=true;await expectState("unknown",0);assert.deepEqual(events,["put:1"])
|
||||
reset();failDelete=true;await expectState("partial",2);assert.deepEqual(events,["put:1","put:2","delete:/ip/route/*old"])
|
||||
reset();failPersist=true;await expectState("applied",3)
|
||||
reset();rows=[];assert.deepEqual(await executeRecursiveApply(client,[],persist),{pushed:0,deleted:0});assert.deepEqual(events,["persist"])
|
||||
console.log("Recursive apply: strict ownership before PUT, foreign retention, partial/unknown/rejected/applied, no retry/delete after rejection")
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
import { parseRecursiveCatalog } from "./recursive-route-catalog.js"
|
||||
|
||||
export class RecursiveApplyError extends Error {
|
||||
constructor(message: string, public readonly receipt: {
|
||||
state: "partial" | "unknown" | "rejected" | "applied"
|
||||
confirmedWrites: number
|
||||
createdIds: string[]
|
||||
deletedIds: string[]
|
||||
needsReview: boolean
|
||||
metadataRecorded: boolean
|
||||
}) { super(message); this.name = "RecursiveApplyError" }
|
||||
}
|
||||
|
||||
export async function executeRecursiveApply(
|
||||
client: Pick<MikrotikClient, "get" | "put" | "delete">,
|
||||
payloads: Record<string, string>[],
|
||||
persist: () => Promise<void>,
|
||||
) {
|
||||
// The editor replaces every owned row: reject unsupported ownership before any write.
|
||||
const existing = parseRecursiveCatalog(await client.get<unknown>("/ip/route"))
|
||||
const createdIds: string[] = [], deletedIds: string[] = []
|
||||
let nativeComplete = false
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const result = await client.put<unknown>("/ip/route", payload)
|
||||
if (!result || typeof result !== "object" || !(".id" in result) || typeof result[".id"] !== "string" || !result[".id"].trim()) throw new Error("RouterOS не подтвердил ID созданного маршрута")
|
||||
createdIds.push(result[".id"])
|
||||
}
|
||||
for (const route of existing) {
|
||||
await client.delete("/ip/route/" + encodeURIComponent(route.id))
|
||||
deletedIds.push(route.id)
|
||||
}
|
||||
nativeComplete = true
|
||||
await persist()
|
||||
return { pushed: createdIds.length, deleted: deletedIds.length }
|
||||
} catch (error) {
|
||||
const rejected = error instanceof MikrotikError && error.statusCode >= 400 && error.statusCode < 500
|
||||
const confirmedWrites = createdIds.length + deletedIds.length
|
||||
const state = nativeComplete ? "applied" : rejected ? (confirmedWrites ? "partial" : "rejected") : "unknown"
|
||||
throw new RecursiveApplyError(`Подтверждено записей RouterOS: ${confirmedWrites}. Обновите каталог перед повторной попыткой. ${String(error)}`, {
|
||||
state, confirmedWrites, createdIds, deletedIds, needsReview: state !== "rejected", metadataRecorded: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { z } from "zod"
|
||||
import { isIP } from "node:net"
|
||||
const destination = z.string().trim().refine(value => {
|
||||
const [address, prefix, extra] = value.split("/")
|
||||
return extra === undefined && isIP(address ?? "") === 4 && (prefix === undefined || /^\d+$/.test(prefix) && Number(prefix) <= 32)
|
||||
}, "Нужен IPv4 адрес или префикс")
|
||||
const gateway = z.string().trim().refine(value => {
|
||||
const [address, iface, extra] = value.split("%")
|
||||
return extra === undefined && isIP(address ?? "") === 4 && (iface === undefined || Boolean(iface) && !/\s/.test(iface))
|
||||
}, "Нужен IPv4 шлюз с необязательным интерфейсом")
|
||||
const serverId = z.union([z.number().int().positive(), z.string().trim().regex(/^\d+$/)])
|
||||
.transform(Number).refine(value => Number.isSafeInteger(value) && value > 0)
|
||||
export const recursiveTargetSchema = z.object({ serverId })
|
||||
export const recursiveInputRowSchema = z.object({
|
||||
id: z.string().optional().default(""),
|
||||
dstAddress: z.string().trim().min(1), gateway: z.string().trim().min(1),
|
||||
dstAddress: destination, gateway,
|
||||
distance: z.number().int().min(0).max(255),
|
||||
scope: z.number().int().min(0).max(255).nullable(),
|
||||
targetScope: z.number().int().min(0).max(255).nullable(),
|
||||
routingTable: z.string().trim().min(1), checkGateway: z.string(),
|
||||
routingTable: z.string().trim().min(1), checkGateway: z.enum(["none", "arp", "ping", "bfd"]),
|
||||
country: z.string(), comment: z.string(), disabled: z.boolean(),
|
||||
})
|
||||
export const recursiveInputRowsSchema = z.array(recursiveInputRowSchema)
|
||||
|
||||
Reference in new issue
Block a user