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

186 lines
9.6 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. preflight module for scripts/src/ci.ts.
// Moved mechanically from scripts/src/ci.ts:592-750 for #903.
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, posix as posixPath } from "node:path";
import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "../ci-catalog";
import { runCommand } from "../command";
import { type UniDeskConfig, repoRoot, rootPath } from "../config";
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity";
import { jobWithTail, listJobs, readJob, startJob } from "../jobs";
import { coreInternalFetch } from "../microservices";
import {
artifactRegistryReadonlyResultFromCommand,
buildArtifactRegistryReadonlyProbe,
parseArtifactRegistryOptions,
type ArtifactRegistryReadonlyProbe,
} from "../artifact-registry";
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard";
import { runSshCommandCapture } from "../ssh";
import type { DispatchResult, PublishPreflight, PublishPreflightChannelProbe, PublishPreflightControlChannel, PublishPreflightControlChannelProbe, PublishPreflightDetailedChannel, PublishPreflightFailureClassification, PublishPreflightTransport } from "./types";
import { status } from "./cleanup";
import { d601ProviderId } from "./types";
export function chunks(value: string, size: number): string[] {
const result: string[] = [];
for (let index = 0; index < value.length; index += size) {
result.push(value.slice(index, index + size));
}
return result;
}
export const hostSshBase64UploadChunkChars = 3000;
export function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function coreBody(response: unknown): Record<string, unknown> | null {
return asRecord(asRecord(response)?.body);
}
export function emitCiInstallProgress(stage: string, status: "started" | "skipped" | "succeeded" | "failed", detail: Record<string, unknown> = {}): void {
console.error(JSON.stringify({
event: "ci.install.progress",
at: new Date().toISOString(),
stage,
status,
...detail,
}));
}
export function responseOk(response: unknown): boolean {
if (typeof response !== "object" || response === null) return false;
const record = response as Record<string, unknown>;
if ("ok" in record && record.ok === false) return false;
const body = asRecord(record.body);
if (body !== null && "ok" in body && body.ok === false) return false;
return true;
}
export function channelProbe(
channel: PublishPreflightDetailedChannel,
controlChannel: PublishPreflightControlChannel,
ok: boolean,
requiredFor: string,
detail: unknown,
): PublishPreflightChannelProbe {
return { channel, controlChannel, ok, requiredFor, detail };
}
export const publishPreflightControlChannelOrder: PublishPreflightControlChannel[] = ["backend-core", "database", "provider", "registry"];
export function summarizePublishControlChannels(channels: PublishPreflightChannelProbe[]): PublishPreflightControlChannelProbe[] {
return publishPreflightControlChannelOrder.map((channel) => {
const probes = channels.filter((item) => item.controlChannel === channel);
return {
channel,
ok: probes.length > 0 && probes.every((item) => item.ok),
requiredFor: Array.from(new Set(probes.map((item) => item.requiredFor))),
probes: probes.map((item) => item.channel),
};
});
}
export function backendCoreUnavailable(value: unknown): boolean {
const record = asRecord(value);
const body = coreBody(value);
if (record?.runnerDisposition === "infra-blocked") return true;
if (record?.failureKind === "target-stack-not-running") return true;
if (body?.runnerDisposition === "infra-blocked") return true;
if (body?.failureKind === "target-stack-not-running") return true;
if (body?.degradedReason === "backend-core-container-missing") return true;
const text = JSON.stringify(value) ?? "";
return text.includes("No such container: unidesk-backend-core")
|| text.includes("No such container: unidesk-database");
}
export function transportKind(transport: PublishPreflightTransport): "local-docker" | "remote-frontend" | "provider-tunnel" {
return transport.kind ?? "local-docker";
}
export function classifyPublishPreflightFailure(
transport: PublishPreflightTransport,
overview: unknown,
sshProbe: DispatchResult,
registry: unknown,
): PublishPreflightFailureClassification | null {
const kind = transportKind(transport);
const overviewRecord = asRecord(overview);
if (overviewRecord?.failureClassification === "auth-missing") return "auth-missing";
if (overviewRecord?.failureClassification === "remote-proxy-missing") return "remote-proxy-missing";
if (overviewRecord?.failureClassification === "provider-unreachable") return "provider-unreachable";
if (kind === "local-docker" && backendCoreUnavailable(overview)) return "local-docker-required";
if (kind !== "local-docker" && !responseOk(overview)) return "remote-proxy-missing";
if (!sshProbe.ok) return "provider-unreachable";
const registryRecord = asRecord(registry);
if (registryRecord?.failureClassification === "local-docker-required") return "local-docker-required";
if (registryRecord?.failureClassification === "provider-ssh-command-missing") return "provider-unreachable";
if (registryRecord?.failureClassification === "ssh-helper-command-shape-incompatible") return "ssh-helper-command-shape-incompatible";
if (registryRecord?.failureClassification === "remote-command-timeout") return "remote-command-timeout";
if (registryRecord?.failureClassification === "registry-not-installed") return "registry-not-installed";
if (registryRecord?.failureClassification === "registry-unhealthy") return "registry-unhealthy";
if (registryRecord?.ok === false) return kind === "local-docker" ? "provider-unreachable" : "registry-unhealthy";
return null;
}
export function recommendedPublishPreflightAction(
failureClassification: PublishPreflightFailureClassification | null,
registry: unknown,
missingControlChannels: PublishPreflightControlChannel[],
): string {
if (failureClassification === null && missingControlChannels.length === 0) return "none";
const registryRecommendedAction = asString(asRecord(registry)?.recommendedAction);
if (registryRecommendedAction.length > 0 && registryRecommendedAction !== "none") return registryRecommendedAction;
if (failureClassification === "local-docker-required") return "rerun this read-only preflight through --main-server-ip <host> or from a main-server CLI with backend-core available";
if (failureClassification === "auth-missing") return "restore frontend control-plane authentication before rerunning the dry-run preflight";
if (failureClassification === "remote-proxy-missing") return "restore frontend to backend-core proxy reachability before rerunning the dry-run preflight";
if (failureClassification === "provider-unreachable") return "restore D601 provider-gateway host.ssh reachability before rerunning the dry-run preflight";
if (failureClassification === "ssh-helper-command-shape-incompatible") return "upgrade or shorten the host.ssh readonly command shape before rerunning the dry-run preflight";
if (failureClassification === "remote-command-timeout") return "rerun artifact-registry health and inspect D601 host.ssh latency if the timeout repeats";
if (failureClassification === "registry-not-installed") return "install the D601 artifact registry through the controlled artifact-registry install path, then rerun health";
if (failureClassification === "registry-unhealthy") return "restore the D601 artifact registry service/container/API until artifact-registry health passes";
if (failureClassification === "ci-runner-not-ready") return "restore D601 unidesk-ci Tekton runner prerequisites before authorizing backend-core artifact publication";
if (missingControlChannels.includes("registry")) return "restore or install the D601 artifact registry until artifact-registry health passes";
return `restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}`;
}
export function publishPreflightControlPlane(
transport: PublishPreflightTransport,
failureClassification: PublishPreflightFailureClassification | null,
remoteCommandShape = "bun scripts/cli.ts --main-server-ip <host> ci publish-user-service --service <id> --commit <full-sha> --dry-run",
): Record<string, unknown> {
const kind = transportKind(transport);
const remoteCapable = kind !== "local-docker";
return {
transport: kind,
remoteCapable,
remoteHost: transport.remoteHost ?? null,
localDockerRequired: kind === "local-docker",
failureClassification,
preferredRunnerPath: "frontend-private-proxy",
fallbackRunnerPath: "main-server-local-docker",
remoteCommandShape,
providerTunnel: {
providerId: d601ProviderId,
command: "host.ssh",
requiredFor: ["provider-host-ssh", "artifact-registry"],
},
};
}
export function publishPreflightFailedScopes(preflight: PublishPreflight): string[] {
if (preflight.ok) return [];
const registryScopes = asRecord(preflight.registry)?.failedScopes;
if (Array.isArray(registryScopes)) return registryScopes.map(String);
return preflight.missingChannels;
}