178 lines
8.7 KiB
TypeScript
178 lines
8.7 KiB
TypeScript
// 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<string, string>, 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<string, string>, 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<string, string>();
|
|
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<ArtifactSummary> {
|
|
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<typeof runCommand> {
|
|
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<typeof runCommand>, 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}`}`);
|
|
}
|