// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. runner-preflight module for scripts/src/ci.ts. // Moved mechanically from scripts/src/ci.ts:751-887 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, CiPublishBackendCoreOptions, DispatchResult } from "./types"; import { shellQuote } from "./options"; import { backendCoreUnavailable } from "./preflight"; import { dispatchSsh } from "./remote"; import { defaultCiKubeconfig } from "./types"; export function ciRunnerPreflightScript(sourceHostPath: string): string { return [ "set -eu", ...k3sGuardShellLines(defaultCiKubeconfig), "printf 'provider_host_ssh=ok\\n'", "printf 'kubectl='", "command -v kubectl >/dev/null && printf 'ok\\n' || { printf 'missing\\n'; exit 127; }", "printf 'docker='", "command -v docker >/dev/null && printf 'ok\\n' || { printf 'missing\\n'; exit 127; }", "printf 'docker_socket='", "if [ -S /var/run/docker.sock ] || [ -S /run/docker.sock ]; then printf 'true\\n'; else printf 'false\\n'; fi", "printf 'namespace='", "kubectl get namespace unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'", "printf 'tekton_pipeline='", "kubectl get pipeline/unidesk-backend-core-artifact-publish -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'", "printf 'tekton_task='", "kubectl get task/unidesk-backend-core-artifact-publish -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'", "printf 'service_account='", "kubectl get serviceaccount/unidesk-ci-runner -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'", "printf 'pvc='", "kubectl get pvc/unidesk-ci-cache -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'", "printf 'source_parent_directory='", `test -d ${shellQuote(posixPath.dirname(sourceHostPath))} && printf 'true\\n' || printf 'false\\n'`, ].join("\n"); } export function keyValueBool(stdout: string, key: string): boolean { const match = new RegExp(`^${key}=(.*)$`, "mu").exec(stdout); if (match === null) return false; const value = match[1]?.trim().toLowerCase() ?? ""; return value === "true" || value === "ok" || value === "pass" || value.startsWith("pass "); } export function backendCoreCiRunnerReady(result: DispatchResult): boolean { return result.ok && keyValueBool(result.stdout, "k3s_target_guard") && keyValueBool(result.stdout, "kubectl") && keyValueBool(result.stdout, "docker") && keyValueBool(result.stdout, "namespace") && keyValueBool(result.stdout, "tekton_pipeline") && keyValueBool(result.stdout, "tekton_task") && keyValueBool(result.stdout, "service_account") && keyValueBool(result.stdout, "pvc") && keyValueBool(result.stdout, "source_parent_directory"); } export function backendCoreArtifactRequirements(options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary): Record { return { requiredLabels: { "unidesk.ai/service-id": "backend-core", "unidesk.ai/source-commit": options.commit, "unidesk.ai/source-repo": options.repoUrl, "unidesk.ai/dockerfile": options.dockerfile, }, digest: { requiredAfterPublish: true, dryRunValue: plannedArtifact.digest, fields: ["artifactSummary.digest", "artifactSummary.digestRef"], source: "D601 registry manifest HEAD after ci publish-backend-core succeeds", }, imageRef: plannedArtifact.imageRef, }; } export function backendCoreDevApplyPath(options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary): Record { return { environment: "dev", pullOnly: true, apply: `bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`, dryRun: `bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit} --dry-run`, sourceImage: plannedArtifact.imageRef, forbidden: ["--env prod", "cargo build", "docker build", "docker compose build", "server rebuild backend-core"], note: "PROD is intentionally not part of backend-core publish preflight; dev apply still needs explicit authorization after artifact publication.", }; } export function backendCoreRemoteCommandShape(commit: string): string { return `bun scripts/cli.ts --main-server-ip ci publish-backend-core --commit ${commit} --dry-run`; } export function dispatchPreflightFailure(command: string, result: DispatchResult): DispatchResult { return { ok: false, taskId: result.taskId, status: result.status, stdout: result.stdout.slice(-4000), stderr: result.stderr.slice(-4000), exitCode: result.exitCode, raw: { command, taskId: result.taskId, status: result.status, exitCode: result.exitCode, stderrTail: result.stderr.slice(-1200), stdoutTail: result.stdout.slice(-1200), raw: result.raw, }, }; } export function commandResultFromDispatch(command: string[], cwd: string, result: DispatchResult) { return { command, cwd, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, signal: null, timedOut: result.status === "timeout", }; } export async function dispatchReadonlySsh(command: string, waitMs: number, remoteTimeoutMs: number): Promise { try { const result = await dispatchSsh(command, waitMs, remoteTimeoutMs); if (!result.ok && backendCoreUnavailable(result.raw)) { return { ...result, status: "infra-blocked", stderr: "backend-core bridge unavailable while dispatching readonly SSH task", }; } return result; } catch (error) { return { ok: false, taskId: null, status: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), exitCode: null, raw: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack ?? null } : String(error), }; } } export function artifactRegistryProbeCommand(probe: ArtifactRegistryReadonlyProbe): string[] { return [process.execPath, "scripts/cli.ts", "ssh", probe.providerId, "argv", "bash", "-lc", probe.script]; }