// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. artifact-summary module for scripts/src/ci.ts. // Moved mechanically from scripts/src/ci.ts:1969-2124 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 { ArtifactSummary, ArtifactSummaryContext } from "./types"; import { shellQuote } from "./options"; import { dispatchSsh } from "./remote"; import { codeQueueDirectDockerBaseImage } from "./types"; export function artifactSummaryDefaults(context: ArtifactSummaryContext): ArtifactSummary { const registry = "127.0.0.1:5000"; const repository = `${registry}/${context.imageRepository}`; return { serviceId: context.serviceId, sourceCommit: context.commit, sourceRepo: context.repoUrl, dockerfile: context.dockerfile, registry, repository, tag: context.commit, imageRef: `${repository}:${context.commit}`, digest: null, digestRef: null, }; } export function artifactSummaryField(fields: Map, suffix: string): string | null { const value = fields.get(`user_service_artifact_${suffix}`) ?? fields.get(`backend_core_artifact_${suffix}`) ?? null; return value === null || value.length === 0 ? null : value; } export function parseArtifactSummaryFromFields(fields: Map, context: ArtifactSummaryContext): ArtifactSummary { const planned = artifactSummaryDefaults(context); const registry = artifactSummaryField(fields, "registry") ?? planned.registry; const repository = artifactSummaryField(fields, "repository") ?? planned.repository; const tag = artifactSummaryField(fields, "tag") ?? planned.tag; const imageRef = artifactSummaryField(fields, "image") ?? (repository.length > 0 && tag.length > 0 ? `${repository}:${tag}` : planned.imageRef); const digest = artifactSummaryField(fields, "digest"); const digestRef = artifactSummaryField(fields, "digest_ref") ?? (digest === null || digest.length === 0 || repository.length === 0 ? null : `${repository}@${digest}`); return { serviceId: artifactSummaryField(fields, "service_id") ?? planned.serviceId, sourceCommit: artifactSummaryField(fields, "source_commit") ?? planned.sourceCommit, sourceRepo: artifactSummaryField(fields, "source_repo") ?? planned.sourceRepo, dockerfile: artifactSummaryField(fields, "dockerfile") ?? planned.dockerfile, registry, repository, tag, imageRef, digest, digestRef, }; } export function parseArtifactSummaryFromOutput(output: string, context: ArtifactSummaryContext): ArtifactSummary { const fields = new Map(); for (const line of output.split(/\r?\n/u)) { const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim()); if (match !== null) fields.set(match[1], match[2]); } return parseArtifactSummaryFromFields(fields, context); } export async function completeArtifactSummaryFromRegistry(artifact: ArtifactSummary, context: ArtifactSummaryContext): Promise { if (artifact.digest !== null && artifact.digest.length > 0 && artifact.digestRef !== null && artifact.digestRef.length > 0) return artifact; const planned = artifactSummaryDefaults(context); const registry = artifact.registry.length > 0 ? artifact.registry : planned.registry; const repository = artifact.repository.length > 0 ? artifact.repository : planned.repository; const tag = artifact.tag.length > 0 ? artifact.tag : planned.tag; const imageRef = artifact.imageRef.length > 0 ? artifact.imageRef : `${repository}:${tag}`; if (registry !== "127.0.0.1:5000" || !repository.startsWith(`${registry}/`)) return artifact; const repositoryPath = repository.slice(`${registry}/`.length); if (repositoryPath.length === 0 || repositoryPath.includes("..") || tag.length === 0) return artifact; const result = await dispatchSsh([ "set -euo pipefail", `manifest_url=${shellQuote(`http://127.0.0.1:5000/v2/${repositoryPath}/manifests/${tag}`)}`, "headers=$(mktemp /tmp/unidesk-artifact-summary.XXXXXX.headers)", "trap 'rm -f \"$headers\"' EXIT", "curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -D \"$headers\" -o /dev/null \"$manifest_url\"", "manifest_digest=$(awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub(/\\r/, \"\", $2); print $2; exit}' \"$headers\")", "test -n \"$manifest_digest\"", "printf 'artifact_registry_manifest_digest=%s\\n' \"$manifest_digest\"", ].join("\n"), 60_000, 45_000); const digest = /^artifact_registry_manifest_digest=(sha256:[0-9a-f]{64})$/mu.exec(result.stdout)?.[1] ?? null; if (!result.ok || digest === null) return { ...artifact, registry, repository, tag, imageRef }; return { ...artifact, registry, repository, tag, imageRef, digest, digestRef: `${repository}@${digest}`, }; } export function missingArtifactSummaryFields(artifact: ArtifactSummary): string[] { const missing: string[] = []; if (artifact.serviceId.length === 0) missing.push("serviceId"); if (!/^[0-9a-f]{40}$/u.test(artifact.sourceCommit)) missing.push("sourceCommit"); if (artifact.sourceRepo.length === 0) missing.push("sourceRepo"); if (artifact.dockerfile.length === 0) missing.push("dockerfile"); if (artifact.imageRef.length === 0) missing.push("imageRef"); if (artifact.tag.length === 0) missing.push("tag"); if (artifact.digest === null || artifact.digest.length === 0) missing.push("digest"); if (artifact.digestRef === null || artifact.digestRef.length === 0) missing.push("digestRef"); return missing; } export function dockerArtifactDigest(repository: string, imageRef: string): string | null { const inspect = runCommand(["docker", "image", "inspect", imageRef, "--format", "{{range .RepoDigests}}{{println .}}{{end}}"], repoRoot, { timeoutMs: 30_000 }); if (inspect.exitCode !== 0) return null; for (const line of inspect.stdout.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean)) { const [repo, digest] = line.split("@"); if (repo === repository && /^sha256:[0-9a-f]{64}$/u.test(digest ?? "")) return digest ?? null; } return null; } export function directDockerRegistryCurl(args: string[], timeoutMs = 30_000): ReturnType { const local = runCommand(["curl", ...args], repoRoot, { timeoutMs }); if (local.exitCode === 0 && !local.timedOut) return local; const basePresent = runCommand(["docker", "image", "inspect", codeQueueDirectDockerBaseImage], repoRoot, { timeoutMs: 30_000 }); if (basePresent.exitCode !== 0) return local; return runCommand([ "docker", "run", "--rm", "--network", "host", "--pull", "never", "--entrypoint", "curl", codeQueueDirectDockerBaseImage, ...args, ], repoRoot, { timeoutMs }); } export function registryManifestDigest(repository: string, tag: string): string | null { const registry = "127.0.0.1:5000"; if (!repository.startsWith(`${registry}/`)) return null; const repositoryPath = repository.slice(`${registry}/`.length); if (repositoryPath.length === 0 || repositoryPath.includes("..") || tag.length === 0) return null; const result = directDockerRegistryCurl([ "-fsSI", "-H", "Accept: application/vnd.docker.distribution.manifest.v2+json", `http://${registry}/v2/${repositoryPath}/manifests/${tag}`, ], 30_000); if (result.exitCode !== 0) return null; const match = /^Docker-Content-Digest:\s*(sha256:[0-9a-f]{64})\s*$/imu.exec(result.stdout); return match?.[1] ?? null; } export function assertCommandOk(result: ReturnType, label: string): void { if (result.exitCode === 0 && !result.timedOut) return; throw new Error(`${label} failed: ${result.stderr.slice(-2000) || result.stdout.slice(-2000) || `exitCode=${result.exitCode}`}`); }