import { databaseBackgroundWork } from "./database-background-work.js" 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 { void databaseBackgroundWork.track(Promise.resolve().then(() => run(jobId))).catch(report) } return { run, queue } }