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

344 lines
15 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. health module for scripts/src/deploy.ts.
// Moved mechanically from scripts/src/deploy.ts:2219-2525 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 { DeployManifestService } from "./types";
import { asRecord, asString, d601K3sGuardScript, parseJsonObjectFromText, shellQuote } from "./options";
import { buildImageTag, k8sManifestPath, targetWorkDir } from "./paths";
import { runTargetCommand } from "./remote";
import { isCoreDeployService, isDevK3sDeployService } from "./service-plan";
import { k8sKubeconfig, k8sNamespace } from "./types";
export function cleanupLegacyDirectCodeQueueScript(service: UniDeskMicroserviceConfig): string {
if (service.id !== "code-queue") return "";
if (isDevK3sDeployService(service)) return "";
return [
"set -euo pipefail",
"container=code-queue-backend",
"if docker ps -a --format '{{.Names}}' | grep -Fx \"$container\" >/dev/null; then",
" docker update --restart=no \"$container\" >/dev/null 2>&1 || true",
` compose_file=${shellQuote(`${targetWorkDir(service)}/src/components/microservices/code-queue/docker-compose.d601.yml`)}`,
" if [ -f \"$compose_file\" ]; then",
" docker compose -p code-queue -f \"$compose_file\" down --remove-orphans",
" else",
" docker rm -f \"$container\"",
" fi",
" echo stopped_legacy_direct_code_queue=$container",
"else",
" echo stopped_legacy_direct_code_queue=not-present",
"fi",
"if docker ps --format '{{.Names}}' | grep -Fx \"$container\" >/dev/null; then",
" echo legacy_direct_code_queue_still_running=$container >&2",
" exit 1",
"fi",
].join("\n");
}
export function k8sNamespaceForService(service: UniDeskMicroserviceConfig): string {
return service.deployment.namespace ?? k8sNamespace;
}
export function k8sDeploymentsForService(service: UniDeskMicroserviceConfig): string[] {
if (isDevK3sDeployService(service) && service.id === "code-queue") {
return ["d601-dev-provider-egress-proxy", "code-queue-scheduler-dev", "code-queue-read-dev", "code-queue-write-dev"];
}
if (service.id === "code-queue") return ["d601-provider-egress-proxy", "d601-tcp-egress-gateway", "code-queue", "code-queue-read", "code-queue-write"];
return [service.repository.composeService];
}
export function applyK8sScript(service: UniDeskMicroserviceConfig): string {
const manifest = `${targetWorkDir(service)}/${k8sManifestPath(service)}`;
const namespace = k8sNamespaceForService(service);
const cleanup = service.id === "code-queue" && !isDevK3sDeployService(service)
? [
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} delete endpointslice d601-provider-egress-proxy --ignore-not-found`,
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} delete deployment code-queue-d518 --ignore-not-found`,
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} delete service code-queue-d518 --ignore-not-found`,
].join("\n")
: "";
return [
"set -euo pipefail",
d601K3sGuardScript(),
cleanup,
`kubectl apply -f ${shellQuote(manifest)}`,
].filter(Boolean).join("\n");
}
export function verifyK8sImagesScript(service: UniDeskMicroserviceConfig): string {
const namespace = k8sNamespaceForService(service);
const image = buildImageTag(service);
const deployments = k8sDeploymentsForService(service);
return [
"set -euo pipefail",
`image=${shellQuote(image)}`,
`namespace=${shellQuote(namespace)}`,
`deployments=(${deployments.map(shellQuote).join(" ")})`,
"for deployment in \"${deployments[@]}\"; do",
` actual=$(KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n "$namespace" get deployment "$deployment" -o jsonpath='{range .spec.template.spec.containers[*]}{.image}{\"\\n\"}{end}')`,
" if [ -z \"$actual\" ]; then",
" echo \"deployment_image_missing=$deployment\" >&2",
" exit 1",
" fi",
" while IFS= read -r actual_image; do",
" [ -z \"$actual_image\" ] && continue",
" if [ \"$actual_image\" != \"$image\" ]; then",
" printf 'deployment_image_mismatch deployment=%s expected=%s actual=%s\\n' \"$deployment\" \"$image\" \"$actual_image\" >&2",
" exit 1",
" fi",
" done <<EOF",
"$actual",
"EOF",
" printf 'deployment_image_ok deployment=%s image=%s\\n' \"$deployment\" \"$image\"",
"done",
].join("\n");
}
export function stampK8sScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string {
const deployments = k8sDeploymentsForService(service).map((name) => `deployment/${name}`);
const namespace = k8sNamespaceForService(service);
const envPairs = [
`UNIDESK_DEPLOY_SERVICE_ID=${service.id}`,
`UNIDESK_DEPLOY_REPO=${desired.repo}`,
`UNIDESK_DEPLOY_COMMIT=${resolvedCommit}`,
`UNIDESK_DEPLOY_REQUESTED_COMMIT=${desired.commitId}`,
...(service.id === "code-queue" ? [
`CODE_QUEUE_DEPLOY_COMMIT=${resolvedCommit}`,
`CODE_QUEUE_DEPLOY_REQUESTED_COMMIT=${desired.commitId}`,
] : []),
];
const annotatePairs = [
`unidesk.ai/deploy-service-id=${service.id}`,
`unidesk.ai/deploy-repo=${desired.repo}`,
`unidesk.ai/deploy-commit=${resolvedCommit}`,
`unidesk.ai/deploy-requested-commit=${desired.commitId}`,
];
return [
"set -euo pipefail",
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} set env ${deployments.map(shellQuote).join(" ")} ${envPairs.map(shellQuote).join(" ")}`,
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} annotate ${deployments.map(shellQuote).join(" ")} ${annotatePairs.map(shellQuote).join(" ")} --overwrite`,
`actual=$(KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} get deployment ${shellQuote(service.repository.composeService)} -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}')`,
`test "$actual" = ${shellQuote(resolvedCommit)}`,
"printf 'k8s_deploy_commit=%s\\n' \"$actual\"",
].join("\n");
}
export function rolloutK8sScript(service: UniDeskMicroserviceConfig): string {
const deployments = k8sDeploymentsForService(service);
const namespace = k8sNamespaceForService(service);
return [
"set -euo pipefail",
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} rollout restart ${deployments.map((name) => shellQuote(`deployment/${name}`)).join(" ")}`,
...deployments.map((name) => `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} rollout status ${shellQuote(`deployment/${name}`)} --timeout=180s`),
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} get deploy ${deployments.map(shellQuote).join(" ")} -o wide`,
].join("\n");
}
export function k8sCommitProbeScript(service: UniDeskMicroserviceConfig): string {
const namespace = k8sNamespaceForService(service);
return [
"set -euo pipefail",
`commit=$(KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} get deployment ${shellQuote(service.repository.composeService)} -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}' 2>/dev/null || true)`,
"printf '%s\\n' \"$commit\"",
].join("\n");
}
export function dockerCommitProbeScript(service: UniDeskMicroserviceConfig): string {
return [
"set -euo pipefail",
`container=${shellQuote(service.repository.containerName)}`,
"cid=$(docker ps -q -f name=\"^/${container}$\" | head -1)",
"if [ -z \"$cid\" ]; then exit 0; fi",
"image_id=$(docker inspect -f '{{.Image}}' \"$cid\")",
"docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\" 2>/dev/null || true",
].join("\n");
}
export function healthDeployCommit(body: Record<string, unknown> | null): string | null {
const deploy = asRecord(body?.deploy);
const commit = asString(deploy?.commit).toLowerCase();
return commit.length > 0 ? commit : null;
}
export function healthDeployRequestedCommit(body: Record<string, unknown> | null): string | null {
const deploy = asRecord(body?.deploy);
const commit = asString(deploy?.requestedCommit).toLowerCase();
return commit.length > 0 ? commit : null;
}
export function healthSummary(response: unknown): Record<string, unknown> {
const record = asRecord(response);
const body = asRecord(record?.body);
return {
ok: record?.ok ?? false,
status: record?.status ?? null,
body: body === null
? null
: {
ok: body.ok ?? null,
service: body.service ?? null,
instanceId: body.instanceId ?? null,
deploy: body.deploy ?? null,
status: body.status ?? null,
startedAt: body.startedAt ?? null,
},
};
}
export async function directHttpJson(url: string, timeoutMs: number): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
const text = await response.text();
let body: unknown = null;
try {
body = text.length > 0 ? JSON.parse(text) : null;
} catch {
body = { text };
}
return { ok: response.ok, status: response.status, body };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
export function devK3sServiceHealthScript(service: UniDeskMicroserviceConfig): string {
const namespace = k8sNamespaceForService(service);
const serviceName = service.repository.composeService;
const path = service.backend.healthPath;
const port = service.backend.nodePort;
return [
"set -euo pipefail",
`namespace=${shellQuote(namespace)}`,
`service_name=${shellQuote(serviceName)}`,
`path=${shellQuote(path)}`,
`port=${shellQuote(String(port))}`,
"case \"$path\" in /*) ;; *) path=\"/$path\" ;; esac",
"proxy_path=\"/api/v1/namespaces/$namespace/services/http:$service_name:$port/proxy$path\"",
"tmp_health=$(mktemp)",
"trap 'rm -f \"$tmp_health\"' EXIT",
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl get --raw "$proxy_path" > "$tmp_health"`,
"python3 - \"$tmp_health\" <<'PY'",
"import json",
"import sys",
"",
"text = open(sys.argv[1], encoding='utf-8', errors='replace').read()",
"try:",
" body = json.loads(text)",
"except Exception:",
" print(text[:4000])",
" raise SystemExit(0)",
"",
"def compact(mapping, keys):",
" if not isinstance(mapping, dict):",
" return None",
" return {key: mapping.get(key) for key in keys if key in mapping}",
"",
"summary = compact(body, ['ok', 'service', 'instanceId', 'role', 'environment', 'namespace', 'databaseName', 'serviceId', 'status', 'startedAt', 'deployRef']) or {}",
"deploy = compact(body.get('deploy'), ['repo', 'commit', 'requestedCommit', 'serviceId'])",
"if deploy is not None:",
" summary['deploy'] = deploy",
"queue = body.get('queue')",
"if isinstance(queue, dict):",
" queue_summary = compact(queue, ['total', 'queueCount', 'defaultQueueId', 'processing']) or {}",
" storage = compact(queue.get('storage'), ['primary', 'postgresReady'])",
" if storage is not None:",
" queue_summary['storage'] = storage",
" summary['queue'] = queue_summary",
"print(json.dumps(summary, ensure_ascii=False, separators=(',', ':')))",
"PY",
].join("\n");
}
export async function serviceHealth(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise<unknown> {
if (isCoreDeployService(service)) {
return await directHttpJson(`${service.backend.nodeBaseUrl}${service.backend.healthPath}`, service.backend.timeoutMs);
}
if (isDevK3sDeployService(service)) {
const result = await runTargetCommand(config, service, devK3sServiceHealthScript(service), "/home/ubuntu", 30_000, 20_000);
const stdout = result.stdout.trim();
let body: unknown = null;
try {
body = stdout.length > 0 ? parseJsonObjectFromText(stdout) : null;
} catch {
body = null;
}
if (body === null && stdout.length > 0) body = { text: stdout };
return {
ok: result.ok,
status: result.ok ? 200 : 502,
body,
raw: result.raw,
error: result.ok ? undefined : result.stderr || result.stdout || "dev k3s service health failed",
};
}
return coreInternalFetch(`/api/microservices/${encodeURIComponent(service.id)}/health`);
}
export function commitMatches(actual: string | null, desired: string): boolean {
if (actual === null || actual.length === 0) return false;
const normalized = actual.toLowerCase();
return normalized === desired.toLowerCase() || (desired.length < 40 && normalized.startsWith(desired.toLowerCase()));
}
export function runtimeCommitVerified(
service: UniDeskMicroserviceConfig,
healthCommit: string | null,
healthRequestedCommit: string | null,
imageCommit: string | null,
orchestratorCommit: string | null,
desired: string,
): boolean {
if (service.deployment.mode === "k3sctl-managed") {
if (healthRequestedCommit !== null && healthRequestedCommit.length > 0 && !commitMatches(healthRequestedCommit, desired)) return false;
return commitMatches(orchestratorCommit, desired);
}
if (healthCommit !== null && healthCommit.length > 0 && !commitMatches(healthCommit, desired)) return false;
if (healthRequestedCommit !== null && healthRequestedCommit.length > 0 && !commitMatches(healthRequestedCommit, desired)) return false;
return commitMatches(imageCommit, desired);
}
export function runtimeCurrentCommit(
service: UniDeskMicroserviceConfig,
healthCommit: string | null,
healthRequestedCommit: string | null,
imageCommit: string | null,
orchestratorCommit: string | null,
): string | null {
if (service.deployment.mode === "k3sctl-managed") return orchestratorCommit ?? healthCommit ?? healthRequestedCommit ?? imageCommit;
return healthCommit ?? healthRequestedCommit ?? imageCommit ?? orchestratorCommit;
}
export function coreBody(response: unknown): Record<string, unknown> | null {
const record = asRecord(response);
return asRecord(record?.body);
}