refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. remote module for scripts/src/artifact-registry.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/artifact-registry.ts:1659-1933 for #903.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { runCommand, type CommandResult } from "../command";
|
||||
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
|
||||
import { startJob } from "../jobs";
|
||||
import {
|
||||
compareDeployJsonExecutorMirrors,
|
||||
deployJsonCommitImage,
|
||||
deployJsonDriftResult,
|
||||
deployJsonSourceOfTruth,
|
||||
hasDeployJsonExecutorContract,
|
||||
k3sManifestExecutorMirror,
|
||||
parseDeployJsonServiceContractBase64,
|
||||
readDeployJsonServiceContractFromFile,
|
||||
type DeployJsonExecutorMirror,
|
||||
type DeployJsonServiceContract,
|
||||
} from "../deploy-json-contract";
|
||||
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
|
||||
import { composeRuntimeEnvValue } from "../runtime-env";
|
||||
|
||||
import type { ArtifactRegistryCommandRuntime, ArtifactRegistryOptions } from "./types";
|
||||
import { renderBundle, safeName, shellQuote } from "./bundle";
|
||||
import { artifactRegistryCommandTail } from "./consumer";
|
||||
import { classifyProviderSshCommandFailure, isLocalProvider, providerSshCommandFailureScopes, readonlyRemoteCommandShape, registryRecommendedAction } from "./status";
|
||||
|
||||
export function runRemoteScript(options: ArtifactRegistryOptions, script: string, timeoutMs = options.timeoutMs, runtime: ArtifactRegistryCommandRuntime = {}): CommandResult {
|
||||
if (runtime.runRemoteScriptForTest !== undefined) return runtime.runRemoteScriptForTest(options, script, timeoutMs);
|
||||
if (isLocalProvider(options.providerId)) {
|
||||
return runCommand(["bash", "-lc", script], repoRoot, { timeoutMs });
|
||||
}
|
||||
const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script];
|
||||
return runCommand(command, repoRoot, { timeoutMs });
|
||||
}
|
||||
|
||||
export function combineCommandResults(command: string[], parts: CommandResult[]): CommandResult {
|
||||
const failed = parts.find((part) => part.exitCode !== 0 || part.timedOut) ?? parts.at(-1) ?? null;
|
||||
return {
|
||||
command,
|
||||
cwd: repoRoot,
|
||||
exitCode: failed === null ? 0 : failed.exitCode,
|
||||
signal: failed?.signal ?? null,
|
||||
timedOut: parts.some((part) => part.timedOut),
|
||||
stdout: parts.map((part) => part.stdout).filter((part) => part.length > 0).join("\n"),
|
||||
stderr: parts.map((part) => part.stderr).filter((part) => part.length > 0).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadRemoteFile(options: ArtifactRegistryOptions, remotePath: string, localPath: string, timeoutMs = options.timeoutMs): CommandResult {
|
||||
const transferRuntimeTimeoutMs = Math.max(timeoutMs * 4, 60 * 60_000);
|
||||
const command = [
|
||||
process.execPath,
|
||||
"scripts/cli.ts",
|
||||
"ssh",
|
||||
options.providerId,
|
||||
"download",
|
||||
"--inactivity-timeout-ms",
|
||||
String(timeoutMs),
|
||||
remotePath,
|
||||
localPath,
|
||||
];
|
||||
const stdoutFile = process.env.UNIDESK_JOB_STDOUT_FILE;
|
||||
const stderrFile = process.env.UNIDESK_JOB_STDERR_FILE;
|
||||
if (!stdoutFile || !stderrFile) return runCommand(command, repoRoot, { timeoutMs: transferRuntimeTimeoutMs });
|
||||
return runCommand([
|
||||
"bash",
|
||||
"-lc",
|
||||
`set -o pipefail; "$@" > >(tee -a ${shellQuote(stdoutFile)}) 2> >(tee -a ${shellQuote(stderrFile)} >&2)`,
|
||||
"unidesk-artifact-download",
|
||||
...command,
|
||||
], repoRoot, { timeoutMs: transferRuntimeTimeoutMs });
|
||||
}
|
||||
|
||||
export async function runRemoteScriptBackground(
|
||||
options: ArtifactRegistryOptions,
|
||||
script: string,
|
||||
timeoutMs: number,
|
||||
name: string,
|
||||
): Promise<CommandResult> {
|
||||
const token = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const prefix = `/tmp/unidesk-artifact-${safeName(options.serviceId ?? "service")}-${safeName(name)}-${token}`;
|
||||
const logFile = `${prefix}.log`;
|
||||
const doneFile = `${prefix}.done`;
|
||||
const wrapped = [
|
||||
`bash -lc ${shellQuote(script)}`,
|
||||
"code=$?",
|
||||
`printf '%s\\n' "$code" > ${shellQuote(doneFile)}`,
|
||||
"exit \"$code\"",
|
||||
].join("; ");
|
||||
const launchScript = [
|
||||
`rm -f ${shellQuote(logFile)} ${shellQuote(doneFile)}`,
|
||||
`nohup bash -lc ${shellQuote(wrapped)} > ${shellQuote(logFile)} 2>&1 < /dev/null & echo $!`,
|
||||
].join("\n");
|
||||
const launch = runRemoteScript(options, launchScript, 30_000);
|
||||
const pid = launch.stdout.trim().split(/\n/u).pop()?.trim() ?? "";
|
||||
if (launch.exitCode !== 0 || launch.timedOut || !/^\d+$/u.test(pid)) {
|
||||
return combineCommandResults(["artifact-registry", "remote-background", name], [launch]);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
let latest: CommandResult | null = null;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await Bun.sleep(2_000);
|
||||
const poll = runRemoteScript(options, [
|
||||
`if [ -f ${shellQuote(doneFile)} ]; then printf 'SENTINEL:%s\\n' "$(cat ${shellQuote(doneFile)} 2>/dev/null || true)"; else echo RUNNING; fi`,
|
||||
`tail -n 160 ${shellQuote(logFile)} 2>/dev/null | tr -d '\\000' | LC_ALL=C sed 's/[^[:print:]\t]//g' || true`,
|
||||
].join("; "), 30_000);
|
||||
latest = poll;
|
||||
if (poll.exitCode !== 0 || poll.timedOut) continue;
|
||||
const stdout = poll.stdout.trimEnd();
|
||||
const [head = "", ...rest] = stdout.split("\n");
|
||||
if (!head.startsWith("SENTINEL:")) continue;
|
||||
const code = Number(head.slice("SENTINEL:".length).trim());
|
||||
return {
|
||||
command: ["artifact-registry", "remote-background", name],
|
||||
cwd: repoRoot,
|
||||
exitCode: Number.isInteger(code) ? code : 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
`remote_background_name=${name}`,
|
||||
`remote_background_pid=${pid}`,
|
||||
`remote_background_log=${logFile}`,
|
||||
...rest,
|
||||
].join("\n"),
|
||||
stderr: poll.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: ["artifact-registry", "remote-background", name],
|
||||
cwd: repoRoot,
|
||||
exitCode: 124,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
stdout: [
|
||||
`remote_background_name=${name}`,
|
||||
`remote_background_pid=${pid}`,
|
||||
`remote_background_log=${logFile}`,
|
||||
latest?.stdout ?? "",
|
||||
].join("\n"),
|
||||
stderr: latest?.stderr ?? `remote background script ${name} did not finish within ${timeoutMs}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
export function readonlyCommandFailureResult(
|
||||
options: ArtifactRegistryOptions,
|
||||
command: CommandResult,
|
||||
action: "status" | "health",
|
||||
): Record<string, unknown> {
|
||||
const bundle = renderBundle(options);
|
||||
const failureClassification = classifyProviderSshCommandFailure(command);
|
||||
return {
|
||||
ok: false,
|
||||
readonly: true,
|
||||
installed: false,
|
||||
healthy: false,
|
||||
decision: failureClassification === "local-docker-required" ? "infra-blocked" : "retryable-transient",
|
||||
retryable: failureClassification !== "ssh-helper-command-shape-incompatible",
|
||||
failureClassification,
|
||||
recommendedAction: registryRecommendedAction(failureClassification),
|
||||
remoteCommandShape: readonlyRemoteCommandShape(action, options),
|
||||
healthyScopes: [],
|
||||
failedScopes: providerSshCommandFailureScopes(failureClassification),
|
||||
runtimeApiHealthy: false,
|
||||
checks: {},
|
||||
expected: {
|
||||
endpoint: `http://${options.host}:${options.port}`,
|
||||
image: options.image,
|
||||
paths: bundle.paths,
|
||||
},
|
||||
command: artifactRegistryCommandTail(command),
|
||||
};
|
||||
}
|
||||
|
||||
export function remoteFrontendHostFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
for (const key of ["UNIDESK_MAIN_SERVER_IP", "UNIDESK_MAIN_SERVER_HOST", "CODE_QUEUE_DEV_CONTAINER_MASTER_HOST"]) {
|
||||
const value = env[key]?.trim() ?? "";
|
||||
if (value.length === 0) continue;
|
||||
if (value === "localhost" || value === "127.0.0.1" || value === "::1") continue;
|
||||
return value.replace(/\/+$/u, "");
|
||||
}
|
||||
if (env.CODE_QUEUE_SERVICE_ROLE || env.CODE_QUEUE_INSTANCE_ID || env.KUBERNETES_SERVICE_HOST) {
|
||||
const publicHost = readConfig().network.publicHost.trim();
|
||||
if (publicHost.length > 0 && publicHost !== "localhost" && publicHost !== "127.0.0.1" && publicHost !== "::1") return publicHost;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function controlPlaneMissingResult(
|
||||
options: ArtifactRegistryOptions,
|
||||
action: "status" | "health",
|
||||
localResult: Record<string, unknown>,
|
||||
remoteResult: Record<string, unknown> | null,
|
||||
remoteCommand: CommandResult | null,
|
||||
remoteHost: string | null,
|
||||
): Record<string, unknown> {
|
||||
const bundle = renderBundle(options);
|
||||
return {
|
||||
ok: false,
|
||||
readonly: true,
|
||||
installed: false,
|
||||
healthy: false,
|
||||
decision: "infra-blocked",
|
||||
retryable: true,
|
||||
runnerDisposition: "infra-blocked",
|
||||
failureClassification: "control-plane-missing",
|
||||
recommendedAction: registryRecommendedAction("control-plane-missing"),
|
||||
remoteCommandShape: readonlyRemoteCommandShape(action, options),
|
||||
healthyScopes: [],
|
||||
failedScopes: providerSshCommandFailureScopes("control-plane-missing"),
|
||||
runtimeApiHealthy: false,
|
||||
checks: {},
|
||||
expected: {
|
||||
endpoint: `http://${options.host}:${options.port}`,
|
||||
image: options.image,
|
||||
paths: bundle.paths,
|
||||
},
|
||||
controlPlane: {
|
||||
preferred: "remote-frontend",
|
||||
remoteFallbackAttempted: remoteHost !== null,
|
||||
remoteFallbackUsed: false,
|
||||
remoteHost,
|
||||
localBackendCoreMissing: true,
|
||||
classification: "control-plane-missing",
|
||||
retryCommand: remoteHost === null
|
||||
? `bun scripts/cli.ts --main-server-ip <host> artifact-registry ${action} --provider-id ${options.providerId}`
|
||||
: `bun scripts/cli.ts --main-server-ip ${remoteHost} artifact-registry ${action} --provider-id ${options.providerId}`,
|
||||
},
|
||||
localObservation: localResult,
|
||||
remoteObservation: remoteResult,
|
||||
remoteCommand: remoteCommand === null ? null : artifactRegistryCommandTail(remoteCommand),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsedCliData(stdout: string): Record<string, unknown> | null {
|
||||
if (stdout.trim().length === 0) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const data = record.data;
|
||||
return typeof data === "object" && data !== null && !Array.isArray(data) ? data as Record<string, unknown> : record;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapRemoteArtifactRegistryResult(data: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const directResult = data.result;
|
||||
if (typeof directResult === "object" && directResult !== null && !Array.isArray(directResult)) return directResult as Record<string, unknown>;
|
||||
const nested = data.data;
|
||||
if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) {
|
||||
const nestedRecord = nested as Record<string, unknown>;
|
||||
const nestedResult = nestedRecord.result;
|
||||
if (typeof nestedResult === "object" && nestedResult !== null && !Array.isArray(nestedResult)) return nestedResult as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function annotateRemoteReadonlyResult(
|
||||
result: Record<string, unknown>,
|
||||
localResult: Record<string, unknown>,
|
||||
remoteHost: string,
|
||||
command: CommandResult,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...result,
|
||||
controlPlane: {
|
||||
preferred: "remote-frontend",
|
||||
remoteFallbackAttempted: true,
|
||||
remoteFallbackUsed: true,
|
||||
remoteHost,
|
||||
localBackendCoreMissing: true,
|
||||
classification: null,
|
||||
},
|
||||
localObservation: localResult,
|
||||
remoteCommand: artifactRegistryCommandTail(command),
|
||||
};
|
||||
}
|
||||
|
||||
export function annotateRemoteFirstReadonlyResult(
|
||||
result: Record<string, unknown>,
|
||||
remoteHost: string,
|
||||
command: CommandResult,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...result,
|
||||
controlPlane: {
|
||||
preferred: "remote-frontend",
|
||||
remoteFallbackAttempted: false,
|
||||
remoteFallbackUsed: true,
|
||||
remoteFirst: true,
|
||||
remoteHost,
|
||||
localBackendCoreMissing: null,
|
||||
classification: null,
|
||||
},
|
||||
remoteCommand: artifactRegistryCommandTail(command),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user