169 lines
7.2 KiB
TypeScript
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];
|
|
}
|