fix(api): показать частичные отказы каталога контейнеров

This commit is contained in:
Denozordec committed 2026-10-01 10:39:37 +07:00
1 parent 1e0fc4cd2d
commit 644ddb64b3
10 files changed
+191 -5

No files matched your search

+3 -2
View File
@@ -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")
+12 -2
View File
@@ -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()
+9
View File
@@ -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")
+54
View File
@@ -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 })
}
+10 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.8; пользователи приняты локально, далее контейнеры |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.9; пользователи приняты локально, state API контейнеров подготовлен |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,15 @@
## Блок 11А.9 — достоверный backend каталог контейнеров (2026-10-01)
- Добавлен GET /api/containers/state с общим collectedAt и состояниями каждого enabled сервера. available + [] означает подтверждённую пустую загрузку, unsupported — отсутствие /container, error — отказ чтения. Сбой одного устройства сохраняет остальные.
- Загрузка envs/mounts независима, partial failures явно включены в контракт. Неизвестные status/startOnBoot не становятся stopped/false; отсутствующий образ не подменяется именем контейнера; нулевые CPU/RAM сохранены.
- Runtime контракт отклоняет отсутствующие/дубли RouterOS ID, чужой serverId, неверные состояния/даты и дубли устройств/ошибок. Новый reader не выдаёт синтетический rosId для сетевого действия.
- Старые /containers и /servers/:id/containers, mapper и сетевые операции сохранены для совместимости. RBAC соответствует существующим правилам: каталог dashboard read, команды server write; новых разрешений не введено.
- Backend build, профильный lint, новые service/HTTP tests и старый mapper test прошли. test:containers включён в общий test. Тесты без PostgreSQL/RouterOS; реальные чтения ещё требуют внешней приёмки.
- Далее 11А.10: frontend state reader, независимые серверы/контейнеры, последний снимок, unknown KPI, свежесть и защита смены источника. Затем подтверждённые команды, доступность/ПК/телефон.
## Блок 11А.8 — доступность и локальная приёмка пользователей (2026-10-01)
- Существующие ReUI Stepper/Data Grid/Frame сохранены. Семантические токены, явные имена действий/прав, aria-pressed/expanded, клавиатурное раскрытие строки, подпись текущего шага. Vendor компоненты и тема не изменены; checklist ReUI проверен.
+10
View File
@@ -106,3 +106,13 @@
- Пользователи приняты в границах локальных HTTP-подмен. Реальные Portal/PostgreSQL/RouterOS остаются внешней приёмкой; прежние DB binding тесты skipped.
- Далее 11А.9: достоверный state API контейнеров. Аудит выявил превращение отказов устройств/envs/mounts в пустые списки, неизвестного статуса в stopped и недостоверное общее empty. Затем подключение frontend, операции и UX; сертификаты/backup ещё не приняты.
## Блок 11А.9 — достоверный backend каталог контейнеров (2026-10-01)
- Добавлен GET /api/containers/state с общим collectedAt и состояниями каждого enabled сервера. available + [] означает подтверждённую пустую загрузку, unsupported — отсутствие /container, error — отказ чтения. Сбой одного устройства сохраняет остальные.
- Загрузка envs/mounts независима, partial failures явно включены в контракт. Неизвестные status/startOnBoot не становятся stopped/false; отсутствующий образ не подменяется именем контейнера; нулевые CPU/RAM сохранены.
- Runtime контракт отклоняет отсутствующие/дубли RouterOS ID, чужой serverId, неверные состояния/даты и дубли устройств/ошибок. Новый reader не выдаёт синтетический rosId для сетевого действия.
- Старые /containers и /servers/:id/containers, mapper и сетевые операции сохранены для совместимости. RBAC соответствует существующим правилам: каталог dashboard read, команды server write; новых разрешений не введено.
- Backend build, профильный lint, новые service/HTTP tests и старый mapper test прошли. test:containers включён в общий test. Тесты без PostgreSQL/RouterOS; реальные чтения ещё требуют внешней приёмки.
- Далее 11А.10: frontend state reader, независимые серверы/контейнеры, последний снимок, unknown KPI, свежесть и защита смены источника. Затем подтверждённые команды, доступность/ПК/телефон.
+4
View File
@@ -57,6 +57,10 @@
"./statistics": {
"types": "./dist/statistics.d.ts",
"default": "./dist/statistics.js"
},
"./containers": {
"types": "./dist/containers.d.ts",
"default": "./dist/containers.js"
}
},
"dependencies": {
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod"
const serverId = z.number().int().positive().safe()
const time = z.string().datetime({ offset: true })
export const containerStateRowSchema = z.object({
id: z.string().min(1), rosId: z.string().min(1), name: z.string().min(1),
serverId: z.string().regex(/^[1-9]\d*$/),
image: z.string(), tag: z.string(), status: z.enum(["running", "stopped", "error", "unknown"]),
envs: z.array(z.object({ key: z.string().min(1), value: z.string() })),
mounts: z.array(z.object({ dst: z.string().min(1), src: z.string().optional() })),
interfaces: z.array(z.string().min(1)), cmd: z.string().optional(),
startOnBoot: z.boolean().nullable(), comment: z.string(), uptime: z.string().optional(),
cpu: z.number().finite().min(0).max(100).optional(), memMb: z.number().finite().nonnegative().optional(),
})
export const containerServerStateSchema = z.object({
serverId, collectedAt: time, availability: z.enum(["available", "unsupported", "error"]),
containers: z.array(containerStateRowSchema),
failures: z.array(z.object({ resource: z.enum(["containers", "envs", "mounts"]), error: z.string().min(1) })),
}).superRefine((value, ctx) => {
const ids = new Set<string>()
for (const row of value.containers) {
if (row.serverId !== String(value.serverId) || row.id !== value.serverId + "-" + row.rosId || ids.has(row.rosId)) ctx.addIssue({ code: "custom", message: "Контейнеры не соответствуют устройству или содержат дубли" })
ids.add(row.rosId)
}
const resources = value.failures.map(f => f.resource)
if (new Set(resources).size !== resources.length) ctx.addIssue({ code: "custom", message: "Дубли ошибок ресурсов" })
if (value.availability !== "available" && value.containers.length) ctx.addIssue({ code: "custom", message: "Неуспешная загрузка содержит контейнеры" })
if (value.availability === "error" && !resources.includes("containers") || value.availability === "available" && resources.includes("containers") || value.availability === "unsupported" && resources.length) ctx.addIssue({ code: "custom", message: "Состояние не соответствует ошибкам" })
})
export const containersStateSchema = z.object({
collectedAt: time, servers: z.array(containerServerStateSchema),
}).superRefine((value, ctx) => {
if (new Set(value.servers.map(s => s.serverId)).size !== value.servers.length) ctx.addIssue({ code: "custom", message: "Дубли устройств" })
})
export type ContainerStateRow = z.infer<typeof containerStateRowSchema>
export type ContainerServerState = z.infer<typeof containerServerStateSchema>
export type ContainersState = z.infer<typeof containersStateSchema>