edfddd2445
* docs: specify cicd yaml target governance * fix: resolve cicd targets from yaml --------- Co-authored-by: Codex <codex@noreply.local>
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. remote module for scripts/src/artifact-registry.ts.
|
|
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
|
|
|
|
// 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} --target ${options.targetId}`
|
|
: `bun scripts/cli.ts --main-server-ip ${remoteHost} artifact-registry ${action} --target ${options.targetId}`,
|
|
},
|
|
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),
|
|
};
|
|
}
|