fix(certificates): сохранить достоверный результат выпуска
This commit is contained in:
1 parent
16fa6eb5d8
commit
46fe38321a
10 files changed
+208
-101
No files matched your search
@@ -30,7 +30,7 @@
|
|||||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
|
"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: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: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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
|
|||||||
@@ -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, step: "unexpected" }))
|
||||||
assert.throws(() => mapCertificateIssueJob({ ...row, domainNames: [null] }))
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -7,7 +7,7 @@ export function mapCertificateIssueJob(row: CertificateIssueJobRow) {
|
|||||||
return certificateIssueJobSchema.parse({
|
return certificateIssueJobSchema.parse({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
status: row.status,
|
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),
|
serverId: String(row.serverId),
|
||||||
certName: row.certName,
|
certName: row.certName,
|
||||||
domainNames: parseJsonArray(row.domainNames),
|
domainNames: parseJsonArray(row.domainNames),
|
||||||
|
|||||||
@@ -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<Parameters<typeof createCertificateIssueRunner>[0]["updateJob"]>[1]) => { writes.push(patch); Object.assign(row, patch) },
|
||||||
|
issue: async (_input: Parameters<Parameters<typeof createCertificateIssueRunner>[0]["issue"]>[0]) => { calls += 1 },
|
||||||
|
appendEvent: async (input: Parameters<Parameters<typeof createCertificateIssueRunner>[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<void>(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)
|
||||||
|
})
|
||||||
@@ -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<S> = Omit<Parameters<typeof issueCertificateWithCloudflareDns>[0], "server"> & { server: S }
|
||||||
|
type Dependencies<S> = {
|
||||||
|
getJob: (id: string) => Promise<CertificateIssueJobRow | null>
|
||||||
|
getServer: (id: number) => Promise<S | null>
|
||||||
|
updateJob: typeof updateIssueJobRecord
|
||||||
|
issue: (input: IssueInput<S>) => Promise<void>
|
||||||
|
appendEvent: typeof appendEvent
|
||||||
|
warn: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Separate the network outcome from progress/event persistence failures. */
|
||||||
|
export function createCertificateIssueRunner<S extends { id: number; name: string }>(deps: Dependencies<S>) {
|
||||||
|
const runningJobs = new Set<string>()
|
||||||
|
const report = (error: unknown) => { deps.warn(error) }
|
||||||
|
const event = async (input: Parameters<typeof appendEvent>[0]) => {
|
||||||
|
try { await deps.appendEvent(input) } catch (error) { report(error) }
|
||||||
|
}
|
||||||
|
async function run(jobId: string): Promise<void> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -1,100 +1,15 @@
|
|||||||
import { parseJsonArray } from "../db/json.js"
|
|
||||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
import { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js"
|
import { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js"
|
||||||
import {
|
import { getIssueJobRecord, getServerRowByIdString, updateIssueJobRecord } from "./certificates-service.js"
|
||||||
getIssueJobRecord,
|
import { createCertificateIssueRunner } from "./certificate-issue-execution.js"
|
||||||
getServerRowByIdString,
|
|
||||||
updateIssueJobRecord,
|
|
||||||
} from "./certificates-service.js"
|
|
||||||
|
|
||||||
const runningJobs = new Set<string>()
|
const runner = createCertificateIssueRunner({
|
||||||
|
getJob: getIssueJobRecord,
|
||||||
export async function runCertificateIssueJob(jobId: string): Promise<void> {
|
getServer: getServerRowByIdString,
|
||||||
if (runningJobs.has(jobId)) return
|
updateJob: updateIssueJobRecord,
|
||||||
runningJobs.add(jobId)
|
issue: issueCertificateWithCloudflareDns,
|
||||||
const row = await getIssueJobRecord(jobId)
|
appendEvent,
|
||||||
if (!row) {
|
warn: error => { console.warn("Certificate issue metadata error", error) },
|
||||||
runningJobs.delete(jobId)
|
})
|
||||||
return
|
export const runCertificateIssueJob = runner.run
|
||||||
}
|
export const queueCertificateIssueJob = runner.queue
|
||||||
|
|
||||||
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) })
|
|
||||||
}
|
|
||||||
+1
-1
@@ -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/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) |
|
| Контейнеры | [Обзор](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А ещё не приняты.
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 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Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 11А.22 — визуальная и профильная приёмка UI сертификатов (2026-10-01)
|
||||||
|
|
||||||
- Состояния таблицы/предупреждений используют semantic success/warning/destructive. Раскрытие деталей имеет native кнопку с именем, aria-expanded и клавиатурой; исходные нераспознанные даты RouterOS показываются явно без подмены нормализованной датой.
|
- Состояния таблицы/предупреждений используют semantic success/warning/destructive. Раскрытие деталей имеет native кнопку с именем, aria-expanded и клавиатурой; исходные нераспознанные даты RouterOS показываются явно без подмены нормализованной датой.
|
||||||
|
|||||||
@@ -289,3 +289,11 @@
|
|||||||
| Обзор | [ПК](screens/certificates-overview-desktop.png), [390](screens/certificates-overview-mobile.png), [320](screens/certificates-overview-narrow.png) | Свежесть → известный итог / неизвестные классификации → настройки → таблица |
|
| Обзор | [ПК](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-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 |
|
| Выпуск | [ПК](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 остаётся недоступным.
|
||||||
@@ -71,6 +71,7 @@ export const certificateIssueStepSchema = z.enum([
|
|||||||
"acme_order",
|
"acme_order",
|
||||||
"dns_challenge",
|
"dns_challenge",
|
||||||
"finalize",
|
"finalize",
|
||||||
|
"dns_a_sync",
|
||||||
"import",
|
"import",
|
||||||
"cleanup",
|
"cleanup",
|
||||||
"done",
|
"done",
|
||||||
|
|||||||
Reference in new issue
Block a user