// 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(",") || ""}`), ]; 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"]; }