Files
pikasTech-unidesk/scripts/src/ci/options.ts
T
2026-06-25 16:16:25 +00:00

350 lines
16 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/ci.ts.
// Moved mechanically from scripts/src/ci.ts:268-591 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 { CiCleanupFailedPodsOptions, CiCleanupRunsOptions, CiLogsOptions, CiPublishTransport, CiTarget } from "./types";
import { ciTarget, d601ProviderId, providerIdOption } from "./types";
export function stringOption(args: string[], name: string): string | null {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
export function numberOption(args: string[], name: string, fallback: number): number {
const raw = stringOption(args, name);
if (raw === null) return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
return value;
}
export function ciLogsOptions(args: string[]): CiLogsOptions {
const rawTail = stringOption(args, "--tail-lines") ?? stringOption(args, "--tail");
const tailLines = rawTail === null ? 160 : Number(rawTail);
if (!Number.isInteger(tailLines) || tailLines <= 0 || tailLines > 2000) {
throw new Error("ci logs --tail-lines must be an integer between 1 and 2000");
}
return { tailLines, capture: args.includes("--dispatch-task") ? "dispatch-task" : "ssh-stream" };
}
export function ciCleanupRunsOptions(args: string[]): CiCleanupRunsOptions {
const limit = numberOption(args, "--limit", 50);
if (limit <= 0 || limit > 500) throw new Error("ci cleanup-runs --limit must be an integer between 1 and 500");
const confirm = boolFlag(args, "--confirm");
if (confirm && boolFlag(args, "--dry-run")) throw new Error("ci cleanup-runs accepts only one of --confirm or --dry-run");
return {
target: ciTarget(providerIdOption(args)),
minAgeMinutes: numberOption(args, "--min-age-minutes", 60),
limit,
confirm,
};
}
export function ciCleanupFailedPodsOptions(args: string[]): CiCleanupFailedPodsOptions {
const limit = numberOption(args, "--limit", 50);
if (limit <= 0 || limit > 500) throw new Error("ci cleanup-failed-pods --limit must be an integer between 1 and 500");
const confirm = boolFlag(args, "--confirm");
if (confirm && boolFlag(args, "--dry-run")) throw new Error("ci cleanup-failed-pods accepts only one of --confirm or --dry-run");
return {
target: ciTarget(providerIdOption(args)),
namespace: requireK8sName(stringOption(args, "--namespace") ?? "unidesk-ci", "--namespace"),
minAgeMinutes: numberOption(args, "--min-age-minutes", 60),
limit,
confirm,
};
}
export function requireK8sName(value: string, option: string): string {
if (!/^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/u.test(value)) {
throw new Error(`${option} must be a Kubernetes DNS label`);
}
return value;
}
export function countTextLines(value: string): number {
if (value.length === 0) return 0;
const breaks = value.match(/\r\n|\r|\n/gu)?.length ?? 0;
return breaks + (/(\r\n|\r|\n)$/u.test(value) ? 0 : 1);
}
export function tailTextLines(value: string, limit: number): string {
if (value.length === 0) return "";
return value.split(/\r?\n/u).slice(-limit).join("\n");
}
export function boundedHintLine(line: string): string {
const normalized = line.trim();
if (normalized.length <= 1000) return normalized;
return `${normalized.slice(0, 1000)}<truncated:${normalized.length - 1000}>`;
}
export function boundedHeadTail(value: string, headChars: number, tailChars: number): string {
if (value.length <= headChars + tailChars + 80) return value;
return `${value.slice(0, headChars)}\n...[omitted ${value.length - headChars - tailChars} chars]...\n${value.slice(-tailChars)}`;
}
export function extractCiLogConditionHints(value: string): string[] {
return value
.split(/\r?\n/u)
.filter((line) => /^(condition=|taskrun=)/u.test(line.trim()))
.map(boundedHintLine)
.filter((line) => line.length > 0)
.slice(-30);
}
export function extractCiLogFailureHints(value: string): string[] {
const failurePattern = /\b(error|failed|failure|stepfailed|exit code|exited|denied|forbidden|unauthorized|timeout|timed out|panic|exception|could not|cannot|no such|killed|oom|back-off|imagepull|errimagepull|invalid|not found)\b/iu;
return value
.split(/\r?\n/u)
.filter((line) => failurePattern.test(line))
.map(boundedHintLine)
.filter((line) => line.length > 0)
.slice(-40);
}
export function requireRevision(value: string | null): string {
if (value === null || value.length === 0) throw new Error("ci run requires --revision <commit-or-ref>");
if (!/^[A-Za-z0-9._/@:-]{1,160}$/u.test(value) || value.startsWith("-") || value.includes("..")) throw new Error("ci --revision contains unsupported characters");
return value;
}
export function requireFullCommit(value: string | null, option = "--commit"): string {
const commit = value?.toLowerCase() ?? "";
if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`${option} requires a full 40-character pushed Git commit SHA`);
return commit;
}
export function requireServiceId(value: string | null): string {
const serviceId = value ?? "";
if (!/^[a-z0-9]([-a-z0-9]{0,62}[a-z0-9])?$/u.test(serviceId)) {
throw new Error("ci publish-user-service requires --service <dns-safe-user-service-id>");
}
return serviceId;
}
export function requireDesiredRef(value: string | null): string {
const ref = value ?? "master";
if (!/^[A-Za-z0-9._/-]{1,160}$/u.test(ref) || ref.startsWith("-") || ref.includes("..")) {
throw new Error("ci run-dev-e2e --desired-ref contains unsupported characters");
}
return ref;
}
export function boolFlag(args: string[], name: string): boolean {
return args.includes(name);
}
export function publishTransportOption(value: string | null): CiPublishTransport {
if (value === null || value === "auto") return "auto";
if (value === "tekton" || value === "direct-docker") return value;
throw new Error("ci publish-user-service --transport must be one of: auto, tekton, direct-docker");
}
export function isHelpArg(value: string | undefined): boolean {
return value === "help" || value === "--help" || value === "-h";
}
export function shellQuote(value: string): string {
return `'${value.replace(/'/gu, "'\\''")}'`;
}
export function ciTargetGuardShellLines(target: CiTarget, options: { passOutput?: "stdout" | "stderr" | "quiet" } = {}): string[] {
if (target.providerId === d601ProviderId) {
return d601K3sGuardShellLines(target.kubeconfig, options);
}
const passOutput = options.passOutput ?? "stdout";
const labelSelector = target.requiredNodeLabel === undefined ? "" : `${target.requiredNodeLabel.key}=${target.requiredNodeLabel.value}`;
const passLine = passOutput === "quiet"
? ":"
: passOutput === "stderr"
? `printf '${target.guardName}=pass kubeconfig=%s context=%s server=%s nodes=%s\\n' "$required_kubeconfig" "$context" "$server" "$(printf '%s' "$nodes" | tr '\\n' ',')" >&2`
: `printf '${target.guardName}=pass kubeconfig=%s context=%s server=%s nodes=%s\\n' "$required_kubeconfig" "$context" "$server" "$(printf '%s' "$nodes" | tr '\\n' ',')"`;
return [
`export KUBECONFIG=${shellQuote(target.kubeconfig)}`,
"unidesk_ci_k3s_guard() {",
` required_kubeconfig=${shellQuote(target.kubeconfig)}`,
` required_node=${shellQuote(target.requiredNodeName ?? "")}`,
` required_selector=${shellQuote(labelSelector)}`,
` guard_name=${shellQuote(target.guardName)}`,
" if [ \"${KUBECONFIG:-}\" != \"$required_kubeconfig\" ]; then",
" printf '%s=blocked reason=wrong-kubeconfig expected=%s actual=%s\\n' \"$guard_name\" \"$required_kubeconfig\" \"${KUBECONFIG:-<unset>}\" >&2",
" return 1",
" fi",
" if ! command -v kubectl >/dev/null 2>&1; then",
" printf '%s=blocked reason=kubectl-missing\\n' \"$guard_name\" >&2",
" return 1",
" fi",
" if ! context=$(kubectl config current-context 2>&1); then",
" printf '%s=blocked reason=context-read-failed detail=%s\\n' \"$guard_name\" \"$context\" >&2",
" return 1",
" fi",
" if ! server=$(kubectl config view --minify -o 'jsonpath={.clusters[0].cluster.server}' 2>&1); then",
" printf '%s=blocked reason=server-read-failed detail=%s\\n' \"$guard_name\" \"$server\" >&2",
" return 1",
" fi",
" if ! nodes=$(kubectl get nodes -o 'jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}' 2>&1); then",
" printf '%s=blocked reason=nodes-read-failed detail=%s\\n' \"$guard_name\" \"$nodes\" >&2",
" return 1",
" fi",
" combined=$(printf '%s\\n%s\\n%s\\n' \"$context\" \"$server\" \"$nodes\")",
" if printf '%s\\n' \"$combined\" | grep -Eiq 'docker-desktop|desktop-control-plane|127\\.0\\.0\\.1:11700'; then",
" printf '%s=refused reason=forbidden-control-plane context=%s server=%s nodes=%s\\n' \"$guard_name\" \"$context\" \"$server\" \"$(printf '%s' \"$nodes\" | tr '\\n' ',')\" >&2",
" return 1",
" fi",
" if [ -n \"$required_node\" ] && ! printf '%s\\n' \"$nodes\" | grep -Fx \"$required_node\" >/dev/null; then",
" printf '%s=blocked reason=missing-required-node expected=%s nodes=%s\\n' \"$guard_name\" \"$required_node\" \"$(printf '%s' \"$nodes\" | tr '\\n' ',')\" >&2",
" return 1",
" fi",
" if [ -n \"$required_selector\" ]; then",
" if ! labeled_nodes=$(kubectl get nodes -l \"$required_selector\" -o 'jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}' 2>&1); then",
" printf '%s=blocked reason=label-query-failed selector=%s detail=%s\\n' \"$guard_name\" \"$required_selector\" \"$labeled_nodes\" >&2",
" return 1",
" fi",
" if [ -z \"$(printf '%s' \"$labeled_nodes\" | tr -d '\\n')\" ]; then",
" printf '%s=blocked reason=missing-required-label selector=%s nodes=%s\\n' \"$guard_name\" \"$required_selector\" \"$(printf '%s' \"$nodes\" | tr '\\n' ',')\" >&2",
" return 1",
" fi",
" nodes=\"$labeled_nodes\"",
" fi",
` ${passLine}`,
"}",
"unidesk_ci_k3s_guard",
];
}
export function safePathToken(value: string): string {
return value.replace(/[^a-z0-9._-]/giu, "-").toLowerCase().replace(/^-+|-+$/gu, "").slice(0, 80) || "artifact";
}
export function repoSshUrl(repoUrl: string): string {
if (repoUrl.startsWith("git@")) return repoUrl;
if (repoUrl.startsWith("https://github.com/")) {
const repoPath = repoUrl.slice("https://github.com/".length).replace(/\.git$/u, "");
return `git@github.com:${repoPath}.git`;
}
return repoUrl;
}
export function repoNeedsGithubSshIdentity(repoFetchUrl: string): boolean {
return repoFetchUrl.startsWith("git@github.com:");
}
export function repoConnectivityProbeUrl(repoFetchUrl: string): string {
const sshMatch = /^git@([^:]+):/u.exec(repoFetchUrl);
if (sshMatch !== null) return `https://${sshMatch[1]}`;
try {
const parsed = new URL(repoFetchUrl);
return `${parsed.protocol}//${parsed.host}`;
} catch {
return repoFetchUrl.startsWith("https://gitee.com/") ? "https://gitee.com" : "https://github.com";
}
}
export function requireRepoRelativePath(path: string, label: string): string {
if (path.length === 0 || path.startsWith("/") || path.includes("\0") || path.split("/").includes("..")) {
throw new Error(`${label} must be a repo-relative path`);
}
const normalized = posixPath.normalize(path);
if (normalized === "." || normalized.startsWith("../")) throw new Error(`${label} must be a repo-relative path`);
return normalized;
}
export function resolveCatalogArtifact(serviceId: string): CiCatalogArtifact {
const artifact = findCiCatalogArtifact(serviceId);
if (artifact === null) {
const known = loadCiCatalog().artifacts.map((item) => item.serviceId).sort().join(", ");
throw new Error(`unknown CI artifact service: ${serviceId}. Known services: ${known}`);
}
return artifact;
}
export function blockedArtifactResult(artifact: CiUpstreamImageCatalogArtifact | CiSourceBuildCatalogArtifact, commit: string, note: string): Record<string, unknown> {
const base = {
ok: false,
status: "blocked",
error: "blocked",
serviceId: artifact.serviceId,
commit,
reason: note,
catalogArtifact: artifact,
boundary: "CI catalog marks this service as blocked; it must not be treated as a source-build artifact producer",
};
return artifact.kind === "upstream-image"
? {
...base,
upstream: artifact.upstream,
next: [
`document the upstream image contract in CI.json for ${artifact.serviceId}`,
],
}
: {
...base,
next: [
`unblock ${artifact.serviceId} in CI.json before attempting source-build publication`,
],
};
}
export function blockedReason(artifact: CiSourceBuildCatalogArtifact): string {
if (artifact.blockedReason === undefined) throw new Error(`${artifact.serviceId} is blocked in CI.json but has no blockedReason`);
return artifact.blockedReason;
}
export function userServicePublishBoundaryBlock(
config: UniDeskConfig,
serviceId: string,
commit: string,
artifact: CiSourceBuildCatalogArtifact,
): Record<string, unknown> | null {
const configService = config.microservices.find((item) => item.id === serviceId);
if (configService === undefined) return null;
const isD601K3sService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "k3sctl-managed";
const isD601DirectService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "unidesk-direct";
const isMainServerDirectService = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "unidesk-direct";
const isMainServerInternalSidecar = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "internal-sidecar";
if (isD601K3sService || isD601DirectService || isMainServerDirectService || isMainServerInternalSidecar) return null;
return {
ok: false,
status: "blocked",
error: "blocked",
serviceId,
commit,
reason: `config.json marks ${serviceId} as ${configService.providerId}/${configService.deployment.mode}, which is outside the reviewed CI artifact producer boundary`,
catalogArtifact: artifact,
configService: {
providerId: configService.providerId,
deploymentMode: configService.deployment.mode,
},
};
}