Files
pikasTech-unidesk/scripts/src/deploy/paths.ts
T
2026-06-25 16:16:25 +00:00

318 lines
15 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. paths module for scripts/src/deploy.ts.
// Moved mechanically from scripts/src/deploy.ts:1237-1518 for #903.
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { runCommand } from "../command";
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "../config";
import { ensureGithubSshIdentityForProvider } from "../deploy-ssh-identity";
import { baiduNetdiskRuntimeSecretRequirements, runtimeSecretContractFromEnvText, type RuntimeSecretContract, runArtifactRegistryCommand } from "../artifact-registry";
import { startJob } from "../jobs";
import { coreInternalFetch } from "../microservices";
import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "../code-queue-source-guard";
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
encodeDeployJsonServiceContract,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContract,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import type { DeployEnvironment, DeployManifestService, DeployOptions } from "./types";
import { isUnideskRepo, safeId, shellQuote } from "./options";
import { writeComposeEnvFallbackPath } from "./scripts";
import { coreDeployService, devK3sDeployService, isDevK3sDeployService } from "./service-plan";
import { k3sProductionHostPathRepoDir, providerGatewayWsEgressProxyUrl, remoteDeployRoot, unideskRepoUrl } from "./types";
export function environmentPlanServiceConfig(config: UniDeskConfig | null, service: DeployManifestService, environment: DeployEnvironment): UniDeskMicroserviceConfig | null {
if (environment === "dev") {
const devService = devK3sDeployService(service.id);
if (devService !== undefined) return devService;
}
const configured = config?.microservices.find((candidate) => candidate.id === service.id);
if (configured !== undefined) return configured;
if (config !== null) return coreDeployService(config, service.id, environment) ?? null;
return null;
}
export function targetIsMain(service: UniDeskMicroserviceConfig): boolean {
return service.providerId === "main-server";
}
export function targetDeployRoot(service: UniDeskMicroserviceConfig): string {
return targetIsMain(service) ? rootPath(".state", "deploy") : remoteDeployRoot;
}
export function targetRepoDir(service: UniDeskMicroserviceConfig): string {
return `${targetDeployRoot(service)}/repos/${safeId(service.id)}`;
}
export function targetExportDir(service: UniDeskMicroserviceConfig, runId: string): string {
return `${targetDeployRoot(service)}/exports/${safeId(service.id)}-${runId}`;
}
export function targetWorkDir(service: UniDeskMicroserviceConfig): string {
if (isDevK3sDeployService(service)) return service.development.worktreePath;
if (service.deployment.mode === "k3sctl-managed") return k3sProductionHostPathRepoDir;
if (targetIsMain(service) && isUnideskRepo(service.repository.url)) {
return rootPath(".state", "deploy", "work", safeId(service.id));
}
return service.development.worktreePath;
}
export function sourceWorkDir(service: UniDeskMicroserviceConfig): string {
if (service.deployment.mode === "k3sctl-managed" && service.repository.url !== unideskRepoUrl) {
return `${remoteDeployRoot}/work/${safeId(service.id)}`;
}
return targetWorkDir(service);
}
export function sourceFallbackWorktree(service: UniDeskMicroserviceConfig): string {
if (targetIsMain(service) || isUnideskRepo(service.repository.url)) return "";
return service.development.worktreePath;
}
export function sourceRootSubdir(service: UniDeskMicroserviceConfig): string {
const claudeqqPrefix = "claudeqq/";
if (service.id === "claudeqq" && service.repository.url !== unideskRepoUrl && service.repository.dockerfile.startsWith(claudeqqPrefix)) {
return "claudeqq";
}
return "";
}
export function sourceBuildContext(service: UniDeskMicroserviceConfig): string {
const subdir = sourceRootSubdir(service);
return subdir.length > 0 ? `${sourceWorkDir(service)}/${subdir}` : sourceWorkDir(service);
}
export function buildImageTag(service: UniDeskMicroserviceConfig): string {
if (isDevK3sDeployService(service)) return `unidesk-${service.id}:dev`;
if (service.deployment.mode === "k3sctl-managed") return `unidesk-${service.id}:d601`;
if (targetIsMain(service)) {
if (["project-manager", "baidu-netdisk", "oa-event-flow"].includes(service.repository.composeService)) return service.repository.composeService;
return `unidesk-${service.repository.composeService}`;
}
return `unidesk-${service.id}:${service.providerId.toLowerCase()}`;
}
export function codeQueueContainerdImagePreflight(imageListText: string, expectedImage: string): { ok: boolean; expectedImage: string; matchedLine: string | null; error: string | null } {
const matchedLine = imageListText
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => line.length > 0 && line.includes(expectedImage)) ?? null;
return matchedLine === null
? {
ok: false,
expectedImage,
matchedLine: null,
error: `native k3s containerd is missing required image tag: ${expectedImage}`,
}
: { ok: true, expectedImage, matchedLine, error: null };
}
export function codeQueueManifestImagePreflight(manifestText: string, expectedImage: string): { ok: boolean; expectedImage: string; objects: Array<{ kind: string; name: string; images: string[]; ok: boolean }>; errors: string[] } {
const requiredObjectNames = new Set(["code-queue", "code-queue-read", "code-queue-write", "d601-provider-egress-proxy", "d601-tcp-egress-gateway"]);
const objects: Array<{ kind: string; name: string; images: string[]; ok: boolean }> = [];
for (const documentText of manifestText.split(/^---\s*$/mu)) {
const kind = documentText.match(/^kind:\s*(\S+)\s*$/mu)?.[1] ?? "";
if (kind !== "Deployment") continue;
const deploymentName = documentText.match(/^metadata:\s*$[\s\S]*?^\s{2}name:\s*([A-Za-z0-9_.-]+)\s*$/mu)?.[1] ?? "";
if (!requiredObjectNames.has(deploymentName)) continue;
const images = Array.from(documentText.matchAll(/^\s*image:\s*(?:"([^"]+)"|([^\s#]+))/gmu)).map((match) => match[1] ?? match[2] ?? "");
objects.push({ kind, name: deploymentName, images, ok: images.length > 0 && images.every((image) => image === expectedImage) });
}
const presentNames = new Set(objects.map((object) => object.name));
const errors = [
...Array.from(requiredObjectNames).filter((name) => !presentNames.has(name)).map((name) => `required deployment missing from manifest: ${name}`),
...objects.filter((object) => !object.ok).map((object) => `deployment ${object.name} must use only ${expectedImage}; found ${object.images.join(",") || "<none>"}`),
];
return { ok: errors.length === 0, expectedImage, objects, errors };
}
export function directComposeFile(service: UniDeskMicroserviceConfig): string {
return targetIsMain(service)
? rootPath("docker-compose.yml")
: `${targetWorkDir(service)}/${service.repository.composeFile}`;
}
export function directComposeEnvFile(service: UniDeskMicroserviceConfig): string {
return targetIsMain(service) ? writeComposeEnvFallbackPath() : "";
}
export function redactedSecretContractForService(config: UniDeskConfig | null, service: UniDeskMicroserviceConfig, environment: DeployEnvironment): RuntimeSecretContract | undefined {
if (config === null || service.id !== "baidu-netdisk" || !targetIsMain(service)) return undefined;
const composeEnvFile = config.providerGateway.upgrade.composeEnvFile;
const envFile = join(config.providerGateway.upgrade.hostProjectRoot, composeEnvFile);
const envText = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
const contract = runtimeSecretContractFromEnvText(envText, baiduNetdiskRuntimeSecretRequirements, {
path: envFile,
exists: existsSync(envFile),
workDir: config.providerGateway.upgrade.hostProjectRoot,
composeEnvFile,
composeService: service.repository.composeService,
containerName: service.repository.containerName,
});
return {
...contract,
recommendedAction: contract.requiredSecretsPresent
? "none"
: `Restore ${contract.missingSecretKeys.join(", ")} in the canonical Compose env file without printing values, then rerun deploy apply --env ${environment} --service ${service.id} --dry-run before any live apply.`,
};
}
export function directBuildContextOverride(service: UniDeskMicroserviceConfig): string {
if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return targetWorkDir(service);
return "";
}
export function directDockerfileOverride(service: UniDeskMicroserviceConfig): string {
if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return service.repository.dockerfile;
return "";
}
export function k8sManifestPath(service: UniDeskMicroserviceConfig): string {
const composeFile = service.repository.composeFile;
if (composeFile.endsWith(".k8s.yaml")) return composeFile;
if (!composeFile.endsWith(".k3s.json")) throw new Error(`${service.id} k3s service composeFile must point to *.k3s.json`);
return composeFile.replace(/\.k3s\.json$/u, ".k8s.yaml");
}
export function sourceDockerfilePath(service: UniDeskMicroserviceConfig): string {
const claudeqqPrefix = "claudeqq/";
if (service.id === "claudeqq" && service.repository.url !== unideskRepoUrl && service.repository.dockerfile.startsWith(claudeqqPrefix)) {
return service.repository.dockerfile.slice(claudeqqPrefix.length);
}
return service.repository.dockerfile;
}
export function sourceProxyPrelude(service: UniDeskMicroserviceConfig): string {
if (targetIsMain(service)) return "";
const strictHostKeyChecking = isUnideskRepo(service.repository.url) ? "yes" : "accept-new";
return [
"export DOCKER_CONFIG=/tmp/unidesk-docker-config-clean",
"mkdir -p \"$DOCKER_CONFIG\"",
"[ -f \"$DOCKER_CONFIG/config.json\" ] || printf '{}' > \"$DOCKER_CONFIG/config.json\"",
`build_proxy=${shellQuote(providerGatewayWsEgressProxyUrl)}`,
"export HTTP_PROXY=\"$build_proxy\" HTTPS_PROXY=\"$build_proxy\" ALL_PROXY=\"$build_proxy\"",
"export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal\"",
"git_ssh_proxy=/tmp/unidesk-git-ssh-http-connect.py",
"cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'",
String.raw`#!/usr/bin/env python3
import os
import select
import socket
import sys
from urllib.parse import urlparse
if len(sys.argv) != 3:
raise SystemExit("usage: unidesk-git-ssh-http-connect.py host port")
target_host = sys.argv[1]
target_port = int(sys.argv[2])
proxy = urlparse(os.environ.get("UNIDESK_GIT_SSH_HTTP_PROXY", "http://127.0.0.1:18789"))
proxy_host = proxy.hostname or "127.0.0.1"
proxy_port = proxy.port or 80
sock = socket.create_connection((proxy_host, proxy_port), timeout=20)
sock.sendall(f"CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n".encode("ascii"))
header = b""
while b"\r\n\r\n" not in header:
chunk = sock.recv(4096)
if not chunk:
raise SystemExit("proxy closed before CONNECT response")
header += chunk
head, rest = header.split(b"\r\n\r\n", 1)
if not (head.startswith(b"HTTP/1.1 200") or head.startswith(b"HTTP/1.0 200")):
sys.stderr.write(head.decode("latin1", "replace") + "\n")
raise SystemExit(1)
if rest:
os.write(1, rest)
stdin_open = True
sock.setblocking(False)
while True:
readers = [sock]
if stdin_open:
readers.append(sys.stdin.buffer)
ready, _, _ = select.select(readers, [], [])
if sock in ready:
try:
data = sock.recv(65536)
except BlockingIOError:
data = b""
if not data:
break
os.write(1, data)
if stdin_open and sys.stdin.buffer in ready:
data = os.read(0, 65536)
if data:
sock.sendall(data)
else:
stdin_open = False
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
`,
"UNIDESK_GIT_SSH_PROXY",
"chmod 700 \"$git_ssh_proxy\"",
"export UNIDESK_GIT_SSH_HTTP_PROXY=\"$build_proxy\"",
`export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=${strictHostKeyChecking} -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=$git_ssh_proxy %h %p'"`,
"curl -fsSI --max-time 20 -x \"$build_proxy\" https://github.com >/dev/null",
"echo target_source_proxy=provider-gateway-ws-egress:$build_proxy",
"echo target_build_proxy=provider-gateway-ws-egress:$build_proxy",
"echo target_build_proxy_probe=ok",
].join("\n");
}
export function buildBaseImageFallbacks(service: UniDeskMicroserviceConfig): string[] {
if (isDevK3sDeployService(service) && service.id === "code-queue") {
return [
"unidesk-code-queue:d601-build-base",
"unidesk-code-queue:d601",
];
}
return [];
}
export function buildCachePrelude(dockerfileVariable: string, baseImageFallbacks: string[] = []): string[] {
const fallbackLines = baseImageFallbacks.flatMap((image) => [
`if [ "$base_image_found" = "0" ] && docker image inspect ${shellQuote(image)} >/dev/null 2>&1; then`,
` build_base_image=${shellQuote(image)}`,
" base_image_found=1",
"fi",
]);
return [
"cache_args=(--cache-to type=inline --build-arg BUILDKIT_INLINE_CACHE=1)",
"if docker image inspect \"$image\" >/dev/null 2>&1; then cache_args+=(--cache-from \"$image\"); echo target_build_cache_from_image=$image; else echo target_build_cache_from_image=missing:$image; fi",
"echo target_build_cache_to=inline",
"base_args=()",
"build_base_image=\"${image}-build-base\"",
"base_image_found=0",
"if docker image inspect \"$build_base_image\" >/dev/null 2>&1; then base_image_found=1; fi",
...fallbackLines,
`if grep -Eq '^ARG[[:space:]]+CODE_QUEUE_BASE_IMAGE([=[:space:]]|$)' "${dockerfileVariable}" 2>/dev/null; then if [ "$base_image_found" = "1" ]; then base_args=(--build-arg "CODE_QUEUE_BASE_IMAGE=$build_base_image"); echo target_build_base_image=$build_base_image; else echo target_build_base_image=default; fi; else echo target_build_base_image=unsupported; fi`,
];
}
export function dockerBuildTimeoutMs(service: UniDeskMicroserviceConfig, options: DeployOptions): number {
const maxBuildMs = service.id === "code-queue" ? 1_800_000 : 540_000;
return Math.min(options.timeoutMs, maxBuildMs);
}
export function devK3sPrepullImages(service: UniDeskMicroserviceConfig): string[] {
if (!isDevK3sDeployService(service)) return [];
if (service.id === "backend-core") return ["rust:1-bookworm", "postgres:16-bookworm"];
return ["oven/bun:1-alpine"];
}