186 lines
9.6 KiB
TypeScript
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;
|
|
}
|