Files
pikasTech-unidesk/scripts/src/ci/runner-preflight.ts
T
Codex 4c48f8f17d
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
fix: clear remaining yaml target naming residuals
2026-07-09 11:46:22 +02:00

169 lines
7.2 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. runner-preflight module for scripts/src/ci.ts.
// Moved mechanically from scripts/src/ci.ts:751-887 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, CiPublishBackendCoreOptions, DispatchResult } from "./types";
import { shellQuote } from "./options";
import { backendCoreUnavailable } from "./preflight";
import { dispatchSsh } from "./remote";
import { defaultCiKubeconfig } from "./types";
export function ciRunnerPreflightScript(sourceHostPath: string): string {
return [
"set -eu",
...k3sGuardShellLines(defaultCiKubeconfig),
"printf 'provider_host_ssh=ok\\n'",
"printf 'kubectl='",
"command -v kubectl >/dev/null && printf 'ok\\n' || { printf 'missing\\n'; exit 127; }",
"printf 'docker='",
"command -v docker >/dev/null && printf 'ok\\n' || { printf 'missing\\n'; exit 127; }",
"printf 'docker_socket='",
"if [ -S /var/run/docker.sock ] || [ -S /run/docker.sock ]; then printf 'true\\n'; else printf 'false\\n'; fi",
"printf 'namespace='",
"kubectl get namespace unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'tekton_pipeline='",
"kubectl get pipeline/unidesk-backend-core-artifact-publish -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'tekton_task='",
"kubectl get task/unidesk-backend-core-artifact-publish -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'service_account='",
"kubectl get serviceaccount/unidesk-ci-runner -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'pvc='",
"kubectl get pvc/unidesk-ci-cache -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'source_parent_directory='",
`test -d ${shellQuote(posixPath.dirname(sourceHostPath))} && printf 'true\\n' || printf 'false\\n'`,
].join("\n");
}
export function keyValueBool(stdout: string, key: string): boolean {
const match = new RegExp(`^${key}=(.*)$`, "mu").exec(stdout);
if (match === null) return false;
const value = match[1]?.trim().toLowerCase() ?? "";
return value === "true" || value === "ok" || value === "pass" || value.startsWith("pass ");
}
export function backendCoreCiRunnerReady(result: DispatchResult): boolean {
return result.ok
&& keyValueBool(result.stdout, "k3s_target_guard")
&& keyValueBool(result.stdout, "kubectl")
&& keyValueBool(result.stdout, "docker")
&& keyValueBool(result.stdout, "namespace")
&& keyValueBool(result.stdout, "tekton_pipeline")
&& keyValueBool(result.stdout, "tekton_task")
&& keyValueBool(result.stdout, "service_account")
&& keyValueBool(result.stdout, "pvc")
&& keyValueBool(result.stdout, "source_parent_directory");
}
export function backendCoreArtifactRequirements(options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary): Record<string, unknown> {
return {
requiredLabels: {
"unidesk.ai/service-id": "backend-core",
"unidesk.ai/source-commit": options.commit,
"unidesk.ai/source-repo": options.repoUrl,
"unidesk.ai/dockerfile": options.dockerfile,
},
digest: {
requiredAfterPublish: true,
dryRunValue: plannedArtifact.digest,
fields: ["artifactSummary.digest", "artifactSummary.digestRef"],
source: "D601 registry manifest HEAD after ci publish-backend-core succeeds",
},
imageRef: plannedArtifact.imageRef,
};
}
export function backendCoreDevApplyPath(options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary): Record<string, unknown> {
return {
environment: "dev",
pullOnly: true,
apply: `bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`,
dryRun: `bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit} --dry-run`,
sourceImage: plannedArtifact.imageRef,
forbidden: ["--env prod", "cargo build", "docker build", "docker compose build", "server rebuild backend-core"],
note: "PROD is intentionally not part of backend-core publish preflight; dev apply still needs explicit authorization after artifact publication.",
};
}
export function backendCoreRemoteCommandShape(commit: string): string {
return `bun scripts/cli.ts --main-server-ip <host> ci publish-backend-core --commit ${commit} --dry-run`;
}
export function dispatchPreflightFailure(command: string, result: DispatchResult): DispatchResult {
return {
ok: false,
taskId: result.taskId,
status: result.status,
stdout: result.stdout.slice(-4000),
stderr: result.stderr.slice(-4000),
exitCode: result.exitCode,
raw: {
command,
taskId: result.taskId,
status: result.status,
exitCode: result.exitCode,
stderrTail: result.stderr.slice(-1200),
stdoutTail: result.stdout.slice(-1200),
raw: result.raw,
},
};
}
export function commandResultFromDispatch(command: string[], cwd: string, result: DispatchResult) {
return {
command,
cwd,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
signal: null,
timedOut: result.status === "timeout",
};
}
export async function dispatchReadonlySsh(command: string, waitMs: number, remoteTimeoutMs: number): Promise<DispatchResult> {
try {
const result = await dispatchSsh(command, waitMs, remoteTimeoutMs);
if (!result.ok && backendCoreUnavailable(result.raw)) {
return {
...result,
status: "infra-blocked",
stderr: "backend-core bridge unavailable while dispatching readonly SSH task",
};
}
return result;
} catch (error) {
return {
ok: false,
taskId: null,
status: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
exitCode: null,
raw: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack ?? null } : String(error),
};
}
}
export function artifactRegistryProbeCommand(probe: ArtifactRegistryReadonlyProbe): string[] {
return [process.execPath, "scripts/cli.ts", "ssh", probe.providerId, "argv", "bash", "-lc", probe.script];
}