// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. publish-preflight module for scripts/src/ci.ts. // SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets. // Moved mechanically from scripts/src/ci.ts:2317-2567 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 { k3sGuardShellLines, defaultNativeKubeconfig } from "../k3s-target-guard"; import { runSshCommandCapture } from "../ssh"; import type { ArtifactSummary, ArtifactSummaryContext, CiPublishBackendCoreOptions, CiPublishUserServiceArtifactOptions, PublishPreflight, PublishPreflightChannelProbe, PublishPreflightTransport } from "./types"; import { completeArtifactSummaryFromRegistry, missingArtifactSummaryFields, parseArtifactSummaryFromFields, parseArtifactSummaryFromOutput } from "./artifact-summary"; import { status } from "./cleanup"; import { shellQuote } from "./options"; import { asRecord, asString, backendCoreUnavailable, channelProbe, classifyPublishPreflightFailure, coreBody, publishPreflightControlPlane, recommendedPublishPreflightAction, responseOk, summarizePublishControlChannels } from "./preflight"; import { runRemoteKubectlRaw } from "./remote"; import { backendCoreCiRunnerReady, backendCoreRemoteCommandShape, ciRunnerPreflightScript, commandResultFromDispatch, dispatchPreflightFailure } from "./runner-preflight"; import { ciTarget, defaultCiKubeconfig, defaultCiProviderId } from "./types"; export function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void { const missing = missingArtifactSummaryFields(artifact); if (missing.length > 0) { throw new Error(`artifact summary for ${pipelineRun} is missing required field(s): ${missing.join(", ")}`); } } export async function publishUserServicePreflight( _config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions, plannedArtifact: ArtifactSummary, transport: PublishPreflightTransport, ): Promise { const providerId = defaultCiProviderId; const channels: PublishPreflightChannelProbe[] = []; const overview = await transport.coreFetch("/api/overview", { maxResponseBytes: 500_000 }); const overviewBody = coreBody(overview); const backendCoreOk = responseOk(overview) && overviewBody?.dbReady === true; channels.push(channelProbe("backend-core-api", "backend-core", backendCoreOk, "dispatch API, provider catalog, task polling, and database-backed CI state", { ok: responseOk(overview), dbReady: overviewBody?.dbReady ?? null, runnerDisposition: asRecord(overview)?.runnerDisposition ?? null, failureKind: asRecord(overview)?.failureKind ?? null, detail: backendCoreUnavailable(overview) ? overview : { status: asRecord(overview)?.status ?? null, body: overviewBody, }, })); channels.push(channelProbe("database", "database", backendCoreOk, "backend-core task dispatch, provider state, Tekton task polling, and source identity lookup", { dbReady: overviewBody?.dbReady ?? false, observedThrough: "backend-core /api/overview", })); const probeScript = [ "set -euo pipefail", ...k3sGuardShellLines(defaultCiKubeconfig), "printf 'provider_host_ssh=ok\\n'", "command -v bash >/dev/null", "command -v docker >/dev/null", "command -v kubectl >/dev/null", "test -S /var/run/docker.sock || test -S /run/docker.sock || true", ].join("\n"); const sshProbe = await transport.dispatchHostSsh(probeScript, 30_000, 15_000); channels.push(channelProbe("provider-dispatch", "provider", sshProbe.taskId !== null || sshProbe.ok, "backend-core /api/dispatch can create D601 host.ssh tasks", { taskId: sshProbe.taskId, status: sshProbe.status, ok: sshProbe.taskId !== null || sshProbe.ok, exitCode: sshProbe.exitCode, stderrTail: sshProbe.stderr.slice(-1200), })); channels.push(channelProbe("provider-host-ssh", "provider", sshProbe.ok, "D601 source export, registry checks, kubectl/Tekton submission, and artifact summary reads", { taskId: sshProbe.taskId, status: sshProbe.status, exitCode: sshProbe.exitCode, stdoutTail: sshProbe.stdout.slice(-1200), stderrTail: sshProbe.stderr.slice(-1200), raw: sshProbe.ok ? undefined : dispatchPreflightFailure("host.ssh readonly probe", sshProbe).raw, })); const registryOptions = parseArtifactRegistryOptions(["--target", providerId]); const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions); const registryDispatch = await transport.dispatchHostSsh(registryProbe.script, Math.max(registryProbe.timeoutMs, 30_000), registryProbe.timeoutMs); const registryCommand = commandResultFromDispatch(transport.artifactRegistryCommand(registryProbe), transport.commandCwd, registryDispatch); const registry = artifactRegistryReadonlyResultFromCommand(registryProbe, registryCommand); const registryRecord = asRecord(registry); const registryOk = registryRecord?.ok === true || registryRecord?.runtimeApiHealthy === true; channels.push(channelProbe("artifact-registry", "registry", registryOk, "commit-pinned image push and later CD manifest checks", registry)); const missingChannels = channels.filter((item) => !item.ok).map((item) => item.channel); const controlChannels = summarizePublishControlChannels(channels); const missingControlChannels = controlChannels.filter((item) => !item.ok).map((item) => item.channel); const ready = missingChannels.length === 0; const failureClassification = ready ? null : classifyPublishPreflightFailure(transport, overview, sshProbe, registry); const recommendedAction = recommendedPublishPreflightAction(failureClassification, registry, missingControlChannels); return { ok: ready, runnerDisposition: ready ? "ready" : "infra-blocked", failureClassification, serviceId: options.serviceId, commit: options.commit, providerId, supportedArtifactPublish: true, missingChannels, missingControlChannels, controlChannels, channels, registry, controlPlane: publishPreflightControlPlane(transport, failureClassification), recommendedAction, remoteCommandShape: registryProbe.remoteCommandShape, next: ready ? [ `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`, `later CD must consume ${plannedArtifact.imageRef}; CI itself must not deploy production`, ] : [ `Restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}.`, failureClassification === "local-docker-required" ? "From a Code Queue runner, rerun this read-only preflight through the existing remote frontend transport: bun scripts/cli.ts --main-server-ip ci publish-user-service --service --commit --dry-run." : "Run from the main-server CLI or use remote frontend transport against a healthy frontend/backend-core path.", "Restore backend-core/database/provider-gateway/Host SSH connectivity before retrying artifact publication.", "Use bun scripts/cli.ts artifact-registry health --target D601 to recheck registry reachability after the control bridge is restored.", ], boundary: "preflight is read-only: no D601 source export, no Tekton PipelineRun, no image push, no deploy apply, no service restart", }; } export async function publishBackendCorePreflight( _config: UniDeskConfig, options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary, transport: PublishPreflightTransport, ): Promise { const providerId = defaultCiProviderId; const channels: PublishPreflightChannelProbe[] = []; const overview = await transport.coreFetch("/api/overview", { maxResponseBytes: 500_000 }); const overviewBody = coreBody(overview); const backendCoreOk = responseOk(overview) && overviewBody?.dbReady === true; channels.push(channelProbe("backend-core-api", "backend-core", backendCoreOk, "dispatch API, provider catalog, task polling, and database-backed CI state", { ok: responseOk(overview), dbReady: overviewBody?.dbReady ?? null, runnerDisposition: asRecord(overview)?.runnerDisposition ?? null, failureKind: asRecord(overview)?.failureKind ?? null, detail: backendCoreUnavailable(overview) ? overview : { status: asRecord(overview)?.status ?? null, body: overviewBody, }, })); channels.push(channelProbe("database", "database", backendCoreOk, "backend-core task dispatch, provider state, Tekton task polling, and source identity lookup", { dbReady: overviewBody?.dbReady ?? false, observedThrough: "backend-core /api/overview", })); const ciScript = ciRunnerPreflightScript(options.sourceHostPath); const ciProbe = await transport.dispatchHostSsh(ciScript, 30_000, 15_000); const ciRunnerReady = backendCoreCiRunnerReady(ciProbe); channels.push(channelProbe("provider-dispatch", "provider", ciProbe.taskId !== null || ciProbe.ok, "backend-core /api/dispatch can create D601 host.ssh tasks", { taskId: ciProbe.taskId, status: ciProbe.status, ok: ciProbe.taskId !== null || ciProbe.ok, exitCode: ciProbe.exitCode, stderrTail: ciProbe.stderr.slice(-1200), })); channels.push(channelProbe("provider-host-ssh", "provider", ciRunnerReady, "D601 host.ssh can observe Docker, kubectl, Tekton, PVC, and backend-core source parent without starting CI", { taskId: ciProbe.taskId, status: ciProbe.status, exitCode: ciProbe.exitCode, stdoutTail: ciProbe.stdout.slice(-1600), stderrTail: ciProbe.stderr.slice(-1200), raw: ciRunnerReady ? undefined : dispatchPreflightFailure("backend-core ci runner readonly probe", ciProbe).raw, })); const registryOptions = parseArtifactRegistryOptions(["--target", providerId]); const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions); const registryDispatch = await transport.dispatchHostSsh(registryProbe.script, Math.max(registryProbe.timeoutMs, 30_000), registryProbe.timeoutMs); const registryCommand = commandResultFromDispatch(transport.artifactRegistryCommand(registryProbe), transport.commandCwd, registryDispatch); const registry = artifactRegistryReadonlyResultFromCommand(registryProbe, registryCommand); const registryRecord = asRecord(registry); const registryOk = registryRecord?.ok === true; channels.push(channelProbe("artifact-registry", "registry", registryOk, "backend-core commit-pinned image push and later dev CD manifest checks", registry)); const missingChannels = channels.filter((item) => !item.ok).map((item) => item.channel); const controlChannels = summarizePublishControlChannels(channels); const missingControlChannels = controlChannels.filter((item) => !item.ok).map((item) => item.channel); const ready = missingChannels.length === 0; const baseFailureClassification = ready ? null : classifyPublishPreflightFailure(transport, overview, ciProbe, registry); const failureClassification = !ready && baseFailureClassification === null ? "ci-runner-not-ready" : baseFailureClassification; const recommendedAction = ready ? `authorize and run: bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000` : recommendedPublishPreflightAction(failureClassification, registry, missingControlChannels); return { ok: ready, runnerDisposition: ready ? "ready" : "infra-blocked", failureClassification, serviceId: "backend-core", commit: options.commit, providerId, supportedArtifactPublish: true, missingChannels, missingControlChannels, controlChannels, channels, registry, controlPlane: publishPreflightControlPlane(transport, failureClassification, backendCoreRemoteCommandShape(options.commit)), recommendedAction, remoteCommandShape: registryProbe.remoteCommandShape, next: ready ? [ `bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`, `verify ${plannedArtifact.imageRef} labels and digest before any dev artifact consumer apply`, `dev-only follow-up: bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`, ] : [ `Restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}.`, failureClassification === "local-docker-required" ? `From a Code Queue runner, rerun this read-only preflight through the remote frontend transport: ${backendCoreRemoteCommandShape(options.commit)}.` : "Run from the main-server CLI or use remote frontend transport against a healthy frontend/backend-core path.", "Restore backend-core/database/provider-gateway/Host SSH/Tekton/registry readiness before retrying backend-core artifact publication.", "This preflight must remain read-only; do not start Tekton, compile Rust, push registry artifacts, deploy/apply, or restart services.", ], boundary: "preflight is read-only: no Tekton PipelineRun, no Rust compile, no source export, no image push, no registry write, no deploy apply, no service restart", }; } export async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise { const result = await runRemoteKubectlRaw([ "set -euo pipefail", `kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o json`, ].join("\n"), 60_000, 45_000); if (result.ok && result.stdout.trim().length > 0) { try { const parsed = JSON.parse(result.stdout) as unknown; const fields = new Map(); const list = asRecord(parsed); const items = Array.isArray(list?.items) ? list.items : []; for (const item of items) { const taskRun = asRecord(item); const status = asRecord(taskRun?.status); const results = Array.isArray(status?.results) ? status.results : []; for (const rawResult of results) { const taskResult = asRecord(rawResult); const nameValue = asString(taskResult?.name); const value = asString(taskResult?.value); if (/^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)$/u.test(nameValue) && value.length > 0) { fields.set(nameValue, value); } } } if (fields.size > 0) { const fromResults = parseArtifactSummaryFromFields(fields, context); if (missingArtifactSummaryFields(fromResults).length === 0) return fromResults; const completedFromResults = await completeArtifactSummaryFromRegistry(fromResults, context); if (missingArtifactSummaryFields(completedFromResults).length === 0) return completedFromResults; } } catch { // Fall back to pod logs below; a malformed diagnostic line must not mask a succeeded PipelineRun. } } return completeArtifactSummaryFromRegistry(parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), context), context); } export async function readPipelineRunLogText(name: string, target = ciTarget(null)): Promise { const result = await runRemoteKubectlRaw([ "set -euo pipefail", `kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o wide`, `kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o wide`, `for pod in $(kubectl get pods -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o name); do echo "===== $pod"; kubectl logs -n unidesk-ci "$pod" --all-containers=true --tail=240 || true; done`, ].join("\n"), 60_000, 45_000, target); return `${result.stdout}\n${result.stderr}`.trim(); }