fix(containers): учитывать фактические состояния контейнеров

This commit is contained in:
Denozordec committed 2026-10-14 18:35:07 +07:00
1 parent 0cc8fe5572
commit b3d11276dd
14 files changed
+357 -14

No files matched your search

@@ -0,0 +1,94 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import { randomUUID, createHash } from 'node:crypto';
const { pool } = await import('/app/backend/dist/db/index.js');
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
const rows = (await pool.query("SELECT * FROM servers WHERE host='104.165.244.7' AND enabled=true")).rows; assert.equal(rows.length, 1);
const server = rows[0], client = new MikrotikClient({ host: server.host, port: 80, useSsl: false, verifySsl: false, username: server.username, password: server.password });
const controlRows = (await pool.query("SELECT * FROM servers WHERE host='194.226.121.150' AND enabled=true")).rows; assert.equal(controlRows.length, 1);
const control = new MikrotikClient({ host: controlRows[0].host, port: 80, useSsl: false, verifySsl: false, username: controlRows[0].username, password: controlRows[0].password });
const statePath = '/checks/state247-private.json', phase = process.argv[2];
const save = state => fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
const sha = value => createHash('sha256').update(value).digest('hex');
const normalizedExport = value => value.split(/\r?\n/).filter(line => !line.startsWith('#') && line.trim()).join('\n');
// RouterOS may list available, uninstalled extra packages after reboot; they have no installed version.
const packages = rows => rows.filter(p => typeof p.version === 'string' && p.version.trim()).map(p => ({ name: p.name, version: p.version, disabled: p.disabled ?? 'false' })).sort((a,b) => a.name.localeCompare(b.name));
const ownContainers = async state => (await client.get('/container', 5000)).filter(c => c.interface === state.veth && c.comment === state.comment);
const nativeStatus = row => row.status ?? (row.running === 'true' && row.stopped !== 'true' ? 'running' : row.stopped === 'true' && row.running !== 'true' ? 'stopped' : 'unknown');
async function poll(read, accept, maxMs = 150000) {
const until = Date.now() + maxMs; let last;
while (Date.now() < until) { try { last = await read(); if (accept(last)) return last; } catch {} await new Promise(r => setTimeout(r, 1500)); }
throw Error('Native state not confirmed within polling budget');
}
async function reboot(state, key) {
assert(!state[key], 'Never repeat a possibly applied reboot');
state[key] = { requested: true, acknowledgement: 'unknown' }; save(state);
try { await client.post('/system/reboot', {}, 5000); state[key].acknowledgement = 'confirmed'; }
catch (e) { state[key].errorName = e.name; state[key].status = e.statusCode ?? null; }
save(state); console.log(JSON.stringify({ phase, rebootRequestedOnce: true, acknowledgement: state[key].acknowledgement }));
}
try {
if (phase === 'init') {
assert(!fs.existsSync(statePath));
const uuid = randomUUID().slice(0,8), state = { serverId: Number(server.id), targetHost: server.host, veth: 'mmc247-' + uuid, root: 'mmc247-' + uuid + '-root', comment: 'mmc247-' + uuid, imageFile: 'mmc247-busybox.tar', packageFile: 'container-7.24.5.npk', packageSize: 1163409, imageSize: 2226176, beforePackages: packages(await client.get('/system/package')), beforeFiles: await client.get('/file'), beforeExport: normalizedExport(await client.exportConfigScript()), stages: [] };
assert(!state.beforePackages.some(p => p.name === 'container')); assert(state.beforePackages.every(p => p.version === '7.24.5'));
assert(!state.beforeFiles.some(f => f.name === state.imageFile || f.name === state.packageFile || f.name.startsWith(state.root)));
assert(!(await client.get('/interface')).some(i => i.name === state.veth)); await control.get('/system/resource', 5000);
state.beforeExportSha256 = sha(state.beforeExport); save(state); console.log('Private native baseline captured; control CHR reachable');
} else {
const state = JSON.parse(fs.readFileSync(statePath)); assert(/^mmc247-[a-f0-9]{8}$/.test(state.veth)); assert.equal(state.root, state.veth + '-root');
if (phase === 'reboot-install') {
const files = await client.get('/file');
assert(files.some(f => f.name === state.packageFile && Number(f.size) === state.packageSize));
assert(files.some(f => f.name === state.imageFile && Number(f.size) === state.imageSize));
await control.get('/system/resource', 5000); await reboot(state, 'installReboot');
} else if (phase === 'installed') {
const p = await poll(() => client.get('/system/package', 4000), p => p.some(p => p.name === 'container' && p.version === '7.24.5' && p.disabled === 'false'));
assert.equal((await client.get('/container')).length, 0); await control.get('/system/resource', 5000);
state.afterInstallPackages = packages(p); state.stages.push({ phase, packageInstalled: true, version: '7.24.5', controlReachable: true }); save(state); console.log('Container package installed on7.24.5; native endpoint200/control reachable');
} else if (phase === 'create') {
assert(state.afterInstallPackages); assert.equal((await ownContainers(state)).length, 0);
await client.put('/interface/veth', { name: state.veth, address: '198.18.247.2/30', gateway: '198.18.247.1', comment: state.comment });
await client.put('/container', { file: state.imageFile, interface: state.veth, 'root-dir': state.root, cmd: '/bin/sleep 600', 'start-on-boot': 'false', comment: state.comment });
const containers = await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === 'stopped'); assert.equal(containers.length, 1);
state.containerId = containers[0]['.id']; state.stages.push({ phase, initialStatus: 'stopped' }); save(state);
fs.writeFileSync('/checks/state247-public.json', JSON.stringify({ serverId: state.serverId, rosId: state.containerId, interface: state.veth, comment: state.comment, root: state.root })); console.log('Own container extracted/stopped; public identity saved');
} else if (phase === 'adopt-created') {
const containers = await ownContainers(state); assert.equal(containers.length, 1); assert.equal(nativeStatus(containers[0]), 'stopped');
state.containerId = containers[0]['.id']; state.stages.push({ phase: 'create', initialStatus: 'stopped', observedNativeStoppedFlag: containers[0].stopped }); save(state);
fs.writeFileSync('/checks/state247-public.json', JSON.stringify({ serverId: state.serverId, rosId: state.containerId, name: containers[0].name, interface: state.veth, comment: state.comment, root: state.root })); console.log('Existing own extracted container adopted read-only');
} else if (phase === 'running' || phase === 'stopped') {
const containers = await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === phase, 30000);
assert.equal(containers[0]['.id'], state.containerId); state.stages.push({ phase, nativeConfirmed: true }); save(state); console.log('Native status confirmed: ' + phase);
} else if (phase === 'cleanup-network') {
const containers = await ownContainers(state); assert(containers.length <= 1);
if (containers.length) { if (nativeStatus(containers[0]) !== 'stopped') { await client.post('/container/stop', { '.id': containers[0]['.id'] }); await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === 'stopped', 30000); } await client.delete('/container/' + encodeURIComponent(containers[0]['.id'])); }
const veth = (await client.get('/interface/veth')).filter(i => i.name === state.veth); assert(veth.length <= 1);
for (const i of veth) { assert.equal(i.comment, state.comment); await client.delete('/interface/veth/' + encodeURIComponent(i['.id'])); }
const files = await client.get('/file');
const ownFile = f => { const name = String(f.name).replace(/^\/+/, ''); return name === state.imageFile || name === state.packageFile || name === state.root || name.startsWith(state.root + '/'); };
assert(!state.beforeFiles.some(ownFile), 'Never remove an original file');
const own = files.filter(ownFile).sort((a,b) => b.name.length - a.name.length);
assert(own.every(f => !f.name.split('/').includes('..')));
for (const f of own) { const current = (await client.get('/file')).find(x => x.name === f.name); if (current) await client.removeFile(current['.id']); }
assert.equal((await ownContainers(state)).length, 0); assert(!(await client.get('/interface/veth')).some(i => i.name === state.veth));
assert(!(await client.get('/file')).some(ownFile));
state.stages.push({ phase, ownObjectsAbsent: true }); save(state); console.log('Own container/veth/image/root removed');
} else if (phase === 'uninstall') {
assert(state.stages.some(s => s.phase === 'cleanup-network'));
const p = (await client.get('/system/package')).filter(p => p.name === 'container'); assert.equal(p.length, 1); assert.equal(p[0].version, '7.24.5');
await client.post('/system/package/uninstall', { '.id': p[0]['.id'] });
const scheduled = (await client.get('/system/package')).find(p => p.name === 'container'); assert(['uninstall','scheduled for uninstall'].includes(scheduled.scheduled));
await reboot(state, 'uninstallReboot');
} else if (phase === 'reboot-uninstall') {
const scheduled = (await client.get('/system/package')).find(p => p.name === 'container'); assert(scheduled && ['uninstall','scheduled for uninstall'].includes(scheduled.scheduled));
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);
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));
} else throw Error('Unsupported phase');
}
} finally { await pool.end(); }
@@ -0,0 +1,26 @@
type Status = "running" | "stopped" | "error" | "unknown"
const flags = ["running", "stopped", "error", "failed", "extracting", "downloading", "starting", "stopping"] as const
/** RouterOS REST can expose active flags instead of the legacy status field. */
export function containerRuntimeStatus(row: Record<string, unknown>): Status {
const active = new Set<string>()
for (const flag of flags) {
const value = row[flag]
if (value === undefined) continue
if (value === true || value === "true" || value === "yes") active.add(flag)
else if (value !== false && value !== "false" && value !== "no") return "unknown"
}
if (["extracting", "downloading", "starting", "stopping"].some(flag => active.has(flag))) return "unknown"
const states: Status[] = []
if (active.has("running")) states.push("running")
if (active.has("stopped")) states.push("stopped")
if (active.has("error") || active.has("failed")) states.push("error")
if (states.length > 1) return "unknown"
if (row.status !== undefined) {
if (typeof row.status !== "string") return "unknown"
const value = row.status.toLowerCase()
const legacy = value === "running" || value === "stopped" ? value : value === "error" || value === "failed" ? "error" : "unknown"
if (legacy === "unknown" || (states.length && states[0] !== legacy)) return "unknown"
return legacy
}
return states[0] ?? "unknown"
}
@@ -10,8 +10,19 @@ 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 nativeEnvLists = await readContainerServerState(server, async path => path === "/container" ? [{ ...row, envlist: undefined, envlists: "e" }] : read(path))
assert.deepEqual(nativeEnvLists.containers[0].envs, [{ key: "A", value: "B" }])
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,"")
for (const [flags, expected] of [
[{ stopped: "true" }, "stopped"], [{ running: "true" }, "running"], [{ error: true }, "error"],
[{ stopped: true, running: false }, "stopped"], [{ stopped: "true", running: "true" }, "unknown"],
[{ stopped: "true", extracting: "true" }, "unknown"], [{ stopped: "maybe" }, "unknown"],
[{ status: "running", stopped: "true" }, "unknown"], [{ running: "false", stopped: "false" }, "unknown"],
] as const) {
const result = await readContainerServerState(server, async path => path === "/container" ? [{ ".id": "*N", name: "native", ...flags }] : [])
assert.equal(result.availability, "available"); assert.equal(result.containers[0].status, expected)
}
const empty = await readContainerServerState(server, async()=>[])
assert.equal(empty.availability,"available");assert.equal(empty.containers.length,0)
for (const raw of [{containers:[]},[{}],[row,row]]) {
+8 -4
View File
@@ -2,14 +2,19 @@ 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(), mounts: z.string().optional(), mountlists: 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" })
})
@@ -39,13 +44,12 @@ export async function readContainerServerState(server: ServerRow, read: Reader):
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.envlist ?? "").split(",").map(name => name.trim()).filter(Boolean))
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 status = (row.status ?? "").toLowerCase()
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: status === "running" || status === "stopped" ? status : status === "error" || status === "failed" ? "error" : "unknown",
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 }