284 lines
16 KiB
TypeScript
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();
|
|
}
|