318 lines
15 KiB
TypeScript
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"];
|
|
}
|