Files
pikasTech-unidesk/scripts/src/ci/publish-preflight.ts
T
2026-07-09 10:46:55 +02:00

284 lines
16 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. publish-preflight module for scripts/src/ci.ts.
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
// Moved mechanically from scripts/src/ci.ts:2317-2567 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 { k3sGuardShellLines, defaultNativeKubeconfig } from "../k3s-target-guard";
import { runSshCommandCapture } from "../ssh";
import type { ArtifactSummary, ArtifactSummaryContext, CiPublishBackendCoreOptions, CiPublishUserServiceArtifactOptions, PublishPreflight, PublishPreflightChannelProbe, PublishPreflightTransport } from "./types";
import { completeArtifactSummaryFromRegistry, missingArtifactSummaryFields, parseArtifactSummaryFromFields, parseArtifactSummaryFromOutput } from "./artifact-summary";
import { status } from "./cleanup";
import { shellQuote } from "./options";
import { asRecord, asString, backendCoreUnavailable, channelProbe, classifyPublishPreflightFailure, coreBody, publishPreflightControlPlane, recommendedPublishPreflightAction, responseOk, summarizePublishControlChannels } from "./preflight";
import { runRemoteKubectlRaw } from "./remote";
import { backendCoreCiRunnerReady, backendCoreRemoteCommandShape, ciRunnerPreflightScript, commandResultFromDispatch, dispatchPreflightFailure } from "./runner-preflight";
import { ciTarget, defaultCiKubeconfig, defaultCiProviderId } from "./types";
export function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void {
const missing = missingArtifactSummaryFields(artifact);
if (missing.length > 0) {
throw new Error(`artifact summary for ${pipelineRun} is missing required field(s): ${missing.join(", ")}`);
}
}
export async function publishUserServicePreflight(
_config: UniDeskConfig,
options: CiPublishUserServiceArtifactOptions,
plannedArtifact: ArtifactSummary,
transport: PublishPreflightTransport,
): Promise<PublishPreflight> {
const providerId = defaultCiProviderId;
const channels: PublishPreflightChannelProbe[] = [];
const overview = await transport.coreFetch("/api/overview", { maxResponseBytes: 500_000 });
const overviewBody = coreBody(overview);
const backendCoreOk = responseOk(overview) && overviewBody?.dbReady === true;
channels.push(channelProbe("backend-core-api", "backend-core", backendCoreOk, "dispatch API, provider catalog, task polling, and database-backed CI state", {
ok: responseOk(overview),
dbReady: overviewBody?.dbReady ?? null,
runnerDisposition: asRecord(overview)?.runnerDisposition ?? null,
failureKind: asRecord(overview)?.failureKind ?? null,
detail: backendCoreUnavailable(overview) ? overview : {
status: asRecord(overview)?.status ?? null,
body: overviewBody,
},
}));
channels.push(channelProbe("database", "database", backendCoreOk, "backend-core task dispatch, provider state, Tekton task polling, and source identity lookup", {
dbReady: overviewBody?.dbReady ?? false,
observedThrough: "backend-core /api/overview",
}));
const probeScript = [
"set -euo pipefail",
...k3sGuardShellLines(defaultCiKubeconfig),
"printf 'provider_host_ssh=ok\\n'",
"command -v bash >/dev/null",
"command -v docker >/dev/null",
"command -v kubectl >/dev/null",
"test -S /var/run/docker.sock || test -S /run/docker.sock || true",
].join("\n");
const sshProbe = await transport.dispatchHostSsh(probeScript, 30_000, 15_000);
channels.push(channelProbe("provider-dispatch", "provider", sshProbe.taskId !== null || sshProbe.ok, "backend-core /api/dispatch can create D601 host.ssh tasks", {
taskId: sshProbe.taskId,
status: sshProbe.status,
ok: sshProbe.taskId !== null || sshProbe.ok,
exitCode: sshProbe.exitCode,
stderrTail: sshProbe.stderr.slice(-1200),
}));
channels.push(channelProbe("provider-host-ssh", "provider", sshProbe.ok, "D601 source export, registry checks, kubectl/Tekton submission, and artifact summary reads", {
taskId: sshProbe.taskId,
status: sshProbe.status,
exitCode: sshProbe.exitCode,
stdoutTail: sshProbe.stdout.slice(-1200),
stderrTail: sshProbe.stderr.slice(-1200),
raw: sshProbe.ok ? undefined : dispatchPreflightFailure("host.ssh readonly probe", sshProbe).raw,
}));
const registryOptions = parseArtifactRegistryOptions(["--target", providerId]);
const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions);
const registryDispatch = await transport.dispatchHostSsh(registryProbe.script, Math.max(registryProbe.timeoutMs, 30_000), registryProbe.timeoutMs);
const registryCommand = commandResultFromDispatch(transport.artifactRegistryCommand(registryProbe), transport.commandCwd, registryDispatch);
const registry = artifactRegistryReadonlyResultFromCommand(registryProbe, registryCommand);
const registryRecord = asRecord(registry);
const registryOk = registryRecord?.ok === true || registryRecord?.runtimeApiHealthy === true;
channels.push(channelProbe("artifact-registry", "registry", registryOk, "commit-pinned image push and later CD manifest checks", registry));
const missingChannels = channels.filter((item) => !item.ok).map((item) => item.channel);
const controlChannels = summarizePublishControlChannels(channels);
const missingControlChannels = controlChannels.filter((item) => !item.ok).map((item) => item.channel);
const ready = missingChannels.length === 0;
const failureClassification = ready ? null : classifyPublishPreflightFailure(transport, overview, sshProbe, registry);
const recommendedAction = recommendedPublishPreflightAction(failureClassification, registry, missingControlChannels);
return {
ok: ready,
runnerDisposition: ready ? "ready" : "infra-blocked",
failureClassification,
serviceId: options.serviceId,
commit: options.commit,
providerId,
supportedArtifactPublish: true,
missingChannels,
missingControlChannels,
controlChannels,
channels,
registry,
controlPlane: publishPreflightControlPlane(transport, failureClassification),
recommendedAction,
remoteCommandShape: registryProbe.remoteCommandShape,
next: ready
? [
`bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
`later CD must consume ${plannedArtifact.imageRef}; CI itself must not deploy production`,
]
: [
`Restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}.`,
failureClassification === "local-docker-required"
? "From a Code Queue runner, rerun this read-only preflight through the existing remote frontend transport: bun scripts/cli.ts --main-server-ip <host> ci publish-user-service --service <id> --commit <full-sha> --dry-run."
: "Run from the main-server CLI or use remote frontend transport against a healthy frontend/backend-core path.",
"Restore backend-core/database/provider-gateway/Host SSH connectivity before retrying artifact publication.",
"Use bun scripts/cli.ts artifact-registry health --target D601 to recheck registry reachability after the control bridge is restored.",
],
boundary: "preflight is read-only: no D601 source export, no Tekton PipelineRun, no image push, no deploy apply, no service restart",
};
}
export async function publishBackendCorePreflight(
_config: UniDeskConfig,
options: CiPublishBackendCoreOptions,
plannedArtifact: ArtifactSummary,
transport: PublishPreflightTransport,
): Promise<PublishPreflight> {
const providerId = defaultCiProviderId;
const channels: PublishPreflightChannelProbe[] = [];
const overview = await transport.coreFetch("/api/overview", { maxResponseBytes: 500_000 });
const overviewBody = coreBody(overview);
const backendCoreOk = responseOk(overview) && overviewBody?.dbReady === true;
channels.push(channelProbe("backend-core-api", "backend-core", backendCoreOk, "dispatch API, provider catalog, task polling, and database-backed CI state", {
ok: responseOk(overview),
dbReady: overviewBody?.dbReady ?? null,
runnerDisposition: asRecord(overview)?.runnerDisposition ?? null,
failureKind: asRecord(overview)?.failureKind ?? null,
detail: backendCoreUnavailable(overview) ? overview : {
status: asRecord(overview)?.status ?? null,
body: overviewBody,
},
}));
channels.push(channelProbe("database", "database", backendCoreOk, "backend-core task dispatch, provider state, Tekton task polling, and source identity lookup", {
dbReady: overviewBody?.dbReady ?? false,
observedThrough: "backend-core /api/overview",
}));
const ciScript = ciRunnerPreflightScript(options.sourceHostPath);
const ciProbe = await transport.dispatchHostSsh(ciScript, 30_000, 15_000);
const ciRunnerReady = backendCoreCiRunnerReady(ciProbe);
channels.push(channelProbe("provider-dispatch", "provider", ciProbe.taskId !== null || ciProbe.ok, "backend-core /api/dispatch can create D601 host.ssh tasks", {
taskId: ciProbe.taskId,
status: ciProbe.status,
ok: ciProbe.taskId !== null || ciProbe.ok,
exitCode: ciProbe.exitCode,
stderrTail: ciProbe.stderr.slice(-1200),
}));
channels.push(channelProbe("provider-host-ssh", "provider", ciRunnerReady, "D601 host.ssh can observe Docker, kubectl, Tekton, PVC, and backend-core source parent without starting CI", {
taskId: ciProbe.taskId,
status: ciProbe.status,
exitCode: ciProbe.exitCode,
stdoutTail: ciProbe.stdout.slice(-1600),
stderrTail: ciProbe.stderr.slice(-1200),
raw: ciRunnerReady ? undefined : dispatchPreflightFailure("backend-core ci runner readonly probe", ciProbe).raw,
}));
const registryOptions = parseArtifactRegistryOptions(["--target", providerId]);
const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions);
const registryDispatch = await transport.dispatchHostSsh(registryProbe.script, Math.max(registryProbe.timeoutMs, 30_000), registryProbe.timeoutMs);
const registryCommand = commandResultFromDispatch(transport.artifactRegistryCommand(registryProbe), transport.commandCwd, registryDispatch);
const registry = artifactRegistryReadonlyResultFromCommand(registryProbe, registryCommand);
const registryRecord = asRecord(registry);
const registryOk = registryRecord?.ok === true;
channels.push(channelProbe("artifact-registry", "registry", registryOk, "backend-core commit-pinned image push and later dev CD manifest checks", registry));
const missingChannels = channels.filter((item) => !item.ok).map((item) => item.channel);
const controlChannels = summarizePublishControlChannels(channels);
const missingControlChannels = controlChannels.filter((item) => !item.ok).map((item) => item.channel);
const ready = missingChannels.length === 0;
const baseFailureClassification = ready ? null : classifyPublishPreflightFailure(transport, overview, ciProbe, registry);
const failureClassification = !ready && baseFailureClassification === null ? "ci-runner-not-ready" : baseFailureClassification;
const recommendedAction = ready
? `authorize and run: bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`
: recommendedPublishPreflightAction(failureClassification, registry, missingControlChannels);
return {
ok: ready,
runnerDisposition: ready ? "ready" : "infra-blocked",
failureClassification,
serviceId: "backend-core",
commit: options.commit,
providerId,
supportedArtifactPublish: true,
missingChannels,
missingControlChannels,
controlChannels,
channels,
registry,
controlPlane: publishPreflightControlPlane(transport, failureClassification, backendCoreRemoteCommandShape(options.commit)),
recommendedAction,
remoteCommandShape: registryProbe.remoteCommandShape,
next: ready
? [
`bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
`verify ${plannedArtifact.imageRef} labels and digest before any dev artifact consumer apply`,
`dev-only follow-up: bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`,
]
: [
`Restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}.`,
failureClassification === "local-docker-required"
? `From a Code Queue runner, rerun this read-only preflight through the remote frontend transport: ${backendCoreRemoteCommandShape(options.commit)}.`
: "Run from the main-server CLI or use remote frontend transport against a healthy frontend/backend-core path.",
"Restore backend-core/database/provider-gateway/Host SSH/Tekton/registry readiness before retrying backend-core artifact publication.",
"This preflight must remain read-only; do not start Tekton, compile Rust, push registry artifacts, deploy/apply, or restart services.",
],
boundary: "preflight is read-only: no Tekton PipelineRun, no Rust compile, no source export, no image push, no registry write, no deploy apply, no service restart",
};
}
export async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise<ArtifactSummary> {
const result = await runRemoteKubectlRaw([
"set -euo pipefail",
`kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o json`,
].join("\n"), 60_000, 45_000);
if (result.ok && result.stdout.trim().length > 0) {
try {
const parsed = JSON.parse(result.stdout) as unknown;
const fields = new Map<string, string>();
const list = asRecord(parsed);
const items = Array.isArray(list?.items) ? list.items : [];
for (const item of items) {
const taskRun = asRecord(item);
const status = asRecord(taskRun?.status);
const results = Array.isArray(status?.results) ? status.results : [];
for (const rawResult of results) {
const taskResult = asRecord(rawResult);
const nameValue = asString(taskResult?.name);
const value = asString(taskResult?.value);
if (/^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)$/u.test(nameValue) && value.length > 0) {
fields.set(nameValue, value);
}
}
}
if (fields.size > 0) {
const fromResults = parseArtifactSummaryFromFields(fields, context);
if (missingArtifactSummaryFields(fromResults).length === 0) return fromResults;
const completedFromResults = await completeArtifactSummaryFromRegistry(fromResults, context);
if (missingArtifactSummaryFields(completedFromResults).length === 0) return completedFromResults;
}
} catch {
// Fall back to pod logs below; a malformed diagnostic line must not mask a succeeded PipelineRun.
}
}
return completeArtifactSummaryFromRegistry(parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), context), context);
}
export async function readPipelineRunLogText(name: string, target = ciTarget(null)): Promise<string> {
const result = await runRemoteKubectlRaw([
"set -euo pipefail",
`kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o wide`,
`kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o wide`,
`for pod in $(kubectl get pods -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o name); do echo "===== $pod"; kubectl logs -n unidesk-ci "$pod" --all-containers=true --tail=240 || true; done`,
].join("\n"), 60_000, 45_000, target);
return `${result.stdout}\n${result.stderr}`.trim();
}