fix(evolution): проверять каталог и сохранять неизвестные счётчики
This commit is contained in:
1 parent
9db1471b2c
commit
7e3cb754b0
16 files changed
+140
-82
No files matched your search
@@ -28,7 +28,7 @@
|
||||
"test:backups": "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: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:probes && npm run test:exec && 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:scheduler && 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 && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution",
|
||||
"test:evolution": "tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
|
||||
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
|
||||
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
|
||||
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
|
||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { normalizeEvoBgpBaseUrl as normalizeBaseUrl, normalizeEvoBgpApiKey as normalizeApiKey } from "@mmapp/contracts/evobgp"
|
||||
import { parseEvoCatalogRaw, aggregateCommunityRouteCounts } from "../services/evobgp-catalog.js"
|
||||
import { createEvoBgpSettingsRoutes } from "./evobgp-settings.js"
|
||||
import { db } from "../db/index.js"
|
||||
import { evobgpSettings, filterRules } from "../db/schema.js"
|
||||
@@ -14,67 +15,6 @@ async function ensureEvobgpRow() {
|
||||
return row!
|
||||
}
|
||||
|
||||
interface EvoCatalogRaw {
|
||||
modules: { items: Array<{ id: string; name: string; type: string }> }
|
||||
domains: {
|
||||
items: Array<{
|
||||
module_id: string
|
||||
entry: { id: string; fqdn: string; community_id?: string | null }
|
||||
}>
|
||||
}
|
||||
asns: {
|
||||
items: Array<{
|
||||
module_id: string
|
||||
entry: {
|
||||
id: string
|
||||
asn: number
|
||||
community_id?: string | null
|
||||
asn_name?: string | null
|
||||
prefix_count?: number | null
|
||||
asn_resolved_at?: string | null
|
||||
}
|
||||
}>
|
||||
}
|
||||
ip_ranges: {
|
||||
items: Array<{
|
||||
module_id: string
|
||||
entry: { id: string; prefix: string; community_id: string }
|
||||
}>
|
||||
}
|
||||
communities: {
|
||||
items: Array<{ id: string; community?: string; title?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* По UUID community в каталоге EvoBGP: сумма «маршрутов» —
|
||||
* 1 на домен, 1 на CIDR в ip_ranges, prefix_count на ASN.
|
||||
*/
|
||||
function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map<string, number> {
|
||||
const routesPerComm = new Map<string, number>()
|
||||
function bump(commId: string, routeAdd: number) {
|
||||
routesPerComm.set(commId, (routesPerComm.get(commId) ?? 0) + routeAdd)
|
||||
}
|
||||
|
||||
for (const rowItem of catalog.domains?.items ?? []) {
|
||||
const cid = rowItem.entry.community_id
|
||||
if (cid) bump(cid, 1)
|
||||
}
|
||||
for (const rowItem of catalog.ip_ranges?.items ?? []) {
|
||||
const cid = rowItem.entry.community_id
|
||||
if (cid) bump(cid, 1)
|
||||
}
|
||||
for (const rowItem of catalog.asns?.items ?? []) {
|
||||
const cid = rowItem.entry.community_id
|
||||
if (!cid) continue
|
||||
const pc = rowItem.entry.prefix_count
|
||||
const n = typeof pc === "number" && Number.isFinite(pc) && pc >= 0 ? Math.trunc(pc) : 0
|
||||
bump(cid, n)
|
||||
}
|
||||
|
||||
return routesPerComm
|
||||
}
|
||||
|
||||
/**
|
||||
* Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN).
|
||||
* Ключ — значение `filter_rules.community` после trim (как в EvoBGP `community`).
|
||||
@@ -168,10 +108,10 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
|
||||
}
|
||||
try {
|
||||
const catalog = await fetchEvoJson<EvoCatalogRaw>(cred.root, "/v1/router-lists/catalog", cred.apiKey)
|
||||
const modules = catalog.modules?.items ?? []
|
||||
const catalog = parseEvoCatalogRaw(await fetchEvoJson<unknown>(cred.root, "/v1/router-lists/catalog", cred.apiKey))
|
||||
const modules = catalog.modules.items
|
||||
const modMap = new Map(modules.map((m) => [m.id, m]))
|
||||
const commItems = catalog.communities?.items ?? []
|
||||
const commItems = catalog.communities.items
|
||||
const commLabel = new Map<string, string>()
|
||||
for (const c of commItems) {
|
||||
const label = (c.community && c.title)
|
||||
@@ -183,7 +123,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const fmtTime = new Date().toLocaleString("ru-RU")
|
||||
const fetchedAt = new Date().toISOString()
|
||||
|
||||
const domains = (catalog.domains?.items ?? []).map((rowItem) => {
|
||||
const domains = catalog.domains.items.map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
@@ -198,7 +138,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
})
|
||||
|
||||
const ipRanges = (catalog.ip_ranges?.items ?? []).map((rowItem) => {
|
||||
const ipRanges = catalog.ip_ranges.items.map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
@@ -213,13 +153,11 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
})
|
||||
|
||||
const asns = (catalog.asns?.items ?? []).map((rowItem) => {
|
||||
const asns = catalog.asns.items.map((rowItem) => {
|
||||
const cid = rowItem.entry.community_id
|
||||
const e = rowItem.entry
|
||||
const name = (e.asn_name ?? "").trim()
|
||||
const pc = e.prefix_count
|
||||
const prefixes =
|
||||
typeof pc === "number" && Number.isFinite(pc) && pc >= 0 ? Math.trunc(pc) : 0
|
||||
const prefixes = e.prefix_count ?? null
|
||||
let updated = fmtTime
|
||||
const rawResolved = (e.asn_resolved_at ?? "").trim()
|
||||
if (rawResolved) {
|
||||
@@ -251,7 +189,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
type: "custom" as const,
|
||||
filterIds: [] as string[],
|
||||
serverCount: valueStr ? (serversByCommunityStr.get(valueStr) ?? 0) : 0,
|
||||
prefixCount: routeCountsByCommUuid.get(c.id) ?? 0,
|
||||
prefixCount: routeCountsByCommUuid.has(c.id) ? routeCountsByCommUuid.get(c.id)! : 0,
|
||||
action: "permit" as const,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { parseEvoCatalogRaw, aggregateCommunityRouteCounts } from "./evobgp-catalog.js"
|
||||
const empty = { modules:{items:[]}, domains:{items:[]}, ip_ranges:{items:[]}, asns:{items:[]}, communities:{items:[]} }
|
||||
const asn = {module_id:"m",entry:{id:"a",asn:64512,community_id:"c"}}
|
||||
|
||||
test("upstream catalog requires every collection; known empty stays empty", () => {
|
||||
assert.deepEqual(parseEvoCatalogRaw(empty),empty)
|
||||
for(const section of Object.keys(empty)) {
|
||||
for(const bad of [undefined,null,{}, {items:null}]) assert.throws(()=>parseEvoCatalogRaw({...empty,[section]:bad}), /не подтверждён/)
|
||||
}
|
||||
for(const raw of [null,{},[],{error:"upstream unavailable"}]) assert.throws(()=>parseEvoCatalogRaw(raw), /не подтверждён/)
|
||||
})
|
||||
test("row identities, ASN range and integral counts are checked before mapping", () => {
|
||||
for(const entry of [{...asn.entry,id:""},{...asn.entry,asn:0},{...asn.entry,asn:4294967296},...[-1,1.5,Number.MAX_SAFE_INTEGER+1,"5"].map(prefix_count=>({...asn.entry,prefix_count}))]) assert.throws(()=>parseEvoCatalogRaw({...empty,asns:{items:[{...asn,entry}]}}), /не подтверждён/)
|
||||
assert.throws(()=>parseEvoCatalogRaw({...empty,asns:{items:[asn,asn]}}))
|
||||
assert.throws(()=>parseEvoCatalogRaw({...empty,domains:{items:[{module_id:"m",entry:{id:"d"}}]}}))
|
||||
assert.throws(()=>parseEvoCatalogRaw({...empty,modules:{items:[{id:"m",name:"module"}]}}))
|
||||
assert.equal(parseEvoCatalogRaw({...empty,asns:{items:[asn,{...asn,module_id:"other"}]}}).asns.items.length,2)
|
||||
})
|
||||
test("unknown ASN count propagates to its community, known zero stays zero", () => {
|
||||
for(const prefix_count of [undefined,null]) {
|
||||
const raw={...empty,domains:{items:[{module_id:"m",entry:{id:"d",fqdn:"example.test",community_id:"c"}}]},asns:{items:[{...asn,entry:{...asn.entry,prefix_count}},{...asn,entry:{...asn.entry,id:"zero",community_id:"z",prefix_count:0}}]}}
|
||||
const totals=aggregateCommunityRouteCounts(parseEvoCatalogRaw(raw))
|
||||
assert.equal(totals.get("c"),null);assert.equal(totals.get("z"),0)
|
||||
}
|
||||
})
|
||||
test("known sums include domains, CIDR and prefixes; safe integer overflow fails", () => {
|
||||
const raw={...empty,domains:{items:[{module_id:"m",entry:{id:"d",fqdn:"example.test",community_id:"c"}}]},ip_ranges:{items:[{module_id:"m",entry:{id:"r",prefix:"192.0.2.0/24",community_id:"c"}}]},asns:{items:[{...asn,entry:{...asn.entry,prefix_count:5}}]}}
|
||||
assert.equal(aggregateCommunityRouteCounts(parseEvoCatalogRaw(raw)).get("c"),7)
|
||||
assert.throws(()=>aggregateCommunityRouteCounts(parseEvoCatalogRaw({...raw,asns:{items:[{...asn,entry:{...asn.entry,prefix_count:Number.MAX_SAFE_INTEGER}}]}})), /вне допустимого диапазона/)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const id = z.string().trim().min(1)
|
||||
const count = z.number().int().nonnegative().safe()
|
||||
const communityId = id.nullish()
|
||||
function items<T extends z.ZodType>(row: T, key: (value: z.output<T>) => string) {
|
||||
return z.object({ items: z.array(row).refine(rows => new Set(rows.map(key)).size === rows.length) })
|
||||
}
|
||||
const schema = z.object({
|
||||
modules: items(z.object({ id, name: z.string(), type: z.string() }), row => row.id),
|
||||
domains: items(z.object({ module_id: id, entry: z.object({ id, fqdn: id, community_id: communityId }) }), row => `${row.module_id}:${row.entry.id}`),
|
||||
ip_ranges: items(z.object({ module_id: id, entry: z.object({ id, prefix: id, community_id: id }) }), row => `${row.module_id}:${row.entry.id}`),
|
||||
asns: items(z.object({ module_id: id, entry: z.object({
|
||||
id, asn: z.number().int().min(1).max(4294967295), community_id: communityId,
|
||||
asn_name: z.string().nullish(), prefix_count: count.nullish(),
|
||||
asn_resolved_at: z.string().nullish(),
|
||||
}) }), row => `${row.module_id}:${row.entry.id}`),
|
||||
communities: items(z.object({ id, community: z.string().optional(), title: z.string().optional() }), row => row.id),
|
||||
})
|
||||
export type EvoCatalogRaw = z.output<typeof schema>
|
||||
|
||||
export function parseEvoCatalogRaw(raw: unknown): EvoCatalogRaw {
|
||||
const result = schema.safeParse(raw)
|
||||
if (!result.success) throw new Error("Каталог Evolution не подтверждён: получен некорректный ответ интеграции")
|
||||
return result.data
|
||||
}
|
||||
|
||||
/** Домен/CIDR дают один маршрут; неизвестный prefix_count ASN делает итог community неизвестным. */
|
||||
export function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map<string, number | null> {
|
||||
const totals = new Map<string, number | null>()
|
||||
function add(community: string, count: number | null) {
|
||||
const previous = totals.has(community) ? totals.get(community)! : 0
|
||||
if (previous === null || count === null) { totals.set(community, null); return }
|
||||
const total = previous + count
|
||||
if (!Number.isSafeInteger(total)) throw new Error("Каталог Evolution не подтверждён: итог маршрутов вне допустимого диапазона")
|
||||
totals.set(community, total)
|
||||
}
|
||||
for (const { entry } of catalog.domains.items) if (entry.community_id) add(entry.community_id, 1)
|
||||
for (const { entry } of catalog.ip_ranges.items) add(entry.community_id, 1)
|
||||
for (const { entry } of catalog.asns.items) if (entry.community_id) add(entry.community_id, entry.prefix_count ?? null)
|
||||
return totals
|
||||
}
|
||||
Reference in new issue
Block a user