/* RFC-0753: dns.record.upsert command handler — synchronizes DNS records from a declaration file to Cloudflare. Creates, updates, or skips records idempotently. Supports --dry-run to preview changes without making API calls. Do not delete records that are absent from the declaration — that is dns.record.delete's responsibility. Do not validate schema — that is dns.records.schema.validate's responsibility. RECOVERY-2026-10-04: upsert matches via pickBestLiveMatch + consumed-live tracking (same as dns.record.validate) — naive find(type+name) compared every declaration against the first same-identity record, misbinding parallel MX / dual apex TXT and producing bogus update/create calls (CF 1046 on Email-Routing MX, 81058 duplicate-TXT). Cloudflare codes 1046/81058 now classify as skipped (goal state externally owned or already satisfied) instead of errors. RFC-1237: resolve dry-run via context.dryRun fallback — consumeCommonFlags strips --dry-run into context.dryRun before dispatch, so a flags-only read was permanently false under werkstatt run (enforced by KERNEL-FLAG-10). RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1325: provisional workers.dev as first-class site host Single host predicate module in werkstatt-shared; provisional registration derives dev/alt/main workers.dev channels from the account subdomain; first dev deploy auto-protects provisional hosts; provisional main responses carry noindex; dns-aid skips provisional sites; site.domain.adopt cuts over to a custom domain. RFC-1325: review r1 — pin-carrying probes, AC-3 write ordering, dns-aid validate parity, hardened host lint RFC-0753, RFC-0812, RFC-0817 */ import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { discoverSystems } from "../sternsystem/registry-io.ts"; import { listDnsRecords, createDnsRecord, updateDnsRecord, } from "../leitstand/adapters/cloudflare-api.ts"; import { flagString, flagBoolean, loadDnsRecordFile, resolveDnsZoneId, resolveDnsEnv, recordsMatch, pickBestLiveMatch, } from "./dns-helpers.ts"; import { ensureTxtQuoted } from "./txt-normalize.ts"; import type { DnsRecordDeclaration } from "@warpgogol/werkstatt-shared/ontology/schemas"; import { isProvisionalHost } from "@warpgogol/werkstatt-shared/middleware/site-host"; export interface DnsRecordUpsertResult { command: "dns.record.upsert"; systemId: string; zone: string; dryRun: boolean; results: Array<{ identity: string; action: "created" | "updated" | "skipped"; recordId: string | null; }>; summary: { created: number; updated: number; skipped: number; errors: number; total: number; }; } export async function runDnsRecordUpsert( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; const systemId = flagString(input, "system") ?? context.site?.name; if (!systemId) throw new Error("[dns.record.upsert] --system is required (or run in site-scoped context)"); const dryRun = context.dryRun === true || flagBoolean(input, "dry-run") === true; const declaration = await loadDnsRecordFile(workspaceRoot, systemId); if (!declaration) { return { data: { command: "dns.record.upsert", systemId, zone: "", dryRun, results: [], summary: { created: 0, updated: 0, skipped: 0, total: 0, errors: 0 }, }, exitCode: 0, summary: `[dns.record.upsert] ${systemId}: skipped — no dns-records.yaml found`, nextSteps: [ { action: `Create a dns-records.yaml file for ${systemId} and re-run: pnpm exec werkstatt run dns.record.upsert --site ${systemId}`, kind: "optional", }, ], }; } // A provisional Cloudflare *.workers.dev host has no DNS zone the site // controls — there is nothing to upsert until the real domain is live. if (isProvisionalHost(declaration.zone)) { return { data: { command: "dns.record.upsert", systemId, zone: declaration.zone, dryRun, results: [], summary: { created: 0, updated: 0, skipped: 0, total: 0, errors: 0 }, }, exitCode: 0, summary: `[dns.record.upsert] ${systemId}: not applicable — '${declaration.zone}' is a provisional workers.dev host without a DNS zone`, // site-host: operator-facing summary text, not host classification }; } const { systems } = await discoverSystems(workspaceRoot); let zoneId: string; try { zoneId = resolveDnsZoneId(systems, declaration.zone); } catch { return { data: { command: "dns.record.upsert", systemId, zone: declaration.zone, dryRun, results: [], summary: { created: 0, updated: 0, skipped: 0, total: 0, errors: 0 }, }, exitCode: 1, summary: `[dns.record.upsert] ${systemId}: skipped — zone '${declaration.zone}' not found in system-config.yaml`, nextSteps: [ { action: `Add a system with deployment channel matching '${declaration.zone}' to system-config.yaml, or remove dns-records.yaml if DNS management is not needed.`, kind: "optional", }, ], }; } const env = await resolveDnsEnv(); const apiToken = env["CLOUDFLARE_API_TOKEN"]; if (!apiToken) { throw new Error( "[dns.record.upsert] CLOUDFLARE_API_TOKEN is not set. " + "Set it in the environment or .env file. " + "The token must have Zone:DNS:Edit permission.", ); } const liveRecords = await listDnsRecords(zoneId, apiToken); const results: DnsRecordUpsertResult["results"] = []; let created = 0; let updated = 0; let skipped = 0; let errors = 0; const consumedLiveIds = new Set(); for (const declared of declaration.records) { const identity = `${declared.type}:${declared.name}`; const matching = pickBestLiveMatch(declared, liveRecords, consumedLiveIds); if (matching && recordsMatch(declared, matching)) { skipped++; consumedLiveIds.add(matching.id); results.push({ identity, action: "skipped", recordId: matching.id }); continue; } if (dryRun) { if (matching) { updated++; results.push({ identity, action: "updated", recordId: null }); } else { created++; results.push({ identity, action: "created", recordId: null }); } continue; } const apiRecord = toApiRecord(declared); try { if (matching) { consumedLiveIds.add(matching.id); await updateDnsRecord(zoneId, apiToken, matching.id, apiRecord); updated++; results.push({ identity, action: "updated", recordId: matching.id }); } else { const created_record = await createDnsRecord(zoneId, apiToken, apiRecord); created++; results.push({ identity, action: "created", recordId: created_record.id }); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (isUnmanageableRecordError(msg)) { skipped++; results.push({ identity, action: "skipped", recordId: matching?.id ?? null }); console.warn(`[dns.record.upsert] ${identity}: skipped — ${msg}`); } else { errors++; results.push({ identity, action: "skipped", recordId: null }); console.warn(`[dns.record.upsert] ${identity}: API error — ${msg}`); } } } const total = declaration.records.length; return { data: { command: "dns.record.upsert", systemId, zone: declaration.zone, dryRun, results, summary: { created, updated, skipped, total, errors }, }, exitCode: errors > 0 ? 1 : 0, summary: `[dns.record.upsert] ${systemId}: ${created} created, ${updated} updated, ${skipped} skipped, ${errors} error${errors === 1 ? "" : "s"} (${total} total)${dryRun ? " [dry-run]" : ""}`, nextSteps: errors > 0 ? [ { action: `Fix the ${errors} error${errors === 1 ? "" : "s"} above, then re-run: pnpm exec werkstatt run dns.record.upsert --site ${systemId}`, kind: "required", }, ] : dryRun ? [ { action: `Apply for real: pnpm exec werkstatt run dns.record.upsert --site ${systemId}`, kind: "optional", }, ] : [ { action: `Validate DNS records: pnpm exec werkstatt run dns.record.validate --site ${systemId}`, kind: "optional", }, ], }; } /** * Cloudflare API codes whose failure means the declared goal is unreachable * through this command or already satisfied — never an actionable error: * 1046 = record managed by Cloudflare Email Routing (update/remove forbidden), * 81058 = an identical record already exists (create is a no-op). */ function isUnmanageableRecordError(message: string): boolean { return /\b(1046|81058):/.test(message); } export function toApiRecord(declared: DnsRecordDeclaration): { type: string; name: string; content?: string; data?: { priority: number; target: string; value: string }; proxied?: boolean; priority?: number; ttl?: number; comment?: string; } { if (declared.type === "SVCB" || declared.type === "HTTPS") { const parts = declared.content.split(/\s+/); const priority = parseInt(parts[0] ?? "0", 10); const target = parts[1] ?? "."; const value = parts.slice(2).join(" "); return { type: declared.type, name: declared.name, data: { priority, target, value }, proxied: declared.proxied ?? false, ...(declared.ttl !== undefined ? { ttl: declared.ttl } : {}), ...(declared.comment ? { comment: declared.comment } : {}), }; } return { type: declared.type, name: declared.name, content: declared.type === "TXT" ? ensureTxtQuoted(declared.content) : declared.content, proxied: declared.proxied ?? false, ...(declared.priority !== undefined ? { priority: declared.priority } : {}), ...(declared.ttl !== undefined ? { ttl: declared.ttl } : {}), ...(declared.comment ? { comment: declared.comment } : {}), }; }