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

This commit is contained in:
Denozordec committed 2026-10-14 19:03:58 +07:00
1 parent b3d11276dd
commit 74151176d7
20 files changed
+414 -28

No files matched your search

@@ -3,6 +3,7 @@ import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
import { createContainersRoutes } from "./containers.js"
import { permissionForRequest } from "../lib/permissions.js"
import { ContainerRemovalConflict } from "../services/container-removal.js"
const empty = {collectedAt:new Date().toISOString(),servers:[]}
let value:unknown=empty,failed=false
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
@@ -16,3 +17,14 @@ 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
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"})
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})
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")
+6 -4
View File
@@ -12,6 +12,7 @@ import {
stopContainer,
} from "../services/containers-live.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import { ContainerRemovalConflict } from "../services/container-removal.js"
const RosIdBodySchema = z.object({
rosId: z.string().min(1),
@@ -20,7 +21,7 @@ const RosIdBodySchema = z.object({
type RosIdBody = z.infer<typeof RosIdBodySchema>
type MutateFn = typeof startContainer
export function createContainersRoutes(deps: { getState?: typeof listContainersState } = {}): FastifyPluginAsyncZod {
export function createContainersRoutes(deps: { getState?: typeof listContainersState; getServer?: typeof getEnabledServerById; remove?: MutateFn } = {}): FastifyPluginAsyncZod {
return async (app) => {
app.get("/containers/state", async (_req, reply) => {
const state = containersStateSchema.safeParse(await (deps.getState ?? listContainersState)())
@@ -44,13 +45,14 @@ export function createContainersRoutes(deps: { getState?: typeof listContainersS
app.post(path, { schema: { params: ServerIdParamSchema, body: RosIdBodySchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const body = req.body as RosIdBody
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" })
if (!server.enabled) return reply.status(409).send({ error: "Сервер выключен. Операции с контейнерами недоступны." })
try {
await fn(server, body.rosId)
return reply.send({ ok: true })
} catch (err) {
return reply.status(502).send({
return reply.status(err instanceof ContainerRemovalConflict ? 409 : 502).send({
error: err instanceof Error ? err.message : "Ошибка RouterOS",
})
}
@@ -60,7 +62,7 @@ export function createContainersRoutes(deps: { getState?: typeof listContainersS
registerMutate("/servers/:id/containers/start", startContainer)
registerMutate("/servers/:id/containers/stop", stopContainer)
registerMutate("/servers/:id/containers/restart", restartContainer)
registerMutate("/servers/:id/containers/remove", removeContainer)
registerMutate("/servers/:id/containers/remove", deps.remove ?? removeContainer)
}
}
@@ -0,0 +1,32 @@
import assert from "node:assert/strict"
import { ContainerRemovalConflict, removeStoppedContainer } from "./container-removal.js"
const stopped = { ".id": "*A", stopped: "true" }
let deletes: string[] = []
let reads: unknown[] = []
const client = { get: async () => { const next = reads.shift(); if (next instanceof Error) throw next; return next }, delete: async (path: string) => { deletes.push(path) } }
for (const row of [{ ".id": "*A", running: "true" }, { ".id": "*A" }, { ...stopped, running: true }, { ...stopped, extracting: true }]) {
reads = [[row]]; deletes = []
await assert.rejects(removeStoppedContainer(client, "*A"), ContainerRemovalConflict)
assert.deepEqual(deletes, [])
}
reads = [[]]; deletes = []
await assert.rejects(removeStoppedContainer(client, "*A"), /уже отсутствует/)
assert.deepEqual(deletes, [])
for (const value of [[stopped, stopped], { containers: [] }, new Error("read offline")]) {
reads = [value]; deletes = []
await assert.rejects(removeStoppedContainer(client, "*A"))
assert.deepEqual(deletes, [])
}
reads = [[stopped, { ".id": "*B", running: "true" }], [{ ".id": "*B", running: "true" }]]; deletes = []
await removeStoppedContainer(client, "*A")
assert.deepEqual(deletes, ["/container/*A"])
reads = [[]]
await assert.rejects(removeStoppedContainer(client, "*A"), ContainerRemovalConflict)
assert.equal(deletes.length, 1, "A repeated request never deletes another container")
for (const after of [[stopped], new Error("confirmation offline"), { containers: [] }]) {
reads = [[stopped], after]; deletes = []
await assert.rejects(removeStoppedContainer(client, "*A"))
assert.equal(deletes.length, 1)
}
console.log("Container removal: running/unknown/conflicting/transient/absent/invalid/read failure cannot delete; stopped deletion confirmed; repeat cannot delete another target; failed confirmation never succeeds")
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod"
import { containerRuntimeStatus } from "./container-runtime-status.js"
const rowsSchema = z.array(z.object({ ".id": z.string().min(1) }).passthrough()).superRefine((rows, ctx) => {
if (new Set(rows.map(row => row[".id"])).size !== rows.length) ctx.addIssue({ code: "custom", message: "Дубли RouterOS ID" })
})
export class ContainerRemovalConflict extends Error {}
type Client = {
get(path: string): Promise<unknown>
delete(path: string): Promise<unknown>
}
/** Never stop implicitly: removal requires a fresh, unambiguous stopped state. */
export async function removeStoppedContainer(client: Client, rosId: string): Promise<void> {
const rows = rowsSchema.parse(await client.get("/container"))
const target = rows.find(row => row[".id"] === rosId)
if (!target) throw new ContainerRemovalConflict("Контейнер уже отсутствует. Обновите каталог.")
if (containerRuntimeStatus(target) !== "stopped") {
throw new ContainerRemovalConflict("Удаление доступно только после подтверждённой остановки контейнера. Остановите его и обновите каталог.")
}
await client.delete(`/container/${encodeURIComponent(rosId)}`)
const after = rowsSchema.parse(await client.get("/container"))
if (after.some(row => row[".id"] === rosId)) throw new Error("Удаление не подтверждено повторным чтением RouterOS. Обновите каталог перед новым действием.")
}
+2 -5
View File
@@ -174,10 +174,6 @@ export async function listContainersForServer(server: ServerRow): Promise<Contai
export async function countContainers():Promise<number|undefined>{return boundedCompleteCount(()=>listContainersState(),completeContainerCount)}
function encodeRosId(rosId: string): string {
return encodeURIComponent(rosId)
}
export async function startContainer(server: ServerRow, rosId: string): Promise<void> {
const client = MikrotikClient.fromServer(server)
await client.post("/container/start", { ".id": rosId })
@@ -195,7 +191,8 @@ export async function restartContainer(server: ServerRow, rosId: string): Promis
export async function removeContainer(server: ServerRow, rosId: string): Promise<void> {
const client = MikrotikClient.fromServer(server)
await client.delete(`/container/${encodeRosId(rosId)}`)
const { removeStoppedContainer } = await import("./container-removal.js")
await removeStoppedContainer(client, rosId)
}
export async function getEnabledServerById(serverId: string | number) {