67 lines
5.5 KiB
TypeScript
67 lines
5.5 KiB
TypeScript
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"
|
|
import { containerRuntimeStatus } from "./container-runtime-status.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(), envlists: z.string().optional(), mounts: z.string().optional(), mountlists: 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(), "memory-current": z.string().optional(), cpu: z.string().optional(),
|
|
running: z.union([z.string(), z.boolean()]).optional(), stopped: z.union([z.string(), z.boolean()]).optional(),
|
|
error: z.union([z.string(), z.boolean()]).optional(), failed: z.union([z.string(), z.boolean()]).optional(),
|
|
extracting: z.union([z.string(), z.boolean()]).optional(), downloading: z.union([z.string(), z.boolean()]).optional(),
|
|
starting: z.union([z.string(), z.boolean()]).optional(), stopping: z.union([z.string(), z.boolean()]).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(), list: z.string().optional(), key: z.string().min(1), value: z.string() }).refine(row => !!(row.list || row.name), "Отсутствует список env"))
|
|
const mountSchema = z.array(z.object({ name: z.string().optional(), list: z.string().optional(), src: z.string().optional(), dst: z.string().min(1) }).refine(row => !!(row.list || row.name), "Отсутствует список mounts"))
|
|
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: [] }
|
|
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) {
|
|
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 envRows = envs.status === "fulfilled" ? envs.value : []
|
|
const mountRows = mounts.status === "fulfilled" ? mounts.value : []
|
|
const mapped = mapContainerRow(server, { ...row, mounts: row.mountlists ?? row.mounts }, envRows.map(item => ({ ...item, name: item.list ?? item.name })), mountRows.map(item => ({ ...item, name: item.list ?? item.name })), index)
|
|
const envLists = new Set((row.envlists ?? row.envlist ?? "").split(",").map(name => name.trim()).filter(Boolean))
|
|
mapped.envs = envRows.filter(item => envLists.has(item.list ?? item.name ?? "")).map(item => ({ key: item.key, value: item.value }))
|
|
const boot = row["start-on-boot"]
|
|
const mem = Number(row["memory-current"]?.trim() || NaN)
|
|
return { ...mapped, image: row["remote-image"] ? mapped.image : "", tag: row["remote-image"] ? mapped.tag : "",
|
|
status: containerRuntimeStatus(row),
|
|
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.filter(server => server.enabled).map(server => {
|
|
let reader: Reader | undefined
|
|
return readContainerServerState(server, path => { reader ??= makeReader(server); return reader(path) })
|
|
}))
|
|
return containersStateSchema.parse({ collectedAt: new Date().toISOString(), servers: states })
|
|
}
|