fix(api): показывать ошибки опроса старых OSPF и BFD API

This commit is contained in:
Denozordec committed 2026-10-14 00:31:50 +07:00
1 parent eaf724d6cc
commit 5ac5a25dfc
17 files changed
+688 -96

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
"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:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts",
"test:live-maps": "tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.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:live-maps": "tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.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 && tsx src/routes/ospf-legacy.test.ts",
"test": "node scripts/test.mjs",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/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",
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {createServer} from 'node:http'
import {randomBytes} from 'node:crypto'
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own215.invalid';process.env.AUTH_PORTAL_URL='https://own215.invalid'
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
let phase='populated',app,nativeCalls=0,nativeWrites=0,disabledCalls=0;const cases=[]
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');if(phase==='failed'||(phase==='partial'&&label==='second')){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own215 fixture denied'}));return}const rows=phase==='populated'&&req.url==='/rest/routing/ospf/instance'?[{'.id':'*1',name:'own215-ospf',disabled:'false'}]:phase==='populated'&&req.url==='/rest/routing/bfd/session'?[{'.id':'*1','local-address':'192.0.2.1','remote-address':'192.0.2.2',state:'up'}]:[];res.end(JSON.stringify(rows))})
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
const baseline=process.env.MMAPP_BASELINE==='true';const report={date:'2026-10-14',block:'12.5.215',baseline,database,cases,scope:'Compiled production routes/auth, isolated PostgreSQL and RouterOS HTTP fixture; real CHR unchanged',passed:false}
try{
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own215-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own215-reader',apps:['mm'],permissions:['mm:network:read','mm:dashboard:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token}
for(phase of ['populated','empty','partial','failed','disabled']){
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false')
for(const endpoint of ['ospf/neighbors','ospf/interfaces','ospf/instances','ospf/all','bfd/sessions']){
const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/'+endpoint,headers}),body=r.json(),hasFailure=['partial','failed'].includes(phase)
assert.equal(r.statusCode,!baseline&&hasFailure?503:200)
if(!baseline&&hasFailure){const expected=phase==='partial'?[ids[1]]:ids.slice(0,2);assert.deepEqual(body.failures.map(f=>f.serverId),expected);assert(body.failures.every(f=>f.error.includes('401')));assert.match(body.error,/OSPF\/BFD/)}
else if(endpoint==='ospf/all'){assert.deepEqual(Object.keys(body).sort(),['bfdSessions','instances','interfaces','neighbors','routes']);assert.equal(body.instances.length,phase==='populated'?2:0);assert.equal(body.bfdSessions.length,phase==='populated'?2:0)}
else{assert(Array.isArray(body));assert.equal(body.length,phase==='populated'&&['ospf/instances','bfd/sessions'].includes(endpoint)?2:0)}
if(phase==='disabled')assert.equal(nativeCalls,before)
cases.push({phase,endpoint,status:r.statusCode,failures:body.failures?.length??0,nativeCalls:nativeCalls-before})
}
}
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/ospf215-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/ospf215-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import Fastify from 'fastify'
import {validatorCompiler,serializerCompiler} from '@fastify/type-provider-zod'
import {createOspfRoutes} from './ospf.js'
import {emptyOspfCatalog} from '../services/ospf-catalog-poll.js'
import {MikrotikClient} from '../services/mikrotik.js'
import {servers} from '../db/schema.js'
const devices=[{id:1,name:'Первый'},{id:2,name:'Второй'}] as typeof servers.$inferSelect[]
const original=MikrotikClient.fromServer
let failedIds:number[]=[],reads=0,enabled=true,populated=false
MikrotikClient.fromServer=server=>{
const read=async()=>{reads++;if(failedIds.includes(server.id))throw Error('Недоступен '+server.name);return []}
return {getOspfNeighbors:read,getOspfAreas:read,getOspfInterfaceTemplates:read,getOspfInstances:async()=>{await read();return populated?[{'.id':'*1',name:'ospf-own'}]:[]},getBfdSessions:async()=>{await read();return populated?[{'.id':'*1','local-address':'192.0.2.1','remote-address':'192.0.2.2',state:'up'}]:[]},getIpRoutes:read,getIpv6Routes:read} as unknown as MikrotikClient
}
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
let cases=0
try{
await app.register(createOspfRoutes({listEnabledServers:async()=>enabled?devices:[],readCatalog:async()=>emptyOspfCatalog()}),{prefix:'/api'})
for(const phase of ['populated','empty','partial','failed','disabled'] as const){
populated=phase==='populated';enabled=phase!=='disabled';failedIds=phase==='partial'?[2]:phase==='failed'?[1,2]:[]
for(const endpoint of ['ospf/neighbors','ospf/interfaces','ospf/instances','ospf/all','bfd/sessions']){
const before=reads,r=await app.inject({method:'GET',url:'/api/'+endpoint}),body=r.json()
assert.equal(r.statusCode,failedIds.length?503:200)
if(failedIds.length){assert.deepEqual(body.failures.map((f:{serverId:number})=>f.serverId),failedIds);assert.match(body.error,/OSPF\/BFD/);assert.deepEqual(body.failures.map((f:{error:string})=>f.error),failedIds.map(id=>'Недоступен '+devices[id-1].name))}
else if(endpoint==='ospf/all'){assert.deepEqual(Object.keys(body).sort(),['bfdSessions','instances','interfaces','neighbors','routes']);assert.equal(body.instances.length,populated?2:0);assert.equal(body.bfdSessions.length,populated?2:0)}
else{assert(Array.isArray(body));assert.equal(body.length,populated&&['ospf/instances','bfd/sessions'].includes(endpoint)?2:0)}
if(phase==='disabled')assert.equal(reads,before)
cases++
}
}
}finally{await app.close();MikrotikClient.fromServer=original}
console.log('Legacy OSPF/BFD: '+cases+' HTTP checks passed; populated/empty/partial/failed/no enabled devices')
+47 -87
View File
@@ -18,6 +18,8 @@ import {readEditableOspfTemplates,validateOspfOptimizationPlan} from "../service
import { observedInteger, observedDurationMs, observedBoolean } from "../services/ospf-observed-values.js"
import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js"
import { pollLegacyOspf } from "../services/ospf-legacy-poll.js"
type ServerRow = typeof servers.$inferSelect
type TrafficSampleRow = typeof trafficSamples.$inferSelect
type SpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
@@ -561,108 +563,66 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog
return async (app) => {
app.get("/ospf/catalog", async (_req, reply) => reply.send(await pollOspfCatalog(await dependencies.listEnabledServers(), dependencies.readCatalog)))
// GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers
// Legacy success envelopes have no place for partial failures. Fail explicitly.
const legacyFailure = (failures: { serverId: number; serverName: string; error: string }[]) => ({
error: "Не удалось опросить все устройства OSPF/BFD", failures,
})
app.get("/ospf/neighbors", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return parseNeighbors(server, neighbors, areaMap)
} catch {
return []
}
}),
)
return reply.send(results.flat())
const result = await pollLegacyOspf(await dependencies.listEnabledServers(), async server => {
const { neighbors, areas } = await fetchServerOspf(server)
return parseNeighbors(server, neighbors, buildAreaMap(areas))
})
if (result.failures.length) return reply.status(503).send(legacyFailure(result.failures))
return reply.send(result.values.flat())
})
// GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers
app.get("/ospf/interfaces", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { ifaceTemplates, areas, instances } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
} catch {
return []
}
}),
)
return reply.send(results.flat())
const result = await pollLegacyOspf(await dependencies.listEnabledServers(), async server => {
const { ifaceTemplates, areas, instances } = await fetchServerOspf(server)
return parseInterfaces(server, ifaceTemplates, areas, instances, buildAreaMap(areas))
})
if (result.failures.length) return reply.status(503).send(legacyFailure(result.failures))
return reply.send(result.values.flat())
})
// GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers
app.get("/ospf/instances", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { instances } = await fetchServerOspf(server)
return parseInstances(server, instances)
} catch {
return []
}
}),
)
return reply.send(results.flat())
const result = await pollLegacyOspf(await dependencies.listEnabledServers(), async server => {
const { instances } = await fetchServerOspf(server)
return parseInstances(server, instances)
})
if (result.failures.length) return reply.status(503).send(legacyFailure(result.failures))
return reply.send(result.values.flat())
})
// GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD
app.get("/ospf/all", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return {
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
}
} catch {
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] }
}
}),
)
const result = await pollLegacyOspf(await dependencies.listEnabledServers(), async server => {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return {
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
}
})
if (result.failures.length) return reply.status(503).send(legacyFailure(result.failures))
return reply.send({
neighbors: perServer.flatMap(r => r.neighbors),
interfaces: perServer.flatMap(r => r.interfaces),
instances: perServer.flatMap(r => r.instances),
bfdSessions: perServer.flatMap(r => r.bfdSessions),
routes: perServer.flatMap(r => r.routes),
neighbors: result.values.flatMap(r => r.neighbors),
interfaces: result.values.flatMap(r => r.interfaces),
instances: result.values.flatMap(r => r.instances),
bfdSessions: result.values.flatMap(r => r.bfdSessions),
routes: result.values.flatMap(r => r.routes),
})
})
// GET /api/bfd/sessions — BFD sessions only (for direct access)
app.get("/bfd/sessions", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBfdSessions()
return parseBfdSessions(server, raw)
} catch {
return []
}
}),
)
return reply.send(results.flat())
const result = await pollLegacyOspf(await dependencies.listEnabledServers(), async server => {
const raw = await MikrotikClient.fromServer(server).getBfdSessions()
return parseBfdSessions(server, raw)
})
if (result.failures.length) return reply.status(503).send(legacyFailure(result.failures))
return reply.send(result.values.flat())
})
// GET /api/servers/:id/ospf — single server OSPF + BFD data
+19
View File
@@ -0,0 +1,19 @@
/** Legacy APIs cannot represent partial data in their successful array envelopes. */
export async function pollLegacyOspf<S extends { id: number; name: string }, T>(
devices: readonly S[], read: (device: S) => Promise<T>,
) {
const results = await Promise.all(devices.map(async device => {
try {
return { ok: true as const, value: await read(device) }
} catch (error) {
return { ok: false as const, failure: {
serverId: device.id, serverName: device.name,
error: error instanceof Error ? error.message : "Ошибка опроса устройства",
} }
}
}))
return {
values: results.flatMap(result => result.ok ? [result.value] : []),
failures: results.flatMap(result => result.ok ? [] : [result.failure]),
}
}