fix(api): показать частичные отказы каталога контейнеров
This commit is contained in:
1 parent
1e0fc4cd2d
commit
644ddb64b3
10 files changed
+191
-5
No files matched your search
@@ -26,9 +26,10 @@
|
||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||
"test:backups": "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:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file",
|
||||
"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: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",
|
||||
"test:geoip": "tsx src/services/traffic-flow-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/routes/ospf-optimizer.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/routes/ospf-optimizer.test.ts",
|
||||
"test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
|
||||
import { createContainersRoutes } from "./containers.js"
|
||||
import { permissionForRequest } from "../lib/permissions.js"
|
||||
const empty = {collectedAt:new Date().toISOString(),servers:[]}
|
||||
let value:unknown=empty,failed=false
|
||||
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(createContainersRoutes({getState:async()=>{if(failed)throw new Error("DB unavailable");return value as typeof empty}}),{prefix:"/api"})
|
||||
const read=()=>app.inject({method:"GET",url:"/api/containers/state"})
|
||||
assert.equal((await read()).statusCode,200);assert.deepEqual((await read()).json().servers,[])
|
||||
value={...empty,servers:[{serverId:1,collectedAt:empty.collectedAt,availability:"error",containers:[],failures:[{resource:"containers",error:"offline"}]}]}
|
||||
assert.equal((await read()).statusCode,200);assert.equal((await read()).json().servers[0].availability,"error")
|
||||
for(const bad of [{containers:[]},{...empty,servers:[{serverId:1}]},{...empty,collectedAt:"yesterday"}]){value=bad;assert.equal((await read()).statusCode,502)}
|
||||
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")
|
||||
@@ -1,8 +1,10 @@
|
||||
import { containersStateSchema } from "@mmapp/contracts/containers"
|
||||
import { z } from "zod"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import {
|
||||
getEnabledServerById,
|
||||
listContainers,
|
||||
listContainersState,
|
||||
listContainersForServer,
|
||||
removeContainer,
|
||||
restartContainer,
|
||||
@@ -18,7 +20,13 @@ const RosIdBodySchema = z.object({
|
||||
type RosIdBody = z.infer<typeof RosIdBodySchema>
|
||||
type MutateFn = typeof startContainer
|
||||
|
||||
const containersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
export function createContainersRoutes(deps: { getState?: typeof listContainersState } = {}): FastifyPluginAsyncZod {
|
||||
return async (app) => {
|
||||
app.get("/containers/state", async (_req, reply) => {
|
||||
const state = containersStateSchema.safeParse(await (deps.getState ?? listContainersState)())
|
||||
if (!state.success) return reply.status(502).send({ error: "Некорректное состояние каталога контейнеров" })
|
||||
return reply.send(state.data)
|
||||
})
|
||||
app.get("/containers", async (_req, reply) => {
|
||||
const containers = await listContainers()
|
||||
return reply.send({ containers })
|
||||
@@ -55,4 +63,6 @@ const containersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
registerMutate("/servers/:id/containers/remove", removeContainer)
|
||||
}
|
||||
|
||||
export default containersRoutes
|
||||
}
|
||||
|
||||
export default createContainersRoutes()
|
||||
@@ -213,3 +213,12 @@ export async function getEnabledServerById(serverId: string | number) {
|
||||
if (!Number.isFinite(id)) return null
|
||||
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
|
||||
}
|
||||
|
||||
export async function listContainersState() {
|
||||
const { collectContainersState } = await import("./containers-state.js")
|
||||
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
return collectContainersState(enabledServers, server => {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
return path => client.get<unknown>(path)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readContainerServerState, collectContainersState } from "./containers-state.js"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
import { containersStateSchema } from "@mmapp/contracts/containers"
|
||||
|
||||
const server = { id: 1, name: "Router" } 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-high": "0" }
|
||||
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)
|
||||
assert.equal(good.availability, "available"); assert.equal(good.containers[0].status,"running")
|
||||
assert.equal(good.containers[0].cpu,0);assert.equal(good.containers[0].memMb,0);assert.equal(good.containers[0].startOnBoot,true)
|
||||
assert.deepEqual(good.containers[0].envs,[{key:"A",value:"B"}]);assert.deepEqual(good.containers[0].mounts,[{src:"/disk",dst:"/data"}])
|
||||
const unknown = await readContainerServerState(server, async path => path === "/container" ? [{".id":"*B",name:"pending",status:"extracting"}] : [])
|
||||
assert.equal(unknown.containers[0].status,"unknown");assert.equal(unknown.containers[0].startOnBoot,null);assert.equal(unknown.containers[0].image,"")
|
||||
const empty = await readContainerServerState(server, async()=>[])
|
||||
assert.equal(empty.availability,"available");assert.equal(empty.containers.length,0)
|
||||
for (const raw of [{containers:[]},[{}],[row,row]]) {
|
||||
const result = await readContainerServerState(server, async()=>raw)
|
||||
assert.equal(result.availability,"error");assert.equal(result.failures[0].resource,"containers")
|
||||
}
|
||||
for(const status of [401,403,500]){const result=await readContainerServerState(server,async()=>{throw new MikrotikError(status,"/container","refused")});assert.equal(result.availability,"error")}
|
||||
const unsupported=await readContainerServerState(server,async()=>{throw new MikrotikError(404,"/container","no such command")});assert.equal(unsupported.availability,"unsupported");assert.deepEqual(unsupported.failures,[])
|
||||
for(const resource of ["envs","mounts"]){const result=await readContainerServerState(server,async path=>{if(path==="/container/"+resource)throw new Error("dependency refused");return read(path)});assert.equal(result.availability,"available");assert.equal(result.containers.length,1);assert.equal(result.failures[0].resource,resource)}
|
||||
const badDependency=await readContainerServerState(server,async path=>path==="/container/envs"?{}:read(path));assert.equal(badDependency.failures[0].resource,"envs")
|
||||
const aggregate=await collectContainersState([server,{...server,id:2}],srv=>srv.id===1?read:async()=>{throw new Error("offline")});assert.equal(aggregate.servers[0].containers.length,1);assert.equal(aggregate.servers[1].availability,"error")
|
||||
assert.equal((await collectContainersState([],()=>read)).servers.length,0)
|
||||
for (const value of [
|
||||
{...aggregate,servers:[good,good]},
|
||||
{...aggregate,servers:[{...good,serverId:2}]},
|
||||
{...aggregate,servers:[{...good,availability:"error",failures:[]}]},
|
||||
{...aggregate,servers:[{...good,containers:[good.containers[0],good.containers[0]]}]},
|
||||
{...aggregate,servers:[{...unsupported,failures:[{resource:"envs",error:"x"}]}]},
|
||||
]) assert.equal(containersStateSchema.safeParse(value).success,false)
|
||||
console.log("Containers state: independent devices, malformed/duplicate IDs, unknown status/boot, known empty, missing package, partial dependencies, zeros and envelope validation; no DB/RouterOS")
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod"
|
||||
import { containersStateSchema, containerServerStateSchema, type ContainersState, type ContainerServerState } from "@mmapp/contracts/containers"
|
||||
import { mapContainerRow } from "./containers-live.js"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
|
||||
type ServerRow = Parameters<typeof mapContainerRow>[0]
|
||||
type Reader = (path: string) => Promise<unknown>
|
||||
const rawSchema = z.array(z.object({
|
||||
".id": z.string().min(1), name: z.string().optional(), tag: z.string().optional(), "remote-image": z.string().optional(),
|
||||
interface: z.string().optional(), envlist: z.string().optional(), mounts: z.string().optional(),
|
||||
cmd: z.string().optional(), "start-on-boot": z.string().optional(), comment: z.string().optional(),
|
||||
status: z.string().optional(), "memory-high": z.string().optional(), cpu: z.string().optional(),
|
||||
})).superRefine((rows, ctx) => {
|
||||
if (new Set(rows.map(r => r[".id"])).size !== rows.length) ctx.addIssue({ code: "custom", message: "Дубли RouterOS ID" })
|
||||
})
|
||||
const envSchema = z.array(z.object({ name: z.string().optional(), key: z.string().optional(), value: z.string().optional() }))
|
||||
const mountSchema = z.array(z.object({ name: z.string().optional(), src: z.string().optional(), dst: z.string().optional() }))
|
||||
const errorMessage = (error: unknown) => error instanceof z.ZodError ? "Некорректный ответ RouterOS" : (error instanceof Error ? error.message.slice(0, 500) : "Ошибка RouterOS")
|
||||
function missingPackage(error: unknown) {
|
||||
return error instanceof MikrotikError && (error.statusCode === 404 || /no such command|unknown command/i.test(error.body))
|
||||
}
|
||||
export async function readContainerServerState(server: ServerRow, read: Reader): Promise<ContainerServerState> {
|
||||
const base = { serverId: server.id, collectedAt: new Date().toISOString(), containers: [], failures: [] }
|
||||
let raw: z.infer<typeof rawSchema>
|
||||
try { raw = rawSchema.parse(await read("/container")) }
|
||||
catch (error) {
|
||||
return containerServerStateSchema.parse({ ...base, collectedAt: new Date().toISOString(), availability: missingPackage(error) ? "unsupported" : "error",
|
||||
failures: missingPackage(error) ? [] : [{ resource: "containers", error: errorMessage(error) }] })
|
||||
}
|
||||
const collectedAt = new Date().toISOString()
|
||||
const [envs, mounts] = await Promise.allSettled([
|
||||
read("/container/envs").then(value => envSchema.parse(value)),
|
||||
read("/container/mounts").then(value => mountSchema.parse(value)),
|
||||
])
|
||||
const failures: ContainerServerState["failures"] = []
|
||||
if (envs.status === "rejected") failures.push({ resource: "envs", error: errorMessage(envs.reason) })
|
||||
if (mounts.status === "rejected") failures.push({ resource: "mounts", error: errorMessage(mounts.reason) })
|
||||
const containers = raw.map((row, index) => {
|
||||
const mapped = mapContainerRow(server, row, envs.status === "fulfilled" ? envs.value : [], mounts.status === "fulfilled" ? mounts.value : [], index)
|
||||
const status = (row.status ?? "").toLowerCase()
|
||||
const boot = row["start-on-boot"]
|
||||
const mem = Number.parseInt(row["memory-high"] ?? "", 10)
|
||||
return { ...mapped, image: row["remote-image"] ? mapped.image : "", tag: row["remote-image"] ? mapped.tag : "",
|
||||
status: status === "running" || status === "stopped" ? status : status === "error" || status === "failed" ? "error" : "unknown",
|
||||
startOnBoot: boot === "true" || boot === "yes" ? true : boot === "false" || boot === "no" ? false : null,
|
||||
cpu: mapped.cpu !== undefined && mapped.cpu >= 0 && mapped.cpu <= 100 ? mapped.cpu : undefined,
|
||||
memMb: Number.isFinite(mem) && mem >= 0 ? Math.round(mem / (1024 * 1024)) : undefined }
|
||||
})
|
||||
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 => readContainerServerState(server, makeReader(server))))
|
||||
return containersStateSchema.parse({ collectedAt: new Date().toISOString(), servers: states })
|
||||
}
|
||||
Reference in new issue
Block a user