From 46fe38321ae6be09c68fa6a92edd3b32a4629a72 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 14:50:44 +0700 Subject: [PATCH] =?UTF-8?q?fix(certificates):=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82=20=D0=B2=D1=8B=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- .../services/certificate-issue-dto.test.ts | 6 + backend/src/services/certificate-issue-dto.ts | 2 +- .../certificate-issue-execution.test.ts | 93 +++++++++++++++ .../services/certificate-issue-execution.ts | 76 ++++++++++++ .../src/services/certificate-issue-runner.ts | 109 ++---------------- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 +- docs/ux-ui-stage-11a.md | 8 ++ packages/contracts/src/certificates.ts | 1 + 10 files changed, 208 insertions(+), 101 deletions(-) create mode 100644 backend/src/services/certificate-issue-execution.test.ts create mode 100644 backend/src/services/certificate-issue-execution.ts diff --git a/backend/package.json b/backend/package.json index 00cc60c0..866876bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,7 +30,7 @@ "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:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts", - "test:certificates": "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: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" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/backend/src/services/certificate-issue-dto.test.ts b/backend/src/services/certificate-issue-dto.test.ts index 07db7eac..ef92d327 100644 --- a/backend/src/services/certificate-issue-dto.test.ts +++ b/backend/src/services/certificate-issue-dto.test.ts @@ -21,3 +21,9 @@ test("invalid state and malformed domain values do not become successful DTOs", assert.throws(() => mapCertificateIssueJob({ ...row, step: "unexpected" })) assert.throws(() => mapCertificateIssueJob({ ...row, domainNames: [null] })) }) + +test("DNS phase and legacy diagnostics remain readable through the contract", () => { + for (const step of ["dns_a_sync", "dns_a_sync_warn:DNS unavailable", "dns_a_sync_skip_cname:test.example"]) { + assert.equal(mapCertificateIssueJob({ ...row, status: "running", step }).step, "dns_a_sync") + } +}) diff --git a/backend/src/services/certificate-issue-dto.ts b/backend/src/services/certificate-issue-dto.ts index a4af36d9..2b203284 100644 --- a/backend/src/services/certificate-issue-dto.ts +++ b/backend/src/services/certificate-issue-dto.ts @@ -7,7 +7,7 @@ export function mapCertificateIssueJob(row: CertificateIssueJobRow) { return certificateIssueJobSchema.parse({ id: row.id, status: row.status, - step: row.step, + step: row.step.startsWith("dns_a_sync_warn:") || row.step.startsWith("dns_a_sync_skip_cname:") ? "dns_a_sync" : row.step, serverId: String(row.serverId), certName: row.certName, domainNames: parseJsonArray(row.domainNames), diff --git a/backend/src/services/certificate-issue-execution.test.ts b/backend/src/services/certificate-issue-execution.test.ts new file mode 100644 index 00000000..4de7896c --- /dev/null +++ b/backend/src/services/certificate-issue-execution.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import type { CertificateIssueJobRow } from "../db/schema.js" +import { createCertificateIssueRunner } from "./certificate-issue-execution.js" + +function fixture() { + const row: CertificateIssueJobRow = { id: "job", serverId: 1, status: "queued", step: "queued", source: "manual", certName: "cert", domainNames: ["test.example"], keyType: "rsa2048", trustStore: "www,api", requestedAt: "2026-10-01T00:00:00Z", startedAt: null, finishedAt: null, error: null } + const writes: Array<{ status?: string; step?: string }> = [] + const events: string[] = [], warnings: unknown[] = [] + let calls = 0 + const deps = { + getJob: async () => row, + getServer: async () => ({ id: 1, name: "router" }), + updateJob: async (_id: string, patch: Parameters[0]["updateJob"]>[1]) => { writes.push(patch); Object.assign(row, patch) }, + issue: async (_input: Parameters[0]["issue"]>[0]) => { calls += 1 }, + appendEvent: async (input: Parameters[0]["appendEvent"]>[0]) => { events.push(input.eventType) }, + warn: (error: unknown) => { warnings.push(error) }, + } + return { row, writes, events, warnings, deps, calls: () => calls } +} + +test("completed operation remains done when event storage fails", async () => { + const f = fixture(); f.deps.appendEvent = async () => { throw new Error("event unavailable") } + await createCertificateIssueRunner(f.deps).run("job") + assert.equal(f.row.status, "done"); assert.equal(f.row.error, null); assert.equal(f.calls(), 1); assert.equal(f.warnings.length, 1) +}) +test("scheduler start event failure does not prevent the issue", async () => { + const f = fixture(); f.row.source = "scheduler" + f.deps.appendEvent = async () => { throw new Error("event unavailable") } + await createCertificateIssueRunner(f.deps).run("job") + assert.equal(f.calls(), 1); assert.equal(f.row.status, "done"); assert.equal(f.warnings.length, 2) +}) +test("actual import failure remains failed despite event storage failure", async () => { + const f = fixture(); f.deps.issue = async input => { input.onStep?.("cleanup"); throw new Error("RouterOS import failed") } + f.deps.appendEvent = async () => { throw new Error("event unavailable") } + await createCertificateIssueRunner(f.deps).run("job") + assert.equal(f.row.status, "failed"); assert.equal(f.row.step, "failed"); assert.equal(f.row.error, "RouterOS import failed") +}) +test("progress is serialized and flushed before terminal status", async () => { + const f = fixture(); const update = f.deps.updateJob + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + f.deps.updateJob = async (id, patch) => { if (patch.step === "dns_challenge") await gate; await update(id, patch) } + f.deps.issue = async input => { input.onStep?.("dns_challenge"); input.onStep?.("dns_a_sync"); input.onStep?.("import"); input.onStep?.("cleanup") } + const task = createCertificateIssueRunner(f.deps).run("job") + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.row.status, "running"); assert.deepEqual(f.writes.map(write => write.step), ["acme_order"]) + release(); await task + assert.deepEqual(f.writes.map(write => write.step), ["acme_order", "dns_challenge", "dns_a_sync", "import", "cleanup", "done"]) +}) +test("DNS diagnostics and progress failures do not poison terminal state", async () => { + const f = fixture(); const update = f.deps.updateJob + f.deps.updateJob = async (id, patch) => { if (patch.step === "import") throw new Error("progress storage failed"); await update(id, patch) } + f.deps.issue = async input => { input.onStep?.("dns_a_sync"); input.onStep?.("dns_a_sync_warn:DNS unavailable"); input.onStep?.("dns_a_sync_skip_cname:test.example"); input.onStep?.("import"); input.onStep?.("cleanup") } + await createCertificateIssueRunner(f.deps).run("job") + assert.equal(f.row.status, "done"); assert.equal(f.row.step, "done"); assert.equal(f.warnings.length, 1) + assert.equal(f.events.filter(value => value === "certificates.issue.dns_a_sync").length, 2) + assert.ok(f.writes.every(write => !write.step?.includes(":"))) +}) +test("setup failure releases process lock and permits a safe queued retry", async () => { + const f = fixture(); let unavailable = true + f.deps.getJob = async () => { if (unavailable) throw new Error("DB unavailable"); return f.row } + const runner = createCertificateIssueRunner(f.deps) + await assert.rejects(runner.run("job"), /DB unavailable/) + unavailable = false; await runner.run("job") + assert.equal(f.calls(), 1); assert.equal(f.row.status, "done") +}) +test("done, failed and running records never replay network operations", async () => { + for (const status of ["done", "failed", "running"] as const) { + const f = fixture(); f.row.status = status + await createCertificateIssueRunner(f.deps).run("job") + assert.equal(f.calls(), 0); assert.equal(f.writes.length, 0) + } +}) +test("concurrent dispatch runs a queued job once", async () => { + const f = fixture(), runner = createCertificateIssueRunner(f.deps) + await Promise.all([runner.run("job"), runner.run("job")]) + assert.equal(f.calls(), 1) +}) +test("success persistence failure never writes failed or retries import", async () => { + const f = fixture(); const update = f.deps.updateJob + f.deps.updateJob = async (id, patch) => { if (patch.status === "done") throw new Error("terminal DB unavailable"); await update(id, patch) } + const runner = createCertificateIssueRunner(f.deps) + await assert.rejects(runner.run("job"), /terminal DB unavailable/) + await runner.run("job") + assert.equal(f.calls(), 1); assert.equal(f.row.status, "running"); assert.ok(f.writes.every(write => write.status !== "failed")) +}) +test("queue handles rejected setup without unhandled rejection", async () => { + const f = fixture(); f.deps.getJob = async () => { throw new Error("DB unavailable") } + createCertificateIssueRunner(f.deps).queue("job") + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.warnings.length, 1); assert.equal(f.calls(), 0) +}) diff --git a/backend/src/services/certificate-issue-execution.ts b/backend/src/services/certificate-issue-execution.ts new file mode 100644 index 00000000..c56ecc02 --- /dev/null +++ b/backend/src/services/certificate-issue-execution.ts @@ -0,0 +1,76 @@ +import { certificateIssueStepSchema } from "@mmapp/contracts/certificates" +import type { CertificateIssueJobRow } from "../db/schema.js" +import type { appendEvent } from "../modules/events/service/events-service.js" +import type { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js" +import type { updateIssueJobRecord } from "./certificates-service.js" +import { parseJsonArray } from "../db/json.js" + +type IssueInput = Omit[0], "server"> & { server: S } +type Dependencies = { + getJob: (id: string) => Promise + getServer: (id: number) => Promise + updateJob: typeof updateIssueJobRecord + issue: (input: IssueInput) => Promise + appendEvent: typeof appendEvent + warn: (error: unknown) => void +} + +/** Separate the network outcome from progress/event persistence failures. */ +export function createCertificateIssueRunner(deps: Dependencies) { + const runningJobs = new Set() + const report = (error: unknown) => { deps.warn(error) } + const event = async (input: Parameters[0]) => { + try { await deps.appendEvent(input) } catch (error) { report(error) } + } + async function run(jobId: string): Promise { + if (runningJobs.has(jobId)) return + runningJobs.add(jobId) + try { + const row = await deps.getJob(jobId) + // A running record from a previous process has an unknown outcome: never replay it. + if (!row || row.status !== "queued") return + const server = await deps.getServer(row.serverId) + if (!server) { + await deps.updateJob(jobId, { status: "failed", step: "failed", finishedAt: new Date().toISOString(), error: "Сервер не найден" }) + return + } + const domainNames = parseJsonArray(row.domainNames).map(String) + const trustStore = row.trustStore.split(",").map(value => value.trim()).filter(Boolean) + const scheduler = row.source === "scheduler" + const context = { sourceModule: "certificates" as const, entityType: "server", entityId: String(server.id) } + const target = [row.certName, domainNames.join(", "), server.name].join(" · ") + await deps.updateJob(jobId, { status: "running", step: "acme_order", startedAt: new Date().toISOString(), error: null }) + if (scheduler) await event({ ...context, level: "info", eventType: "certificates.renew.started", title: "Запущено автообновление сертификата", message: target }) + let progress = Promise.resolve() + const onStep = (step: string) => { + progress = progress.then(async () => { + // Older ACME callbacks encode non-fatal DNS diagnostics in the step string. + if (step.startsWith("dns_a_sync_warn:") || step.startsWith("dns_a_sync_skip_cname:")) { + await event({ ...context, level: step.startsWith("dns_a_sync_warn:") ? "warning" : "info", eventType: "certificates.issue.dns_a_sync", title: "Синхронизация DNS сертификата", message: step.slice(step.indexOf(":") + 1) }) + return + } + const parsed = certificateIssueStepSchema.safeParse(step) + if (!parsed.success || ["queued", "done", "failed"].includes(step)) { + report(new Error("Неизвестный шаг выпуска: " + step)) + return + } + await deps.updateJob(jobId, { step: parsed.data }) + }).catch(report) + } + let operationError: unknown + let failed = false + try { + await deps.issue({ server, certName: row.certName, domainNames, keyType: row.keyType === "ec256" ? "ec256" : "rsa2048", trustStore: trustStore.length ? trustStore : ["www", "api"], onStep }) + } catch (error) { failed = true; operationError = error } + await progress + const message = failed ? (operationError instanceof Error ? operationError.message : String(operationError)) : null + // Persistence errors propagate to the caller; a successful issue must never be relabelled failed. + await deps.updateJob(jobId, { status: failed ? "failed" : "done", step: failed ? "failed" : "done", finishedAt: new Date().toISOString(), error: message }) + await event({ ...context, level: failed ? "warning" : "info", eventType: "certificates." + (scheduler ? "renew." : "issue.") + (failed ? "failed" : "done"), title: failed ? (scheduler ? "Ошибка автообновления сертификата" : "Ошибка выпуска сертификата") : (scheduler ? "Сертификат обновлён" : "Сертификат выпущен"), message: message ?? target }) + } finally { runningJobs.delete(jobId) } + } + function queue(jobId: string): void { + queueMicrotask(() => { void run(jobId).catch(report) }) + } + return { run, queue } +} diff --git a/backend/src/services/certificate-issue-runner.ts b/backend/src/services/certificate-issue-runner.ts index 698b0d71..40f25106 100644 --- a/backend/src/services/certificate-issue-runner.ts +++ b/backend/src/services/certificate-issue-runner.ts @@ -1,100 +1,15 @@ -import { parseJsonArray } from "../db/json.js" import { appendEvent } from "../modules/events/service/events-service.js" import { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js" -import { - getIssueJobRecord, - getServerRowByIdString, - updateIssueJobRecord, -} from "./certificates-service.js" +import { getIssueJobRecord, getServerRowByIdString, updateIssueJobRecord } from "./certificates-service.js" +import { createCertificateIssueRunner } from "./certificate-issue-execution.js" -const runningJobs = new Set() - -export async function runCertificateIssueJob(jobId: string): Promise { - if (runningJobs.has(jobId)) return - runningJobs.add(jobId) - const row = await getIssueJobRecord(jobId) - if (!row) { - runningJobs.delete(jobId) - return - } - - const server = await getServerRowByIdString(row.serverId) - if (!server) { - await updateIssueJobRecord(jobId, { - status: "failed", - step: "failed", - finishedAt: new Date().toISOString(), - error: "Сервер не найден", - }) - runningJobs.delete(jobId) - return - } - - const domainNames = parseJsonArray(row.domainNames).map(String) - - const trustStore = row.trustStore.split(",").map((s) => s.trim()).filter(Boolean) - const startedAt = new Date().toISOString() - const isScheduler = row.source === "scheduler" - await updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null }) - - if (isScheduler) { - await appendEvent({ - level: "info", - eventType: "certificates.renew.started", - sourceModule: "certificates", - title: "Запущено автообновление сертификата", - message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`, - entityType: "server", - entityId: String(server.id), - }) - } - - try { - await issueCertificateWithCloudflareDns({ - server, - certName: row.certName, - domainNames, - keyType: row.keyType === "ec256" ? "ec256" : "rsa2048", - trustStore: trustStore.length > 0 ? trustStore : ["www", "api"], - onStep: (step) => { void updateIssueJobRecord(jobId, { step }) }, - }) - await updateIssueJobRecord(jobId, { - status: "done", - step: "done", - finishedAt: new Date().toISOString(), - error: null, - }) - await appendEvent({ - level: "info", - eventType: isScheduler ? "certificates.renew.done" : "certificates.issue.done", - sourceModule: "certificates", - title: isScheduler ? "Сертификат обновлён" : "Сертификат выпущен", - message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`, - entityType: "server", - entityId: String(server.id), - }) - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - await updateIssueJobRecord(jobId, { - status: "failed", - step: "failed", - finishedAt: new Date().toISOString(), - error: message, - }) - await appendEvent({ - level: "warning", - eventType: isScheduler ? "certificates.renew.failed" : "certificates.issue.failed", - sourceModule: "certificates", - title: isScheduler ? "Ошибка автообновления сертификата" : "Ошибка выпуска сертификата", - message, - entityType: "server", - entityId: String(server.id), - }) - } finally { - runningJobs.delete(jobId) - } -} - -export function queueCertificateIssueJob(jobId: string): void { - queueMicrotask(() => { void runCertificateIssueJob(jobId) }) -} +const runner = createCertificateIssueRunner({ + getJob: getIssueJobRecord, + getServer: getServerRowByIdString, + updateJob: updateIssueJobRecord, + issue: issueCertificateWithCloudflareDns, + appendEvent, + warn: error => { console.warn("Certificate issue metadata error", error) }, +}) +export const runCertificateIssueJob = runner.run +export const queueCertificateIssueJob = runner.queue diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index c65ef307..e02a85db 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Актуальная точка продолжения: 11А.23 — достоверность результата issue runner, затем окончательная приёмка сертификатов и резервные копии. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.24 — окончательная локальная приёмка сертификатов и строгие API-контракты резервных копий. Issue runner исправлен в 11А.23. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 290f9ba1..98e84e7e 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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А.22; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.23 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.23; пользователи/контейнеры и UI сертификатов приняты локально, runner исправлен; далее 11А.24 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11А.23 — достоверный результат backend issue runner (2026-10-01) + +- События и ошибки записи прогресса отделены от результата ACME/RouterOS. Ошибка appendEvent после успеха не записывает failed; ошибка итоговой записи остаётся ошибкой наблюдения, без повторного импорта. Очередь перехватывает setup rejection, process lock освобождается при любой ошибке. +- onStep записи выполняются последовательно и завершаются перед terminal. Done/failed/running записи не запускают сетевую операцию заново; восстановление running после перезапуска требует отдельной инфраструктурной сверки, автоматический replay не добавлен. +- Реальная фаза dns_a_sync добавлена в контракт. Диагностические callback строки Cloudflare сохраняются отдельными событиями, старые DB строки читаются как dns_a_sync. Порядок ACME/DNS/RouterOS не изменён. +- 10 fault unit runner, 5 DTO unit, полный backend test:certificates/build, 14 frontend API/poll/checkpoint unit, TypeScript и профильный ESLint прошли. PostgreSQL/ACME/RouterOS остаются внешней приёмкой; lock действует в одном процессе, распределённый lease не реализован. +- **Следующий блок 11А.24:** закрыть локальную приёмку сертификатов с явными ограничениями и начать строгие API-контракты резервных копий. JSON metadata snapshot необязателен; PEM/private key import/export остаётся недоступным. + ## Блок 11А.22 — визуальная и профильная приёмка UI сертификатов (2026-10-01) - Состояния таблицы/предупреждений используют semantic success/warning/destructive. Раскрытие деталей имеет native кнопку с именем, aria-expanded и клавиатурой; исходные нераспознанные даты RouterOS показываются явно без подмены нормализованной датой. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index f3d01484..524c9c88 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -289,3 +289,11 @@ | Обзор | [ПК](screens/certificates-overview-desktop.png), [390](screens/certificates-overview-mobile.png), [320](screens/certificates-overview-narrow.png) | Свежесть → известный итог / неизвестные классификации → настройки → таблица | | Детали | [ПК](screens/certificates-details-desktop.png), [390](screens/certificates-details-mobile.png), [320](screens/certificates-details-narrow.png) | Фильтр → native раскрытие → поля и unknown → исходные даты | | Выпуск | [ПК](screens/certificates-issue-desktop.png), [390](screens/certificates-issue-mobile.png), [320](screens/certificates-issue-narrow.png) | Цель → проверка → requestId → та же задача → подтверждение API | + +## Блок 11А.23 — достоверный результат backend issue runner (2026-10-01) + +- События и ошибки записи прогресса отделены от результата ACME/RouterOS. Ошибка appendEvent после успеха не записывает failed; ошибка итоговой записи остаётся ошибкой наблюдения, без повторного импорта. Очередь перехватывает setup rejection, process lock освобождается при любой ошибке. +- onStep записи выполняются последовательно и завершаются перед terminal. Done/failed/running записи не запускают сетевую операцию заново; восстановление running после перезапуска требует отдельной инфраструктурной сверки, автоматический replay не добавлен. +- Реальная фаза dns_a_sync добавлена в контракт. Диагностические callback строки Cloudflare сохраняются отдельными событиями, старые DB строки читаются как dns_a_sync. Порядок ACME/DNS/RouterOS не изменён. +- 10 fault unit runner, 5 DTO unit, полный backend test:certificates/build, 14 frontend API/poll/checkpoint unit, TypeScript и профильный ESLint прошли. PostgreSQL/ACME/RouterOS остаются внешней приёмкой; lock действует в одном процессе, распределённый lease не реализован. +- **Следующий блок 11А.24:** закрыть локальную приёмку сертификатов с явными ограничениями и начать строгие API-контракты резервных копий. JSON metadata snapshot необязателен; PEM/private key import/export остаётся недоступным. diff --git a/packages/contracts/src/certificates.ts b/packages/contracts/src/certificates.ts index dbb33574..c87b8106 100644 --- a/packages/contracts/src/certificates.ts +++ b/packages/contracts/src/certificates.ts @@ -71,6 +71,7 @@ export const certificateIssueStepSchema = z.enum([ "acme_order", "dns_challenge", "finalize", + "dns_a_sync", "import", "cleanup", "done",