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

366 lines
20 KiB
TypeScript

// 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<G14MonitorOptions, "once" | "dryRun"> & { 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<G14MonitorOptions, "once" | "dryRun"> & { 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-<sha> 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}-<sha>[-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 <number>");
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 <full-sha> or --pipeline-run hwlab-v02-ci-poll-<sha>");
}
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 <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 <expr>] [--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 <obsolete-secret> [--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),
};
}