fix(containers): исключить опрос выключенных серверов и ложные пустые данные

This commit is contained in:
Denozordec committed 2026-10-14 19:25:14 +07:00
1 parent 74151176d7
commit dc7b22940a
16 files changed
+470 -88

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@
"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/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
"test:containers": "tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
"test:certificates": "tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
"test:system-database": "tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts",
"test:all": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && 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 && npm run test:system-database && npm run test:server-privacy",
+9 -2
View File
@@ -17,14 +17,21 @@ failed=true;assert.equal((await read()).statusCode,500)
assert.equal(permissionForRequest("GET","/api/containers/state"),"mm:dashboard:read")
assert.equal(permissionForRequest("POST","/api/servers/1/containers/start"),"mm:servers:write")
await app.close();console.log("Containers HTTP: explicit empty/partial error, invalid state 502, DB failure 500, existing permission contract")
let enabled = true, missing = false, calls = 0, removalError: Error | undefined
let enabled = true, missing = false, calls = 0, reads = 0, failRead = false, removalError: Error | undefined
const readError = new Error("RouterOS refused")
const mutations = Fastify();mutations.setValidatorCompiler(validatorCompiler);mutations.setSerializerCompiler(serializerCompiler)
await mutations.register(createContainersRoutes({getServer:async()=>missing?null:({id:1,enabled} as NonNullable<Awaited<ReturnType<NonNullable<Parameters<typeof createContainersRoutes>[0]>["getServer"]>>>),remove:async()=>{calls++;if(removalError)throw removalError}}),{prefix:"/api"})
await mutations.register(createContainersRoutes({getServer:async()=>missing?null:({id:1,enabled} as NonNullable<Awaited<ReturnType<NonNullable<Parameters<typeof createContainersRoutes>[0]>["getServer"]>>>),getLegacyCatalog:async()=>{if(failRead)throw readError;return []},getServerContainers:async()=>{reads++;if(failRead)throw readError;return []},remove:async()=>{calls++;if(removalError)throw removalError}}),{prefix:"/api"})
const remove = ()=>mutations.inject({method:"POST",url:"/api/servers/1/containers/remove",payload:{rosId:"*A"}})
enabled=false;assert.equal((await remove()).statusCode,409);assert.equal(calls,0)
missing=true;assert.equal((await remove()).statusCode,404);assert.equal(calls,0)
missing=false;enabled=true;removalError=new ContainerRemovalConflict("Остановите контейнер");assert.equal((await remove()).statusCode,409)
removalError=new Error("read failed");assert.equal((await remove()).statusCode,502)
removalError=undefined;assert.deepEqual((await remove()).json(),{ok:true})
const readSelected=()=>mutations.inject({method:"GET",url:"/api/servers/1/containers"})
enabled=false;assert.equal((await readSelected()).statusCode,409);assert.equal(reads,0)
missing=true;assert.equal((await readSelected()).statusCode,404);assert.equal(reads,0)
missing=false;enabled=true;assert.equal((await readSelected()).statusCode,200);assert.equal(reads,1)
failRead=true;assert.equal((await readSelected()).statusCode,502)
assert.equal((await mutations.inject({method:"GET",url:"/api/containers"})).statusCode,502)
assert.equal(permissionForRequest("POST","/api/servers/1/containers/remove"),"mm:servers:write")
await mutations.close();console.log("Containers mutations HTTP: disabled/missing server cannot dispatch; removal conflict 409, uncertainty 502, confirmed success and write permission")
+15 -6
View File
@@ -21,7 +21,7 @@ const RosIdBodySchema = z.object({
type RosIdBody = z.infer<typeof RosIdBodySchema>
type MutateFn = typeof startContainer
export function createContainersRoutes(deps: { getState?: typeof listContainersState; getServer?: typeof getEnabledServerById; remove?: MutateFn } = {}): FastifyPluginAsyncZod {
export function createContainersRoutes(deps: { getState?: typeof listContainersState; getServer?: typeof getEnabledServerById; getLegacyCatalog?: typeof listContainers; getServerContainers?: typeof listContainersForServer; remove?: MutateFn } = {}): FastifyPluginAsyncZod {
return async (app) => {
app.get("/containers/state", async (_req, reply) => {
const state = containersStateSchema.safeParse(await (deps.getState ?? listContainersState)())
@@ -29,16 +29,25 @@ export function createContainersRoutes(deps: { getState?: typeof listContainersS
return reply.send(state.data)
})
app.get("/containers", async (_req, reply) => {
const containers = await listContainers()
return reply.send({ containers })
try {
const containers = await (deps.getLegacyCatalog ?? listContainers)()
return reply.send({ containers })
} catch (error) {
return reply.status(502).send({ error: error instanceof Error ? error.message : "Не удалось получить контейнеры" })
}
})
app.get("/servers/:id/containers", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = await getEnabledServerById(params.id)
const server = await (deps.getServer ?? getEnabledServerById)(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
const containers = await listContainersForServer(server)
return reply.send({ containers })
if (!server.enabled) return reply.status(409).send({ error: "Сервер выключен. Опрос контейнеров недоступен." })
try {
const containers = await (deps.getServerContainers ?? listContainersForServer)(server)
return reply.send({ containers })
} catch (error) {
return reply.status(502).send({ error: error instanceof Error ? error.message : "Не удалось получить контейнеры" })
}
})
function registerMutate(path: string, fn: MutateFn) {
+30 -1
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict"
import { mapContainerRow } from "./containers-live.js"
import { mapContainerRow, listContainersForServer, getEnabledServerById } from "./containers-live.js"
import { createServer } from "node:http"
const server = {
id: 3,
@@ -44,3 +45,31 @@ assert.deepEqual(row.mounts, [
assert.equal(row.startOnBoot, true)
console.log("containers-live.test.ts: ok")
for(const [native,expected] of [[{running:"true"},"running"],[{stopped:"true"},"stopped"],[{},"unknown"],[{running:true,stopped:true},"unknown"]] as const){
const result=mapContainerRow(server,{".id":"*B",...native},[],[],0)
assert.equal(result.status,expected);assert.equal(result.startOnBoot,null);assert.equal(result.image,"");assert.equal(result.tag,"")
}
const modern=mapContainerRow(server,{".id":"*C",envlists:"first,second",mountlists:"config","memory-current":"0","memory-high":"52428800",cpu:"0"},[{list:"first",key:"A",value:"1"},{list:"second",key:"B",value:"2"}],[{list:"config",dst:"/etc"}],0)
assert.equal(modern.envs.length,2);assert.equal(modern.mounts.length,1);assert.equal(modern.memMb,0);assert.equal(modern.cpu,0)
const limitOnly=mapContainerRow(server,{".id":"*D","memory-high":"52428800",cpu:"5bad"},[],[],0)
assert.equal(limitOnly.memMb,undefined);assert.equal(limitOnly.cpu,undefined)
for(const invalid of ["1abc","","0","-1","1.5","9007199254740993"])assert.equal(await getEnabledServerById(invalid),null)
let mode="ok",requests=0
const router=createServer((req,res)=>{
requests++;res.setHeader("Content-Type","application/json")
if(mode==="unauthorized"){res.writeHead(401);res.end(JSON.stringify({error:401,message:"Unauthorized"}));return}
if(mode==="unsupported"){res.writeHead(404);res.end(JSON.stringify({error:404,message:"no such command"}));return}
if(mode==="malformed"){res.end(JSON.stringify({containers:[]}));return}
if(mode==="dependency"&&req.url==="/rest/container/envs"){res.writeHead(500);res.end(JSON.stringify({error:500}));return}
res.end(JSON.stringify(req.url==="/rest/container"?[{".id":"*A",name:"native",running:"true"}]:[]))
})
await new Promise<void>(resolve=>router.listen(0,"127.0.0.1",resolve))
const address=router.address();assert(address&&typeof address!=="string")
const device={...server,host:"127.0.0.1",port:address.port,useSsl:false,verifySsl:false,enabled:true,username:"fixture",password:"fixture"} as Parameters<typeof listContainersForServer>[0]
try{
const before=requests;await assert.rejects(listContainersForServer({...device,enabled:false}),/выключен/);assert.equal(requests,before)
const rows=await listContainersForServer(device);assert.equal(rows[0].status,"running");assert.equal(rows[0].startOnBoot,null)
for(const failure of ["unauthorized","malformed","dependency"]){mode=failure;await assert.rejects(listContainersForServer(device),/Не удалось полностью/)}
mode="unsupported";assert.deepEqual(await listContainersForServer(device),[])
}finally{await new Promise<void>((resolve,reject)=>router.close(error=>error?reject(error):resolve()))}
console.log("Legacy containers: real loopback HTTP handles native flags, auth/malformed/partial errors; disabled server makes zero requests; invalid IDs never query DB")
+39 -67
View File
@@ -2,7 +2,9 @@ import {boundedCompleteCount,completeContainerCount} from "./complete-count.js"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
import { MikrotikClient } from "./mikrotik.js"
import { containerRuntimeStatus } from "./container-runtime-status.js"
import { parseDbServerId } from "../utils/server-id.js"
type ServerRow = typeof servers.$inferSelect
@@ -13,28 +15,41 @@ interface RosContainer {
"remote-image"?: string
interface?: string
envlist?: string
envlists?: string
mounts?: string
mountlists?: string
cmd?: string
"start-on-boot"?: string
comment?: string
status?: string
"memory-high"?: string
"memory-current"?: string
cpu?: string
running?: string | boolean
stopped?: string | boolean
error?: string | boolean
failed?: string | boolean
extracting?: string | boolean
downloading?: string | boolean
starting?: string | boolean
stopping?: string | boolean
}
interface RosContainerEnv {
name?: string
list?: string
key?: string
value?: string
}
interface RosContainerMount {
name?: string
list?: string
src?: string
dst?: string
}
export type ContainerLiveStatus = "running" | "stopped" | "error"
export type ContainerLiveStatus = "running" | "stopped" | "error" | "unknown"
export type ContainerLive = {
id: string
@@ -48,24 +63,13 @@ export type ContainerLive = {
mounts: { dst: string; src?: string }[]
interfaces: string[]
cmd?: string
startOnBoot: boolean
startOnBoot: boolean | null
comment: string
uptime?: string
cpu?: number
memMb?: number
}
function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function mapStatus(raw: string | undefined): ContainerLiveStatus {
const s = (raw ?? "").toLowerCase()
if (s === "running") return "running"
if (s === "error" || s === "failed") return "error"
return "stopped"
}
function splitCsv(v: string | undefined): string[] {
return (v ?? "")
.split(",")
@@ -82,17 +86,7 @@ function parseImageTag(c: RosContainer): { image: string; tag: string } {
}
return { image: remote, tag: (c.tag ?? "latest").trim() || "latest" }
}
return { image: (c.name ?? "").trim(), tag: (c.tag ?? "latest").trim() || "latest" }
}
function isMissingPackage(err: unknown): boolean {
if (err instanceof MikrotikError) {
if (err.statusCode === 404) return true
const body = err.body.toLowerCase()
return body.includes("no such command") || body.includes("not found") || body.includes("unknown")
}
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase()
return msg.includes("no such command") || msg.includes("404")
return { image: "", tag: "" }
}
export function mapContainerRow(
@@ -105,14 +99,13 @@ export function mapContainerRow(
const rosId = String(c[".id"] ?? `c-${idx}`)
const name = (c.name ?? "").trim() || `container-${idx + 1}`
const { image, tag } = parseImageTag(c)
const envlist = (c.envlist ?? "").trim()
const mountNames = new Set(splitCsv(c.mounts))
const envRows = envlist
? envs.filter((e) => (e.name ?? "").trim() === envlist && (e.key ?? "").trim())
: []
const mountRows = mounts.filter((m) => mountNames.has((m.name ?? "").trim()) && (m.dst ?? "").trim())
const cpuRaw = Number.parseInt(c.cpu ?? "", 10)
const memRaw = Number.parseInt(c["memory-high"] ?? "", 10)
const envNames = new Set(splitCsv(c.envlists ?? c.envlist))
const mountNames = new Set(splitCsv(c.mountlists ?? c.mounts))
const envRows = envs.filter(e => envNames.has((e.list ?? e.name ?? "").trim()) && (e.key ?? "").trim())
const mountRows = mounts.filter((m) => mountNames.has((m.list ?? m.name ?? "").trim()) && (m.dst ?? "").trim())
const cpuRaw = Number(c.cpu?.trim() || NaN)
const memRaw = Number(c["memory-current"]?.trim() || NaN)
const boot = c["start-on-boot"]
return {
id: `${server.id}-${rosId}`,
rosId,
@@ -120,56 +113,35 @@ export function mapContainerRow(
serverId: String(server.id),
image,
tag,
status: mapStatus(c.status),
status: containerRuntimeStatus({ ...c }),
envs: envRows.map((e) => ({ key: e.key ?? "", value: e.value ?? "" })),
mounts: mountRows.map((m) => ({ dst: m.dst ?? "", src: m.src || undefined })),
interfaces: splitCsv(c.interface),
cmd: (c.cmd ?? "").trim() || undefined,
startOnBoot: rosYes(c["start-on-boot"]),
startOnBoot: boot === "true" || boot === "yes" ? true : boot === "false" || boot === "no" ? false : null,
comment: c.comment ?? "",
cpu: Number.isFinite(cpuRaw) ? cpuRaw : undefined,
memMb: Number.isFinite(memRaw) ? Math.round(memRaw / (1024 * 1024)) || undefined : undefined,
cpu: Number.isFinite(cpuRaw) && cpuRaw >= 0 && cpuRaw <= 100 ? cpuRaw : undefined,
memMb: Number.isFinite(memRaw) && memRaw >= 0 ? Math.round(memRaw / (1024 * 1024)) : undefined,
}
}
async function fetchContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
if (!server.enabled) throw new Error("Сервер выключен. Опрос контейнеров недоступен.")
const client = MikrotikClient.fromServer(server)
try {
const [raw, envsRaw, mountsRaw] = await Promise.all([
client.get<RosContainer[]>("/container"),
client.get<RosContainerEnv[]>("/container/envs").catch(() => [] as RosContainerEnv[]),
client.get<RosContainerMount[]>("/container/mounts").catch(() => [] as RosContainerMount[]),
])
const list = Array.isArray(raw) ? raw : []
const envs = Array.isArray(envsRaw) ? envsRaw : []
const mounts = Array.isArray(mountsRaw) ? mountsRaw : []
return list.map((c, idx) => mapContainerRow(server, c, envs, mounts, idx))
} catch (err) {
if (isMissingPackage(err)) return []
throw err
}
const { readContainerServerState } = await import("./containers-state.js")
const state = await readContainerServerState(server, path => client.get(path))
if (state.availability === "error" || state.failures.length) throw new Error("Не удалось полностью получить контейнеры сервера " + server.name + ": " + state.failures.map(f => f.error).join("; "))
return state.containers
}
export async function listContainers(): Promise<ContainerLive[]> {
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
enabledServers.map(async (server) => {
try {
return await fetchContainersForServer(server)
} catch {
return [] as ContainerLive[]
}
}),
)
const results = await Promise.all(enabledServers.map(fetchContainersForServer))
return results.flat()
}
export async function listContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
try {
return await fetchContainersForServer(server)
} catch {
return []
}
return fetchContainersForServer(server)
}
export async function countContainers():Promise<number|undefined>{return boundedCompleteCount(()=>listContainersState(),completeContainerCount)}
@@ -196,8 +168,8 @@ export async function removeContainer(server: ServerRow, rosId: string): Promise
}
export async function getEnabledServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
const id = parseDbServerId(serverId)
if (id === null) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
@@ -3,7 +3,7 @@ import { readContainerServerState, collectContainersState } from "./containers-s
import { MikrotikError } from "./mikrotik.js"
import { containersStateSchema } from "@mmapp/contracts/containers"
const server = { id: 1, name: "Router" } as Parameters<typeof readContainerServerState>[0]
const server = { id: 1, name: "Router", enabled: true } as Parameters<typeof readContainerServerState>[0]
const row = { ".id": "*A", name: "nginx", "remote-image": "nginx:alpine", interface: "veth1", envlist: "e", mounts: "m", status: "running", "start-on-boot": "yes", cpu: "0", "memory-current": "0", "memory-high": "52428800" }
const read = async (path: string): Promise<unknown> => path === "/container" ? [row] : path === "/container/envs" ? [{name:"e",key:"A",value:"B"}] : [{name:"m",src:"/disk",dst:"/data"}]
const good = await readContainerServerState(server, read)
@@ -49,3 +49,8 @@ const modern=await readContainerServerState(server,async path=>path==="/containe
let factories=0
const setupFailure=await collectContainersState([server,{...server,id:2}],srv=>{if(srv.id===2)throw new Error("client setup refused");factories++;return read});assert.equal(setupFailure.servers[0].containers.length,1);assert.equal(setupFailure.servers[1].availability,"error");assert.equal(setupFailure.servers[1].failures[0].error,"client setup refused");assert.equal(factories,1)
let disabledReads=0
const disabled=await readContainerServerState({...server,enabled:false},async()=>{disabledReads++;throw new Error("Must never poll disabled")})
assert.equal(disabledReads,0);assert.equal(disabled.availability,"error");assert.match(disabled.failures[0].error,/выключен/)
const enabledOnly=await collectContainersState([server,{...server,id:2,enabled:false}],srv=>{assert.equal(srv.id,1);return read})
assert.deepEqual(enabledOnly.servers.map(s=>s.serverId),[1])
+2 -1
View File
@@ -26,6 +26,7 @@ function missingPackage(error: unknown) {
}
export async function readContainerServerState(server: ServerRow, read: Reader): Promise<ContainerServerState> {
const base = { serverId: server.id, collectedAt: new Date().toISOString(), containers: [], failures: [] }
if (!server.enabled) return containerServerStateSchema.parse({ ...base, availability: "error", failures: [{ resource: "containers", error: "Сервер выключен. Опрос контейнеров недоступен." }] })
let raw: z.infer<typeof rawSchema>
try { raw = rawSchema.parse(await read("/container")) }
catch (error) {
@@ -57,7 +58,7 @@ export async function readContainerServerState(server: ServerRow, read: Reader):
return containerServerStateSchema.parse({ serverId: server.id, collectedAt, availability: "available", containers, failures })
}
export async function collectContainersState(servers: ServerRow[], makeReader: (server: ServerRow) => Reader): Promise<ContainersState> {
const states = await Promise.all(servers.map(server => {
const states = await Promise.all(servers.filter(server => server.enabled).map(server => {
let reader: Reader | undefined
return readContainerServerState(server, path => { reader ??= makeReader(server); return reader(path) })
}))