Files
pikasTech-unidesk/scripts/src/ci/direct-docker.ts
T
2026-07-09 10:46:55 +02:00

220 lines
10 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. direct-docker module for scripts/src/ci.ts.
// Moved mechanically from scripts/src/ci.ts:2125-2316 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, CiPublishUserServiceArtifactOptions } from "./types";
import { artifactSummaryDefaults, assertCommandOk, directDockerRegistryCurl, dockerArtifactDigest, registryManifestDigest } from "./artifact-summary";
import { assertArtifactSummaryComplete } from "./publish-preflight";
import { codeQueueDirectDockerBaseImage, providerGatewayWsEgressProxyUrl } from "./types";
export function buildContextForService(serviceId: string, dockerfile: string): string {
return serviceId === "claudeqq" ? posixPath.dirname(dockerfile) : ".";
}
export function directDockerBuildNetworkArgs(serviceId: string): string[] {
if (serviceId !== "code-queue") return [];
const noProxy = [
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
"d601-tcp-egress-gateway",
"d601-tcp-egress-gateway.unidesk",
"d601-tcp-egress-gateway.unidesk.svc",
"d601-tcp-egress-gateway.unidesk.svc.cluster.local",
"backend-core",
"oa-event-flow",
"database",
].join(",");
return [
"--network",
"host",
"--build-arg",
`HTTP_PROXY=${providerGatewayWsEgressProxyUrl}`,
"--build-arg",
`HTTPS_PROXY=${providerGatewayWsEgressProxyUrl}`,
"--build-arg",
`http_proxy=${providerGatewayWsEgressProxyUrl}`,
"--build-arg",
`https_proxy=${providerGatewayWsEgressProxyUrl}`,
"--build-arg",
`NO_PROXY=${noProxy}`,
"--build-arg",
`no_proxy=${noProxy}`,
];
}
export function directDockerSourceGitMode(repoUrl: string): "local-unidesk-worktree" | "git-archive" {
return repoUrl === "https://github.com/pikasTech/unidesk" ? "local-unidesk-worktree" : "git-archive";
}
export async function prepareDirectDockerUserServiceSource(options: CiPublishUserServiceArtifactOptions): Promise<{ path: string; cleanup: () => void; summary: Record<string, unknown> }> {
const tempRoot = mkdtempSync(join(tmpdir(), `unidesk-ci-${options.serviceId}-${options.commit.slice(0, 8)}-`));
const sourcePath = join(tempRoot, "source");
const gitMode = directDockerSourceGitMode(options.repoUrl);
if (gitMode !== "local-unidesk-worktree") {
rmSync(tempRoot, { recursive: true, force: true });
throw new Error("direct-docker publish currently supports only UniDesk repo-owned source-build artifacts; use --transport tekton for external repositories");
}
const resolved = runCommand(["git", "rev-parse", "--verify", `${options.commit}^{commit}`], repoRoot, { timeoutMs: 30_000 });
assertCommandOk(resolved, "resolve source commit");
if (resolved.stdout.trim() !== options.commit) {
rmSync(tempRoot, { recursive: true, force: true });
throw new Error(`direct-docker source commit mismatch: resolved ${resolved.stdout.trim()} expected ${options.commit}`);
}
const dockerfileExists = runCommand(["git", "cat-file", "-e", `${options.commit}:${options.dockerfile}`], repoRoot, { timeoutMs: 30_000 });
assertCommandOk(dockerfileExists, "verify source dockerfile");
const worktree = runCommand(["git", "worktree", "add", "--detach", sourcePath, options.commit], repoRoot, { timeoutMs: 120_000 });
if (worktree.exitCode !== 0) {
rmSync(tempRoot, { recursive: true, force: true });
throw new Error(`prepare source worktree failed: ${worktree.stderr || worktree.stdout}`);
}
return {
path: sourcePath,
cleanup: () => {
const removed = runCommand(["git", "worktree", "remove", "--force", sourcePath], repoRoot, { timeoutMs: 60_000 });
if (removed.exitCode !== 0) rmSync(tempRoot, { recursive: true, force: true });
else rmSync(tempRoot, { recursive: true, force: true });
},
summary: {
ok: true,
mode: gitMode,
providerId: "local",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
sourceHostPath: sourcePath,
valuesPrinted: false,
},
};
}
export async function publishUserServiceArtifactDirectDocker(options: CiPublishUserServiceArtifactOptions, context: ArtifactSummaryContext): Promise<Record<string, unknown>> {
const source = await prepareDirectDockerUserServiceSource(options);
const planned = artifactSummaryDefaults(context);
const localImage = `${options.imageRepository}:${options.commit}`;
const buildContext = buildContextForService(options.serviceId, options.dockerfile);
const networkArgs = directDockerBuildNetworkArgs(options.serviceId);
const baseArgs = options.serviceId === "code-queue" && runCommand(["docker", "image", "inspect", codeQueueDirectDockerBaseImage], repoRoot, { timeoutMs: 30_000 }).exitCode === 0
? ["--build-arg", `CODE_QUEUE_BASE_IMAGE=${codeQueueDirectDockerBaseImage}`]
: [];
try {
if (options.serviceId === "code-queue" && baseArgs.length === 0) {
return {
ok: false,
runnerDisposition: "infra-blocked",
failureClassification: "ci-runner-not-ready",
failureKind: "code-queue-base-image-missing",
serviceId: options.serviceId,
commit: options.commit,
artifactSummary: planned,
source: source.summary,
artifact: planned.imageRef,
transport: "direct-docker",
controlledPublish: {
environment: "DEV-local-artifact",
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --transport direct-docker`,
noRollout: true,
noRuntimeMutation: true,
},
boundary: "direct-docker CI artifact publish builds and pushes a commit-pinned image only; no deploy apply, no rollout, no Code Queue restart, no active task mutation",
next: [
"Restore or warm local unidesk-code-queue:d601 base image, then rerun the same direct-docker publish command.",
],
};
}
const build = runCommand([
"docker",
"build",
"--label",
`unidesk.ai/service-id=${options.serviceId}`,
"--label",
`unidesk.ai/source-repo=${options.repoUrl}`,
"--label",
`unidesk.ai/source-commit=${options.commit}`,
"--label",
`unidesk.ai/dockerfile=${options.dockerfile}`,
...networkArgs,
...baseArgs,
"-t",
localImage,
"-t",
planned.imageRef,
"-f",
options.dockerfile,
buildContext,
], source.path, { timeoutMs: Math.max(options.waitMs, 20 * 60_000) });
assertCommandOk(build, "direct docker build");
const inspectLabels = runCommand(["docker", "image", "inspect", planned.imageRef, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }} {{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot, { timeoutMs: 30_000 });
assertCommandOk(inspectLabels, "inspect built image labels");
if (!inspectLabels.stdout.includes(`${options.serviceId} ${options.commit}`)) throw new Error(`direct docker image labels did not match ${options.serviceId}/${options.commit}`);
const registryCheck = directDockerRegistryCurl(["-fsS", "http://127.0.0.1:5000/v2/"], 15_000);
assertCommandOk(registryCheck, "D601 loopback artifact registry health");
const push = runCommand(["docker", "push", planned.imageRef], repoRoot, { timeoutMs: Math.max(options.waitMs, 10 * 60_000) });
assertCommandOk(push, "direct docker push");
const pull = runCommand(["docker", "pull", planned.imageRef], repoRoot, { timeoutMs: 120_000 });
assertCommandOk(pull, "direct docker pull verification");
const digest = dockerArtifactDigest(planned.repository, planned.imageRef) ?? registryManifestDigest(planned.repository, planned.tag);
const artifact: ArtifactSummary = {
...planned,
digest,
digestRef: digest === null ? null : `${planned.repository}@${digest}`,
};
assertArtifactSummaryComplete(artifact, "direct-docker");
return {
ok: true,
transport: "direct-docker",
pipelineRun: null,
namespace: null,
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
source: source.summary,
artifact: artifact.imageRef,
artifactSummary: artifact,
controlledPublish: {
environment: "DEV-local-artifact",
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --transport direct-docker`,
noRollout: true,
noRuntimeMutation: true,
dependsOnLocalUnideskDatabase: false,
},
boundary: "direct-docker CI artifact publish builds and pushes a commit-pinned image only; no deploy apply, no rollout, no Code Queue restart, no active task mutation",
wait: {
ok: true,
dispatchOk: null,
dispatchStatus: null,
dispatchExitCode: null,
stdoutTail: push.stdout.slice(-6000),
stderrTail: push.stderr.slice(-6000),
},
condition: null,
next: [
"use artifactSummary.imageRef or artifactSummary.digestRef as later dev artifact consumer input",
],
};
} finally {
source.cleanup();
}
}