// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/hwlab-g14.ts. // Moved mechanically from scripts/src/hwlab-g14.ts:289-638 for #903. import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { createHash, randomBytes } from "node:crypto"; import { repoRoot, rootPath, type Config } from "../config"; import { runCommand } from "../command"; import { cancelJob, listJobs, readJob, startJob } from "../jobs"; import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes"; import type { G14ControlPlaneOptions, G14GitMirrorOptions, G14LegacyRetirementOptions, G14MonitorLane, G14MonitorOptions, G14ObservabilityOptions, G14RecordRolloutOptions, G14SecretOptions, G14ToolsImageOptions, G14UpstreamImageOptions } from "./types"; import { positiveIntegerOption, runtimeLaneMasterAdminApiKeySecretName, runtimeLaneOpenFgaSecretName } from "./remote"; import { DEFAULT_INTERVAL_SECONDS, DEFAULT_MAX_CYCLES, DEFAULT_TIMEOUT_SECONDS, G14_CI_TOOLS_BASE_TAG, V02_MASTER_ADMIN_API_KEY_SECRET, V02_MASTER_ADMIN_API_KEY_SECRET_KEY, V02_OPENFGA_AUTHN_SECRET_KEY, V02_OPENFGA_DATASTORE_URI_SECRET_KEY, V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY, V02_OPENFGA_SECRET } from "./types"; export function hwlabG14MonitorStateFileName(options: Pick & { lane?: G14MonitorLane }): string { const prefix = options.lane !== undefined && options.lane !== "g14" ? `latest-${options.lane}-` : "latest-"; if (options.once && options.dryRun) return `${prefix}once-dry-run-job.json`; if (options.once) return `${prefix}once-job.json`; if (options.dryRun) return `${prefix}dry-run-job.json`; return `${prefix}monitor-job.json`; } export function hwlabG14MonitorStateRole(options: Pick & { lane?: G14MonitorLane }): string { const lanePrefix = options.lane !== undefined && options.lane !== "g14" ? `${options.lane}-` : ""; if (options.once && options.dryRun) return `${lanePrefix}once-dry-run`; if (options.once) return `${lanePrefix}once`; if (options.dryRun) return `${lanePrefix}dry-run-monitor`; return `${lanePrefix}monitor`; } export function parseMonitorLane(args: string[]): G14MonitorLane { const lane = optionValue(args, "--lane") ?? "g14"; if (lane !== "g14" && !isHwlabRuntimeLane(lane)) throw new Error(`monitor-prs --lane must be g14 or ${hwlabRuntimeLaneIds().join("|")}`); return lane; } export function parseOptions(args: string[]): G14MonitorOptions { return { lane: parseMonitorLane(args), intervalSeconds: positiveIntegerOption(args, "--interval-seconds", DEFAULT_INTERVAL_SECONDS, 86400), maxCycles: positiveIntegerOption(args, "--max-cycles", DEFAULT_MAX_CYCLES, 100000), once: args.includes("--once"), dryRun: args.includes("--dry-run"), worker: args.includes("--worker"), timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", DEFAULT_TIMEOUT_SECONDS, 86400), }; } export function optionValue(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); return value; } export function validateFullShaOption(value: string, name: string): string { if (!/^[0-9a-f]{40}$/iu.test(value)) throw new Error(`${name} must be a full 40-character git SHA`); return value.toLowerCase(); } export function validateV02PipelineRunOption(value: string): string { if (!/^hwlab-v02-ci-poll-[0-9a-f]{7,40}$/iu.test(value)) throw new Error("--pipeline-run must be a hwlab-v02-ci-poll- PipelineRun name"); return value.toLowerCase(); } export function validateRuntimeLanePipelineRunOption(lane: HwlabRuntimeLane, value: string): string { const spec = hwlabRuntimeLaneSpec(lane); const escapedPrefix = spec.pipelineRunPrefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); if (!new RegExp(`^${escapedPrefix}-[0-9a-f]{7,40}(?:-[a-z0-9][a-z0-9-]{0,24})?$`, "iu").test(value)) { throw new Error(`--pipeline-run must be a ${spec.pipelineRunPrefix}-[-rerun] PipelineRun name for --lane ${lane}`); } return value.toLowerCase(); } export function parseRecordRolloutOptions(args: string[]): G14RecordRolloutOptions { const prRaw = optionValue(args, "--pr") ?? optionValue(args, "--number"); const prNumber = Number(prRaw); if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("record-rollout requires --pr "); return { prNumber, sourceCommit: optionValue(args, "--source-commit"), pipelineRun: optionValue(args, "--pipeline-run"), gitopsRevision: optionValue(args, "--gitops-revision"), mergedAt: optionValue(args, "--merged-at"), pipelineSucceededAt: optionValue(args, "--pipeline-succeeded-at"), finishedAt: optionValue(args, "--finished-at"), dryRun: args.includes("--dry-run"), }; } export function parseControlPlaneOptions(args: string[]): G14ControlPlaneOptions { const [rawAction] = args; const actionRaw = rawAction === "rerun-current" ? "trigger-current" : rawAction; if ( actionRaw !== "status" && actionRaw !== "closeout" && actionRaw !== "apply" && actionRaw !== "trigger-current" && actionRaw !== "refresh" && actionRaw !== "cleanup-runs" && actionRaw !== "cleanup-released-pvs" && actionRaw !== "runtime-migration" ) { throw new Error("control-plane usage: status|apply|trigger-current|refresh --lane v02|v03 | closeout|runtime-migration --lane v02 | cleanup-runs --lane v02|v03|g14|all | cleanup-released-pvs --lane all [--dry-run|--confirm]"); } const laneRaw = optionValue(args, "--lane") ?? (actionRaw === "cleanup-released-pvs" ? "all" : "v02"); let lane: G14ControlPlaneOptions["lane"]; if (actionRaw === "cleanup-runs") { if (laneRaw !== "g14" && laneRaw !== "all" && !isHwlabRuntimeLane(laneRaw)) throw new Error("control-plane cleanup-runs requires --lane v02|v03|g14|all"); lane = laneRaw; } else if (actionRaw === "cleanup-released-pvs") { if (laneRaw !== "all") throw new Error("control-plane cleanup-released-pvs requires --lane all because released PVs no longer preserve the v02/g14 PipelineRun lane"); lane = laneRaw; } else if (actionRaw === "closeout" || actionRaw === "runtime-migration") { if (laneRaw !== "v02") throw new Error("control-plane closeout/runtime-migration currently requires --lane v02"); lane = laneRaw; } else if (!isHwlabRuntimeLane(laneRaw)) { throw new Error(`control-plane ${actionRaw} requires --lane ${hwlabRuntimeLaneIds().join("|")}`); } else { lane = laneRaw; } const confirm = args.includes("--confirm"); const explicitDryRun = args.includes("--dry-run"); if (confirm && explicitDryRun) throw new Error("control-plane accepts only one of --confirm or --dry-run"); const allowLiveDbRead = args.includes("--allow-live-db-read"); if (allowLiveDbRead && confirm) throw new Error("control-plane runtime-migration accepts --allow-live-db-read only with dry-run/source-check mode, not --confirm"); if (allowLiveDbRead && actionRaw !== "runtime-migration") throw new Error("--allow-live-db-read is only valid for control-plane runtime-migration"); const sourceCommitRaw = optionValue(args, "--source-commit"); const pipelineRunRaw = optionValue(args, "--pipeline-run"); if ((sourceCommitRaw !== undefined || pipelineRunRaw !== undefined) && actionRaw !== "status" && actionRaw !== "closeout" && actionRaw !== "cleanup-runs") { throw new Error("--source-commit and --pipeline-run are only valid for control-plane status/closeout/cleanup-runs"); } if (actionRaw === "closeout" && sourceCommitRaw === undefined && pipelineRunRaw === undefined) { throw new Error("control-plane closeout requires --source-commit or --pipeline-run hwlab-v02-ci-poll-"); } if (sourceCommitRaw !== undefined && pipelineRunRaw !== undefined) throw new Error("control-plane status/closeout/cleanup-runs accepts only one of --source-commit or --pipeline-run"); return { action: actionRaw, lane, confirm, wait: args.includes("--wait"), rerun: args.includes("--rerun") || rawAction === "rerun-current", allowLiveDbRead, dryRun: actionRaw === "status" || actionRaw === "closeout" ? true : explicitDryRun || !confirm, timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 600), minAgeMinutes: positiveIntegerOption(args, "--min-age-minutes", 60, 10080), limit: positiveIntegerOption(args, "--limit", 20, 200), sourceCommit: sourceCommitRaw === undefined ? undefined : validateFullShaOption(sourceCommitRaw, "--source-commit"), pipelineRun: pipelineRunRaw === undefined ? undefined : isHwlabRuntimeLane(lane) ? validateRuntimeLanePipelineRunOption(lane, pipelineRunRaw) : validateV02PipelineRunOption(pipelineRunRaw), history: args.includes("--history"), }; } export function parseLegacyRetirementOptions(args: string[]): G14LegacyRetirementOptions { const [actionRaw = "status"] = args; if (actionRaw !== "status" && actionRaw !== "plan" && actionRaw !== "execute") { throw new Error("retirement usage: status|plan|execute [--dry-run|--confirm] [--wait] [--reason text] [--timeout-seconds N]"); } const confirm = args.includes("--confirm"); const explicitDryRun = args.includes("--dry-run"); if (confirm && explicitDryRun) throw new Error("retirement accepts only one of --confirm or --dry-run"); if (confirm && actionRaw !== "execute") throw new Error("--confirm is only valid for retirement execute"); if (args.includes("--wait") && actionRaw !== "execute") throw new Error("--wait is only valid for retirement execute"); const reason = optionValue(args, "--reason") ?? "user requested immediate retirement of legacy G14 DEV/PROD"; if (reason.length > 240) throw new Error("--reason is limited to 240 characters"); return { action: actionRaw, confirm, wait: args.includes("--wait"), dryRun: actionRaw !== "execute" || explicitDryRun || !confirm, timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", actionRaw === "execute" ? 60 : 30, 300), reason, }; } export function parseToolsImageOptions(args: string[]): G14ToolsImageOptions { const [actionRaw] = args; if (actionRaw !== "status" && actionRaw !== "build") { throw new Error("tools-image usage: status|build --name ci-node-tools --tag [--dockerfile path] [--dry-run|--confirm]"); } const name = optionValue(args, "--name") ?? "ci-node-tools"; if (name !== "ci-node-tools") throw new Error("tools-image currently supports --name ci-node-tools"); const tag = optionValue(args, "--tag") ?? G14_CI_TOOLS_BASE_TAG; if (!/^[A-Za-z0-9_.-]+$/u.test(tag)) throw new Error("--tag may only contain letters, numbers, dot, underscore, and dash"); const dockerfile = optionValue(args, "--dockerfile") ?? "deploy/ci/hwlab-ci-node-tools.Dockerfile"; if (dockerfile.startsWith("/") || dockerfile.includes("..")) throw new Error("--dockerfile must be a repo-relative path without '..'"); const confirm = args.includes("--confirm"); const explicitDryRun = args.includes("--dry-run"); if (confirm && explicitDryRun) throw new Error("tools-image accepts only one of --confirm or --dry-run"); return { action: actionRaw, name, tag, dockerfile, confirm, dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm, timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 600, 1800), }; } export function parseUpstreamImageOptions(args: string[]): G14UpstreamImageOptions { const [actionRaw] = args; if (actionRaw !== "status" && actionRaw !== "ensure") { throw new Error("upstream-image usage: status|ensure --name openfga --tag v1.17.0 [--dry-run|--confirm]"); } const name = optionValue(args, "--name") ?? "openfga"; if (name !== "openfga") throw new Error("upstream-image currently supports --name openfga"); const tag = optionValue(args, "--tag") ?? "v1.17.0"; if (tag !== "v1.17.0") throw new Error("upstream-image currently supports OpenFGA tag v1.17.0"); const confirm = args.includes("--confirm"); const explicitDryRun = args.includes("--dry-run"); if (confirm && explicitDryRun) throw new Error("upstream-image accepts only one of --confirm or --dry-run"); return { action: actionRaw, name, tag, confirm, dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm, timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 600, 1800), }; } export function parseGitMirrorOptions(args: string[]): G14GitMirrorOptions { const [actionRaw] = args; if (actionRaw !== "status" && actionRaw !== "apply" && actionRaw !== "sync" && actionRaw !== "flush") { throw new Error("git-mirror usage: status|apply|sync|flush [--lane v02|v03] [--dry-run|--confirm]"); } const laneRaw = optionValue(args, "--lane") ?? "v02"; if (!isHwlabRuntimeLane(laneRaw)) throw new Error(`git-mirror --lane must be ${hwlabRuntimeLaneIds().join("|")}`); const confirm = args.includes("--confirm"); const explicitDryRun = args.includes("--dry-run"); if (confirm && explicitDryRun) throw new Error("git-mirror accepts only one of --confirm or --dry-run"); return { action: actionRaw, lane: laneRaw, confirm, wait: args.includes("--wait"), dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm, timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", actionRaw === "sync" || actionRaw === "flush" ? 300 : 120, 900), }; } export function parseObservabilityOptions(args: string[]): G14ObservabilityOptions { const [actionRaw] = args; if ( actionRaw !== "status" && actionRaw !== "apply" && actionRaw !== "query" && actionRaw !== "targets" && actionRaw !== "boundary" && actionRaw !== "closeout" ) { throw new Error("observability usage: status|apply|query|targets|boundary|closeout [--lane v02] [--promql ] [--expect-count N] [--expect-value V] [--dry-run|--confirm]"); } const valueOptions = new Set(["--lane", "--promql", "--query", "--expect-count", "--expect-value", "--timeout-seconds"]); const booleanOptions = new Set(["--confirm", "--dry-run"]); for (let i = 1; i < args.length; i += 1) { const arg = args[i]; if (valueOptions.has(arg)) { i += 1; if (i >= args.length || args[i].startsWith("--")) throw new Error(`${arg} requires a value`); continue; } if (booleanOptions.has(arg)) continue; if (arg === "--wait") throw new Error("observability does not support --wait; commands are bounded short CLI calls"); if (arg.startsWith("--")) throw new Error(`unsupported observability option: ${arg}`); throw new Error(`unexpected observability argument: ${arg}`); } const lane = optionValue(args, "--lane") ?? "v02"; if (lane !== "v02") throw new Error("observability currently supports --lane v02"); const confirm = args.includes("--confirm"); const explicitDryRun = args.includes("--dry-run"); if (confirm && explicitDryRun) throw new Error("observability accepts only one of --confirm or --dry-run"); if ((confirm || explicitDryRun) && actionRaw !== "apply") throw new Error("--confirm and --dry-run are only supported by observability apply"); const query = optionValue(args, "--promql") ?? optionValue(args, "--query") ?? 'up{namespace="hwlab-v02"}'; if (query.length > 500) throw new Error("--promql is limited to 500 characters"); if (query.includes("\n") || query.includes("\r")) throw new Error("--promql must be a single-line expression"); const expectCountRaw = optionValue(args, "--expect-count"); const expectCount = expectCountRaw === undefined ? undefined : Number(expectCountRaw); if (expectCountRaw !== undefined && (!Number.isInteger(expectCount) || expectCount < 0)) throw new Error("--expect-count must be a non-negative integer"); const expectValue = optionValue(args, "--expect-value"); if ((expectCount !== undefined || expectValue !== undefined) && actionRaw !== "query") throw new Error("--expect-count and --expect-value are only supported by observability query"); if (expectValue !== undefined && expectValue.trim().length === 0) throw new Error("--expect-value must not be blank"); return { action: actionRaw, lane, confirm, wait: args.includes("--wait"), dryRun: actionRaw === "apply" ? explicitDryRun || !confirm : true, timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", actionRaw === "apply" ? 240 : 120, 900), query, expectCount, expectValue, }; } export function parseSecretOptions(args: string[]): G14SecretOptions { const [actionRaw] = args; if (actionRaw !== "status" && actionRaw !== "ensure" && actionRaw !== "delete") { throw new Error("secret usage: status|ensure --lane v02|v03 --name hwlab-v0x-openfga|hwlab-v0x-master-server-admin-api-key [--dry-run|--confirm] | delete --lane v02 --name [--dry-run|--confirm]"); } const laneRaw = optionValue(args, "--lane") ?? "v02"; if (!isHwlabRuntimeLane(laneRaw)) throw new Error(`secret --lane must be one of ${hwlabRuntimeLaneIds().join(", ")}`); const lane = laneRaw; const spec = hwlabRuntimeLaneSpec(lane); const openFgaSecret = runtimeLaneOpenFgaSecretName(spec); const masterAdminSecret = runtimeLaneMasterAdminApiKeySecretName(spec); const name = optionValue(args, "--name") ?? openFgaSecret; const key = optionValue(args, "--key"); const confirm = args.includes("--confirm"); const explicitDryRun = args.includes("--dry-run"); if (confirm && explicitDryRun) throw new Error("secret accepts only one of --confirm or --dry-run"); if (actionRaw === "delete") { if (lane !== "v02") throw new Error("secret delete is currently limited to --lane v02 obsolete cleanup"); if (key !== undefined) throw new Error("secret delete does not accept --key; it deletes a whole obsolete Secret object"); if (!/^hwlab-v02-[a-z0-9-]+$/u.test(name)) throw new Error("secret delete requires a hwlab-v02-* Secret name"); if (name === V02_OPENFGA_SECRET || name === V02_MASTER_ADMIN_API_KEY_SECRET || name === "hwlab-v02-postgres") throw new Error(`secret delete refuses required v0.2 Secret ${name}`); return { action: actionRaw, lane, confirm, dryRun: explicitDryRun || !confirm, name, preset: "generic-delete", timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 600), }; } if (name === masterAdminSecret) { if (key !== undefined && key !== V02_MASTER_ADMIN_API_KEY_SECRET_KEY) throw new Error(`secret ${masterAdminSecret} supports only key ${V02_MASTER_ADMIN_API_KEY_SECRET_KEY}`); return { action: actionRaw, lane, confirm, dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm, name, key: key ?? V02_MASTER_ADMIN_API_KEY_SECRET_KEY, preset: "master-server-admin-api-key", timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 600), }; } if (name !== openFgaSecret) { throw new Error(`secret status/ensure currently supports only --name ${openFgaSecret} or ${masterAdminSecret} for --lane ${lane}; use secret delete for obsolete v02 Secret objects`); } if (key !== undefined && key !== V02_OPENFGA_AUTHN_SECRET_KEY && key !== V02_OPENFGA_DATASTORE_URI_SECRET_KEY && key !== V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY) { throw new Error(`secret ${openFgaSecret} supports keys ${V02_OPENFGA_AUTHN_SECRET_KEY}, ${V02_OPENFGA_DATASTORE_URI_SECRET_KEY}, and ${V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY}`); } if (lane !== "v02" && key === V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY) throw new Error(`${openFgaSecret}/${V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY} is derived from ${postgresSecret}/POSTGRES_PASSWORD`); return { action: actionRaw, lane, confirm, dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm, name, key, preset: "openfga", timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 600), }; }