// 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)}`; } 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 "); 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 "); } 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:-}\" >&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 { 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 | 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, }, }; }