// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. preflight module for scripts/src/ci.ts. // Moved mechanically from scripts/src/ci.ts:592-750 for #903. import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, posix as posixPath } from "node:path"; import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "../ci-catalog"; import { runCommand } from "../command"; import { type UniDeskConfig, repoRoot, rootPath } from "../config"; import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity"; import { jobWithTail, listJobs, readJob, startJob } from "../jobs"; import { coreInternalFetch } from "../microservices"; import { artifactRegistryReadonlyResultFromCommand, buildArtifactRegistryReadonlyProbe, parseArtifactRegistryOptions, type ArtifactRegistryReadonlyProbe, } from "../artifact-registry"; import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard"; import { runSshCommandCapture } from "../ssh"; import type { DispatchResult, PublishPreflight, PublishPreflightChannelProbe, PublishPreflightControlChannel, PublishPreflightControlChannelProbe, PublishPreflightDetailedChannel, PublishPreflightFailureClassification, PublishPreflightTransport } from "./types"; import { status } from "./cleanup"; import { d601ProviderId } from "./types"; export function chunks(value: string, size: number): string[] { const result: string[] = []; for (let index = 0; index < value.length; index += size) { result.push(value.slice(index, index + size)); } return result; } export const hostSshBase64UploadChunkChars = 3000; export function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } export function asString(value: unknown): string { return typeof value === "string" ? value : ""; } export function coreBody(response: unknown): Record | null { return asRecord(asRecord(response)?.body); } export function emitCiInstallProgress(stage: string, status: "started" | "skipped" | "succeeded" | "failed", detail: Record = {}): void { console.error(JSON.stringify({ event: "ci.install.progress", at: new Date().toISOString(), stage, status, ...detail, })); } export function responseOk(response: unknown): boolean { if (typeof response !== "object" || response === null) return false; const record = response as Record; if ("ok" in record && record.ok === false) return false; const body = asRecord(record.body); if (body !== null && "ok" in body && body.ok === false) return false; return true; } export function channelProbe( channel: PublishPreflightDetailedChannel, controlChannel: PublishPreflightControlChannel, ok: boolean, requiredFor: string, detail: unknown, ): PublishPreflightChannelProbe { return { channel, controlChannel, ok, requiredFor, detail }; } export const publishPreflightControlChannelOrder: PublishPreflightControlChannel[] = ["backend-core", "database", "provider", "registry"]; export function summarizePublishControlChannels(channels: PublishPreflightChannelProbe[]): PublishPreflightControlChannelProbe[] { return publishPreflightControlChannelOrder.map((channel) => { const probes = channels.filter((item) => item.controlChannel === channel); return { channel, ok: probes.length > 0 && probes.every((item) => item.ok), requiredFor: Array.from(new Set(probes.map((item) => item.requiredFor))), probes: probes.map((item) => item.channel), }; }); } export function backendCoreUnavailable(value: unknown): boolean { const record = asRecord(value); const body = coreBody(value); if (record?.runnerDisposition === "infra-blocked") return true; if (record?.failureKind === "target-stack-not-running") return true; if (body?.runnerDisposition === "infra-blocked") return true; if (body?.failureKind === "target-stack-not-running") return true; if (body?.degradedReason === "backend-core-container-missing") return true; const text = JSON.stringify(value) ?? ""; return text.includes("No such container: unidesk-backend-core") || text.includes("No such container: unidesk-database"); } export function transportKind(transport: PublishPreflightTransport): "local-docker" | "remote-frontend" | "provider-tunnel" { return transport.kind ?? "local-docker"; } export function classifyPublishPreflightFailure( transport: PublishPreflightTransport, overview: unknown, sshProbe: DispatchResult, registry: unknown, ): PublishPreflightFailureClassification | null { const kind = transportKind(transport); const overviewRecord = asRecord(overview); if (overviewRecord?.failureClassification === "auth-missing") return "auth-missing"; if (overviewRecord?.failureClassification === "remote-proxy-missing") return "remote-proxy-missing"; if (overviewRecord?.failureClassification === "provider-unreachable") return "provider-unreachable"; if (kind === "local-docker" && backendCoreUnavailable(overview)) return "local-docker-required"; if (kind !== "local-docker" && !responseOk(overview)) return "remote-proxy-missing"; if (!sshProbe.ok) return "provider-unreachable"; const registryRecord = asRecord(registry); if (registryRecord?.failureClassification === "local-docker-required") return "local-docker-required"; if (registryRecord?.failureClassification === "provider-ssh-command-missing") return "provider-unreachable"; if (registryRecord?.failureClassification === "ssh-helper-command-shape-incompatible") return "ssh-helper-command-shape-incompatible"; if (registryRecord?.failureClassification === "remote-command-timeout") return "remote-command-timeout"; if (registryRecord?.failureClassification === "registry-not-installed") return "registry-not-installed"; if (registryRecord?.failureClassification === "registry-unhealthy") return "registry-unhealthy"; if (registryRecord?.ok === false) return kind === "local-docker" ? "provider-unreachable" : "registry-unhealthy"; return null; } export function recommendedPublishPreflightAction( failureClassification: PublishPreflightFailureClassification | null, registry: unknown, missingControlChannels: PublishPreflightControlChannel[], ): string { if (failureClassification === null && missingControlChannels.length === 0) return "none"; const registryRecommendedAction = asString(asRecord(registry)?.recommendedAction); if (registryRecommendedAction.length > 0 && registryRecommendedAction !== "none") return registryRecommendedAction; if (failureClassification === "local-docker-required") return "rerun this read-only preflight through --main-server-ip or from a main-server CLI with backend-core available"; if (failureClassification === "auth-missing") return "restore frontend control-plane authentication before rerunning the dry-run preflight"; if (failureClassification === "remote-proxy-missing") return "restore frontend to backend-core proxy reachability before rerunning the dry-run preflight"; if (failureClassification === "provider-unreachable") return "restore D601 provider-gateway host.ssh reachability before rerunning the dry-run preflight"; if (failureClassification === "ssh-helper-command-shape-incompatible") return "upgrade or shorten the host.ssh readonly command shape before rerunning the dry-run preflight"; if (failureClassification === "remote-command-timeout") return "rerun artifact-registry health and inspect D601 host.ssh latency if the timeout repeats"; if (failureClassification === "registry-not-installed") return "install the D601 artifact registry through the controlled artifact-registry install path, then rerun health"; if (failureClassification === "registry-unhealthy") return "restore the D601 artifact registry service/container/API until artifact-registry health passes"; if (failureClassification === "ci-runner-not-ready") return "restore D601 unidesk-ci Tekton runner prerequisites before authorizing backend-core artifact publication"; if (missingControlChannels.includes("registry")) return "restore or install the D601 artifact registry until artifact-registry health passes"; return `restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}`; } export function publishPreflightControlPlane( transport: PublishPreflightTransport, failureClassification: PublishPreflightFailureClassification | null, remoteCommandShape = "bun scripts/cli.ts --main-server-ip ci publish-user-service --service --commit --dry-run", ): Record { const kind = transportKind(transport); const remoteCapable = kind !== "local-docker"; return { transport: kind, remoteCapable, remoteHost: transport.remoteHost ?? null, localDockerRequired: kind === "local-docker", failureClassification, preferredRunnerPath: "frontend-private-proxy", fallbackRunnerPath: "main-server-local-docker", remoteCommandShape, providerTunnel: { providerId: d601ProviderId, command: "host.ssh", requiredFor: ["provider-host-ssh", "artifact-registry"], }, }; } export function publishPreflightFailedScopes(preflight: PublishPreflight): string[] { if (preflight.ok) return []; const registryScopes = asRecord(preflight.registry)?.failedScopes; if (Array.isArray(registryScopes)) return registryScopes.map(String); return preflight.missingChannels; }