fix(containers): подтверждать остановку и удаление контейнера
This commit is contained in:
1 parent
b3d11276dd
commit
74151176d7
20 files changed
+414
-28
No files matched your search
@@ -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/routes/containers-state.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: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",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Real Portal login; credentials stay in the caller's ignored private fixture.
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const phase = process.argv[2];
|
||||
assert(['guard', 'remove', 'repeat'].includes(phase));
|
||||
const login = JSON.parse(fs.readFileSync(process.env.MMAPP_PORTAL_FIXTURE || 'tmp/portal195-private.json'));
|
||||
const own = JSON.parse(fs.readFileSync(process.env.MMAPP_CONTAINER_FIXTURE || 'tmp/state248-public.json'));
|
||||
const reportPath = 'docs/ux-ui/acceptance/containers248-live-ui.json';
|
||||
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath)) : { date: '2026-10-14', block: '12.5.248', realPortalLogin: true, noMocks: true, steps: [], pageErrors: [] };
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.on('pageerror', error => report.pageErrors.push(error.message));
|
||||
await page.goto((process.env.MMAPP_TEST_URL || 'http://localhost:3000') + '/containers');
|
||||
await page.getByLabel('Email', { exact: true }).fill(login.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(login.password);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toContainText('Контейнеры', { timeout: 30000 });
|
||||
const endpoint = `/api/servers/${own.serverId}/containers/remove`;
|
||||
const direct = () => page.evaluate(async ({ endpoint, rosId }) => {
|
||||
const r = await fetch(endpoint, { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('mmapp_token'), 'Content-Type': 'application/json' }, body: JSON.stringify({ rosId }) });
|
||||
const body = await r.json();
|
||||
return { status: r.status, ok: body.ok === true, error: body.error };
|
||||
}, { endpoint, rosId: own.rosId });
|
||||
if (phase === 'repeat') {
|
||||
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false); assert.match(result.error, /уже отсутствует/);
|
||||
report.steps.push({ phase, status: result.status, absentConflict: true });
|
||||
} else {
|
||||
await page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Удалить', exact: true }).click();
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await expect(dialog).toContainText(own.name);
|
||||
if (phase === 'guard') {
|
||||
report.layout = [];
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await expect(dialog).toContainText('после подтверждённой остановки');
|
||||
await expect(dialog.getByRole('button', { name: 'Удалить', exact: true })).toBeDisabled();
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth > innerWidth), { timeout: 5000 }).toBe(false);
|
||||
const layout = await page.evaluate(() => ({ viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
|
||||
(report.layout ??= []).push(layout);
|
||||
if (layout.scrollWidth > layout.viewport) {
|
||||
report.overflow = await page.evaluate(() => Array.from(document.querySelectorAll('*')).map(el => ({tag:el.tagName,slot:el.getAttribute('data-slot'),left:el.getBoundingClientRect().left,right:el.getBoundingClientRect().right,width:el.getBoundingClientRect().width,position:getComputedStyle(el).position})).filter(el=>el.right>innerWidth+1).slice(0,20));
|
||||
await page.screenshot({ path: `tmp/containers248-guard-${width}.png`, fullPage: true });
|
||||
}
|
||||
assert.equal(layout.scrollWidth > layout.viewport, false);
|
||||
}
|
||||
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false);
|
||||
report.steps.push({ phase, viewports: [1440, 390], uiDeleteDisabled: true, backendConflict: result.status });
|
||||
} else {
|
||||
await expect(dialog).toContainText('остановленный контейнер');
|
||||
const response = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: 'Удалить', exact: true }).click();
|
||||
const r = await response; assert.equal(r.request().postDataJSON().rosId, own.rosId); assert.equal(r.status(), 200); assert.equal((await r.json()).ok, true);
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true })).toHaveCount(0);
|
||||
report.steps.push({ phase, status: 200, receiptOk: true, targetMatched: true, rowAbsent: true });
|
||||
}
|
||||
}
|
||||
assert.deepEqual(report.pageErrors, []);
|
||||
delete report.overflow;
|
||||
if (report.failure?.phase === phase) { (report.harnessFailures ??= []).push(report.failure); delete report.failure; }
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
|
||||
console.log('Live container removal: ' + phase + ' passed');
|
||||
} catch (error) {
|
||||
report.failure = { phase, name: error.name, ...(error.name === 'AssertionError' ? { actual: error.actual, expected: error.expected } : {}) }; fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); throw error;
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error('Live container removal failed: ' + error.name); process.exitCode = 1; });
|
||||
@@ -85,7 +85,10 @@ try {
|
||||
await reboot(state, 'uninstallReboot');
|
||||
} else if (phase === 'final') {
|
||||
const p = await poll(() => client.get('/system/package', 4000), p => !packages(p).some(p => p.name === 'container'));
|
||||
assert.deepEqual(packages(p), state.beforePackages); assert.equal(normalizedExport(await client.exportConfigScript()), state.beforeExport);
|
||||
assert.deepEqual(packages(p), state.beforePackages);
|
||||
// Automatic timezone discovery can settle after package removal/reboot.
|
||||
// Wait for equality without excluding settings or logging the private export.
|
||||
await poll(async () => normalizedExport(await client.exportConfigScript()), value => value === state.beforeExport);
|
||||
await control.get('/system/resource', 5000);
|
||||
const report = { date: '2026-10-14', block: '12.5.247', targetHost: server.host, controlHost: '194.226.121.150', stages: state.stages, installReboot: state.installReboot, uninstallReboot: state.uninstallReboot, originalPackagesMatched: true, originalNormalizedExportSha256: state.beforeExportSha256, originalNormalizedExportMatched: true, controlReachable: true, passwordChanges: 0, deviceModeChanges: 0, firmwareVersionChanged: false, passed: true };
|
||||
fs.writeFileSync('/checks/containers247-native.json', JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report));
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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. Обновите каталог перед новым действием.")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user