351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. remote module for scripts/src/deploy.ts.
|
|
|
|
// Moved mechanically from scripts/src/deploy.ts:2526-2839 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 { BackgroundPoll, DeployEnvironment, DeployManifestService, DispatchResult, RemoteScriptUpload, ServiceRuntimeState, StepResult } from "./types";
|
|
import { coreBody, dockerCommitProbeScript, healthDeployCommit, healthDeployRequestedCommit, healthSummary, k8sCommitProbeScript, runtimeCommitVerified, runtimeCurrentCommit, serviceHealth } from "./health";
|
|
import { asRecord, asString, compactTail, elapsedMs, nowIso, parseFullCommit, progressLine, safeId, shellQuote } from "./options";
|
|
import { k8sManifestPath, targetIsMain } from "./paths";
|
|
import { isDevK3sDeployService, unsupportedReason } from "./service-plan";
|
|
import { pollIntervalMs, providerDispatchCompletionLagMs, shortDispatchWaitMs, shortRemoteTimeoutMs } from "./types";
|
|
|
|
export async function dispatchSsh(
|
|
config: UniDeskConfig,
|
|
providerId: string,
|
|
command: string,
|
|
cwd: string | null,
|
|
waitMs = shortDispatchWaitMs,
|
|
remoteTimeoutMs = shortRemoteTimeoutMs,
|
|
): Promise<DispatchResult> {
|
|
const dispatchResponse = coreInternalFetch("/api/dispatch", {
|
|
method: "POST",
|
|
body: {
|
|
providerId,
|
|
command: "host.ssh",
|
|
payload: {
|
|
source: "deploy-reconciler",
|
|
mode: "exec",
|
|
command,
|
|
timeoutMs: remoteTimeoutMs,
|
|
...(cwd === null ? {} : { cwd }),
|
|
},
|
|
},
|
|
});
|
|
const dispatchBody = coreBody(dispatchResponse);
|
|
const taskId = asString(dispatchBody?.taskId);
|
|
if (dispatchBody?.ok !== true || taskId.length === 0) {
|
|
return {
|
|
ok: false,
|
|
taskId: taskId || null,
|
|
status: null,
|
|
stdout: "",
|
|
stderr: asString(dispatchBody?.error) || "dispatch did not return a task id",
|
|
exitCode: null,
|
|
raw: dispatchResponse,
|
|
};
|
|
}
|
|
const effectiveWaitMs = Math.max(waitMs, Math.min(remoteTimeoutMs + providerDispatchCompletionLagMs, 120_000));
|
|
const deadline = Date.now() + effectiveWaitMs;
|
|
let latest: unknown = null;
|
|
while (Date.now() < deadline) {
|
|
latest = coreInternalFetch(`/api/tasks/${encodeURIComponent(taskId)}`);
|
|
const task = asRecord(coreBody(latest)?.task);
|
|
const status = asString(task?.status);
|
|
if (status === "succeeded" || status === "failed") {
|
|
const result = asRecord(task?.result);
|
|
const exitCode = typeof result?.exitCode === "number" ? result.exitCode : null;
|
|
const stdout = asString(result?.stdout);
|
|
const stderr = asString(result?.stderr);
|
|
return {
|
|
ok: status === "succeeded" && (exitCode === null || exitCode === 0),
|
|
taskId,
|
|
status,
|
|
stdout,
|
|
stderr,
|
|
exitCode,
|
|
raw: task,
|
|
};
|
|
}
|
|
await Bun.sleep(500);
|
|
}
|
|
return {
|
|
ok: false,
|
|
taskId,
|
|
status: "timeout",
|
|
stdout: "",
|
|
stderr: `host.ssh task ${taskId} did not finish within ${effectiveWaitMs}ms`,
|
|
exitCode: null,
|
|
raw: latest,
|
|
};
|
|
}
|
|
|
|
export async function runTargetCommand(config: UniDeskConfig, service: UniDeskMicroserviceConfig, command: string, cwd: string | null, waitMs = 60_000, remoteTimeoutMs = 45_000): Promise<DispatchResult> {
|
|
if (targetIsMain(service)) {
|
|
const result = runCommand(["bash", "-lc", command], cwd ?? repoRoot);
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
taskId: null,
|
|
status: result.exitCode === 0 ? "succeeded" : "failed",
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
exitCode: result.exitCode,
|
|
raw: result,
|
|
};
|
|
}
|
|
return await dispatchSsh(config, service.providerId, command, cwd, waitMs, remoteTimeoutMs);
|
|
}
|
|
|
|
export function splitFixed(value: string, size: number): string[] {
|
|
const chunks: string[] = [];
|
|
for (let index = 0; index < value.length; index += size) chunks.push(value.slice(index, index + size));
|
|
return chunks;
|
|
}
|
|
|
|
export async function uploadRemoteShellScript(
|
|
config: UniDeskConfig,
|
|
service: UniDeskMicroserviceConfig,
|
|
script: string,
|
|
cwd: string,
|
|
name: string,
|
|
): Promise<RemoteScriptUpload> {
|
|
const remotePath = `/tmp/unidesk-deploy-${safeId(service.id)}-${safeId(name)}-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}.sh`;
|
|
const b64Path = `${remotePath}.b64`;
|
|
const raw: unknown[] = [];
|
|
const init = await runTargetCommand(config, service, `umask 077; rm -f ${shellQuote(remotePath)} ${shellQuote(b64Path)}; : > ${shellQuote(b64Path)}`, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs);
|
|
raw.push(init.raw);
|
|
if (!init.ok) return { ok: false, path: remotePath, error: init.stderr || init.stdout || "failed to initialize remote script upload", raw };
|
|
const encoded = Buffer.from(script, "utf8").toString("base64");
|
|
for (const chunk of splitFixed(encoded, 2400)) {
|
|
const append = await runTargetCommand(config, service, `printf %s ${shellQuote(chunk)} >> ${shellQuote(b64Path)}`, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs);
|
|
raw.push(append.raw);
|
|
if (!append.ok) return { ok: false, path: remotePath, error: append.stderr || append.stdout || "failed to append remote script chunk", raw };
|
|
}
|
|
const finalize = await runTargetCommand(config, service, `base64 -d ${shellQuote(b64Path)} > ${shellQuote(remotePath)}; chmod 700 ${shellQuote(remotePath)}; rm -f ${shellQuote(b64Path)}; wc -c ${shellQuote(remotePath)}`, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs);
|
|
raw.push(finalize.raw);
|
|
if (!finalize.ok) return { ok: false, path: remotePath, error: finalize.stderr || finalize.stdout || "failed to finalize remote script upload", raw };
|
|
return { ok: true, path: remotePath, error: "", raw };
|
|
}
|
|
|
|
export async function launchRemoteBackground(
|
|
config: UniDeskConfig,
|
|
service: UniDeskMicroserviceConfig,
|
|
shellScript: string,
|
|
cwd: string,
|
|
logFile: string,
|
|
sentinelFile: string,
|
|
): Promise<{ ok: boolean; pid: string; raw: unknown; error: string }> {
|
|
const wrapped = [
|
|
`bash -lc ${shellQuote(shellScript)}`,
|
|
"code=$?",
|
|
`printf '%s\\n' "$code" > ${shellQuote(sentinelFile)}`,
|
|
"exit \"$code\"",
|
|
].join("; ");
|
|
const command = [
|
|
`rm -f ${shellQuote(sentinelFile)} ${shellQuote(logFile)}`,
|
|
`nohup bash -lc ${shellQuote(wrapped)} > ${shellQuote(logFile)} 2>&1 < /dev/null & echo $!`,
|
|
].join("; ");
|
|
const result = await runTargetCommand(config, service, command, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs);
|
|
const pid = result.stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
if (!result.ok || !/^\d+$/u.test(pid)) {
|
|
return { ok: false, pid: "", raw: result.raw, error: result.stderr || result.stdout || "failed to launch background command" };
|
|
}
|
|
return { ok: true, pid, raw: result.raw, error: "" };
|
|
}
|
|
|
|
export async function pollRemoteBackground(config: UniDeskConfig, service: UniDeskMicroserviceConfig, cwd: string, logFile: string, sentinelFile: string): Promise<BackgroundPoll> {
|
|
const command = [
|
|
`if [ -f ${shellQuote(sentinelFile)} ]; then printf 'SENTINEL:%s\\n' "$(cat ${shellQuote(sentinelFile)} 2>/dev/null || true)"; else echo RUNNING; fi`,
|
|
`tail -n 100 ${shellQuote(logFile)} 2>/dev/null | tr -d '\\000' | LC_ALL=C sed 's/[^[:print:]\t]//g' || true`,
|
|
].join("; ");
|
|
const result = await runTargetCommand(config, service, command, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs);
|
|
const stdout = result.stdout.trimEnd();
|
|
const [head = "", ...rest] = stdout.split("\n");
|
|
if (head.startsWith("SENTINEL:")) {
|
|
const rawExitCode = head.slice("SENTINEL:".length).trim();
|
|
const exitCode = /^\d+$/u.test(rawExitCode) ? Number(rawExitCode) : null;
|
|
return { done: true, exitCode, logTail: rest.join("\n").trim(), raw: result.raw };
|
|
}
|
|
return { done: false, exitCode: null, logTail: rest.join("\n").trim(), raw: result.raw };
|
|
}
|
|
|
|
export async function step(
|
|
config: UniDeskConfig,
|
|
service: UniDeskMicroserviceConfig,
|
|
name: string,
|
|
command: string,
|
|
cwd: string | null,
|
|
timeoutMs: number,
|
|
background = false,
|
|
): Promise<StepResult> {
|
|
const startedAt = nowIso();
|
|
const startedMs = Date.now();
|
|
progressLine(name, "start", { serviceId: service.id, providerId: service.providerId, cwd });
|
|
if (!background || targetIsMain(service)) {
|
|
const result = await runTargetCommand(config, service, command, cwd, timeoutMs, timeoutMs);
|
|
const detail = compactTail([result.stdout, result.stderr].filter(Boolean).join("\n"), 2000);
|
|
const ok = result.ok;
|
|
progressLine(name, ok ? "succeeded" : "failed", { serviceId: service.id, elapsedMs: elapsedMs(startedMs), detail });
|
|
return { step: name, ok, detail: ok ? detail || `completed in ${elapsedMs(startedMs)}ms` : detail || "command failed", startedAt, finishedAt: nowIso(), raw: result.raw };
|
|
}
|
|
|
|
const runId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
const logFile = `/tmp/unidesk-deploy-${safeId(service.id)}-${name}-${runId}.log`;
|
|
const sentinelFile = `/tmp/unidesk-deploy-${safeId(service.id)}-${name}-${runId}.done`;
|
|
let launchCommand = command;
|
|
if (launchCommand.length > 1800) {
|
|
const upload = await uploadRemoteShellScript(config, service, command, cwd ?? "/home/ubuntu", name);
|
|
if (!upload.ok) return { step: name, ok: false, detail: upload.error, startedAt, finishedAt: nowIso(), raw: upload.raw };
|
|
launchCommand = `bash ${shellQuote(upload.path)}`;
|
|
progressLine(name, "remote script uploaded", { serviceId: service.id, path: upload.path, bytes: command.length });
|
|
}
|
|
const launch = await launchRemoteBackground(config, service, launchCommand, cwd ?? "/home/ubuntu", logFile, sentinelFile);
|
|
if (!launch.ok) return { step: name, ok: false, detail: launch.error, startedAt, finishedAt: nowIso(), raw: launch.raw };
|
|
progressLine(name, "remote background started", { serviceId: service.id, pid: launch.pid, logFile });
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastTail = "";
|
|
while (Date.now() < deadline) {
|
|
await Bun.sleep(pollIntervalMs);
|
|
const poll = await pollRemoteBackground(config, service, cwd ?? "/home/ubuntu", logFile, sentinelFile);
|
|
const tail = compactTail(poll.logTail, 1800);
|
|
if (tail.length > 0 && tail !== lastTail) {
|
|
lastTail = tail;
|
|
progressLine(name, "remote log tail", { serviceId: service.id, elapsedMs: elapsedMs(startedMs), tail });
|
|
}
|
|
if (poll.done) {
|
|
const ok = poll.exitCode === 0;
|
|
const detail = ok
|
|
? `completed in ${elapsedMs(startedMs)}ms; log=${logFile}`
|
|
: `failed with exit ${poll.exitCode}; log=${logFile}; tail=${compactTail(poll.logTail)}`;
|
|
progressLine(name, ok ? "succeeded" : "failed", { serviceId: service.id, detail });
|
|
return { step: name, ok, detail, startedAt, finishedAt: nowIso(), raw: poll.raw };
|
|
}
|
|
}
|
|
return { step: name, ok: false, detail: `timed out after ${timeoutMs}ms`, startedAt, finishedAt: nowIso(), raw: null };
|
|
}
|
|
|
|
export async function readDockerImageCommit(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise<string | null> {
|
|
if (isDevK3sDeployService(service)) return null;
|
|
const result = await runTargetCommand(config, service, dockerCommitProbeScript(service), targetIsMain(service) ? repoRoot : "/home/ubuntu", 30_000, 20_000);
|
|
const commit = parseFullCommit(result.stdout);
|
|
return commit.length > 0 ? commit : null;
|
|
}
|
|
|
|
export async function readK8sCommit(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise<string | null> {
|
|
if (service.deployment.mode !== "k3sctl-managed") return null;
|
|
const result = await runTargetCommand(config, service, k8sCommitProbeScript(service), "/home/ubuntu", 30_000, 20_000);
|
|
const commit = parseFullCommit(result.stdout);
|
|
return commit.length > 0 ? commit : null;
|
|
}
|
|
|
|
export function manifestEnvironmentForService(service: UniDeskMicroserviceConfig): DeployEnvironment {
|
|
return service.deployment.namespace === "unidesk-dev" ? "dev" : "prod";
|
|
}
|
|
|
|
export function serviceDeployRef(service: UniDeskMicroserviceConfig): string {
|
|
return `deploy.json#environments.${manifestEnvironmentForService(service)}.services.${service.id}`;
|
|
}
|
|
|
|
export function k3sDeploymentManifestPath(service: UniDeskMicroserviceConfig): string {
|
|
if (service.deployment.namespace === "unidesk-dev") return k8sManifestPath(service);
|
|
if (service.id === "decision-center") {
|
|
return "src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml";
|
|
}
|
|
return k8sManifestPath(service);
|
|
}
|
|
|
|
export async function readRuntimeState(config: UniDeskConfig, service: UniDeskMicroserviceConfig, desired: DeployManifestService): Promise<ServiceRuntimeState> {
|
|
const reason = unsupportedReason(service);
|
|
const health = await serviceHealth(config, service);
|
|
const healthBody = coreBody(health);
|
|
const healthCommit = healthDeployCommit(healthBody);
|
|
const healthRequestedCommit = healthDeployRequestedCommit(healthBody);
|
|
const healthRecord = asRecord(health);
|
|
const healthOk = healthRecord?.ok === true && healthBody?.ok !== false;
|
|
const [imageCommit, orchestratorCommit] = await Promise.all([
|
|
readDockerImageCommit(config, service).catch(() => null),
|
|
readK8sCommit(config, service).catch(() => null),
|
|
]);
|
|
const currentCommit = runtimeCurrentCommit(service, healthCommit, healthRequestedCommit, imageCommit, orchestratorCommit);
|
|
return {
|
|
serviceId: service.id,
|
|
ok: reason === null,
|
|
supported: reason === null,
|
|
reason,
|
|
desiredRepo: desired.repo,
|
|
desiredCommit: desired.commitId,
|
|
providerId: service.providerId,
|
|
deploymentMode: service.deployment.mode,
|
|
currentCommit,
|
|
healthCommit,
|
|
healthRequestedCommit,
|
|
imageCommit,
|
|
orchestratorCommit,
|
|
healthOk,
|
|
upToDate: reason === null && healthOk && runtimeCommitVerified(service, healthCommit, healthRequestedCommit, imageCommit, orchestratorCommit, desired.commitId),
|
|
raw: { health: healthSummary(health) },
|
|
};
|
|
}
|
|
|
|
export async function healthVerify(config: UniDeskConfig, service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string, timeoutMs: number): Promise<StepResult> {
|
|
const startedAt = nowIso();
|
|
const startedMs = Date.now();
|
|
const deadline = Date.now() + timeoutMs;
|
|
let latest: ServiceRuntimeState | null = null;
|
|
while (Date.now() < deadline) {
|
|
latest = await readRuntimeState(config, service, { ...desired, commitId: resolvedCommit });
|
|
const commitOk = runtimeCommitVerified(service, latest.healthCommit, latest.healthRequestedCommit, latest.imageCommit, latest.orchestratorCommit, resolvedCommit);
|
|
const ok = latest.healthOk && commitOk;
|
|
progressLine("live-health", "probe", { serviceId: service.id, ok, healthOk: latest.healthOk, expectedCommit: resolvedCommit, healthCommit: latest.healthCommit, healthRequestedCommit: latest.healthRequestedCommit, imageCommit: latest.imageCommit, orchestratorCommit: latest.orchestratorCommit });
|
|
if (ok) {
|
|
return {
|
|
step: "live-health",
|
|
ok: true,
|
|
detail: `service ${service.id} health passed with deployed commit ${resolvedCommit} in ${elapsedMs(startedMs)}ms`,
|
|
startedAt,
|
|
finishedAt: nowIso(),
|
|
raw: latest,
|
|
};
|
|
}
|
|
await Bun.sleep(3_000);
|
|
}
|
|
return {
|
|
step: "live-health",
|
|
ok: false,
|
|
detail: `service ${service.id} did not report expected commit ${resolvedCommit} within ${timeoutMs}ms`,
|
|
startedAt,
|
|
finishedAt: nowIso(),
|
|
raw: latest,
|
|
};
|
|
}
|
|
|
|
export function pushStep(steps: StepResult[], result: StepResult): boolean {
|
|
steps.push(result);
|
|
return result.ok;
|
|
}
|