refactor: add node-scoped HWLAB lane CLI

This commit is contained in:
Codex
2026-06-08 14:18:58 +00:00
parent b3476b49f1
commit 8f51bbca91
8 changed files with 508 additions and 116 deletions
+149 -3
View File
@@ -1,9 +1,13 @@
import { existsSync, readFileSync } from "node:fs";
import { repoRoot, type Config } from "./config";
import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
import { runHwlabG14Command } from "./hwlab-g14";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane } from "./hwlab-node-lanes";
type SecretAction = "status" | "ensure";
type SecretPreset = "openfga" | "master-server-admin-api-key";
type DelegatedNodeDomain = "control-plane" | "git-mirror";
interface NodeSecretOptions {
action: SecretAction;
@@ -41,8 +45,11 @@ const OPENFGA_POSTGRES_PASSWORD_KEY = "postgres-password";
export async function runHwlabNodeCommand(_config: Config, args: string[]): Promise<Record<string, unknown>> {
if (args.length === 0 || args.includes("--help") || args.includes("-h")) return hwlabNodeHelp();
const [domain] = args;
if (domain === "control-plane" || domain === "git-mirror") {
return runNodeDelegatedDomain(_config, domain, args.slice(1));
}
if (domain !== "secret") {
return { ok: false, command: `hwlab node ${domain ?? ""}`.trim(), message: "supported commands: hwlab node secret status|ensure" };
return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes secret" };
}
const options = parseSecretOptions(args.slice(1));
return runNodeSecret(options);
@@ -53,7 +60,13 @@ export function hwlabNodeHelp(): Record<string, unknown> {
ok: true,
command: "hwlab nodes",
description: "Node/lane oriented HWLAB operations. G14 is a node id value passed by --node, not a command family.",
configPath: hwlabRuntimeLaneConfigPath(),
examples: [
"bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03",
"bun scripts/cli.ts hwlab nodes control-plane apply --node G14 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane refresh --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane trigger-current --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes git-mirror status --node G14 --lane v03",
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-openfga",
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-openfga --confirm",
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key --confirm",
@@ -61,14 +74,123 @@ export function hwlabNodeHelp(): Record<string, unknown> {
};
}
async function runNodeDelegatedDomain(config: Config, domain: DelegatedNodeDomain, args: string[]): Promise<Record<string, unknown>> {
const scoped = parseNodeScopedDelegatedOptions(domain, args);
if (domain === "control-plane" && scoped.action === "trigger-current" && scoped.confirm && !scoped.dryRun && !scoped.wait) {
return startNodeDelegatedJob(scoped);
}
if (domain === "git-mirror" && (scoped.action === "sync" || scoped.action === "flush") && scoped.confirm && !scoped.dryRun && !scoped.wait) {
return startNodeDelegatedJob(scoped);
}
const delegatedArgs = stripOption(args, "--node");
const result = await runHwlabG14Command(config, [domain, ...delegatedArgs]);
return rewriteDelegatedNodeResult(result, scoped);
}
function parseNodeScopedDelegatedOptions(domain: DelegatedNodeDomain, args: string[]): {
domain: DelegatedNodeDomain;
action: string;
node: string;
lane: HwlabRuntimeLane;
confirm: boolean;
dryRun: boolean;
wait: boolean;
timeoutSeconds: number;
originalArgs: string[];
} {
const [actionRaw] = args;
if (typeof actionRaw !== "string" || actionRaw.startsWith("--")) throw new Error(`${domain} usage: ${domain} ACTION --node NODE --lane vNN [--dry-run|--confirm]`);
const node = requiredOption(args, "--node");
assertNodeId(node);
const laneRaw = requiredOption(args, "--lane");
if (!isHwlabRuntimeLane(laneRaw)) throw new Error(`--lane must be one of v02, v03; got ${laneRaw}`);
const spec = hwlabRuntimeLaneSpec(laneRaw);
if (spec.nodeId !== node) throw new Error(`lane ${laneRaw} is configured for node ${spec.nodeId}; got --node ${node}`);
const confirm = args.includes("--confirm");
const dryRun = args.includes("--dry-run");
if (confirm && dryRun) throw new Error(`${domain} accepts only one of --confirm or --dry-run`);
return {
domain,
action: actionRaw,
node,
lane: laneRaw,
confirm,
dryRun,
wait: args.includes("--wait"),
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 1800, 3600),
originalArgs: [...args],
};
}
function startNodeDelegatedJob(options: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const commandArgs = [
"hwlab",
"nodes",
options.domain,
...stripOptions(options.originalArgs, ["--node", "--lane", "--confirm", "--dry-run", "--wait", "--timeout-seconds"]),
"--node",
options.node,
"--lane",
options.lane,
"--confirm",
"--timeout-seconds",
String(options.timeoutSeconds),
"--wait",
];
const command = ["bun", "scripts/cli.ts", ...commandArgs];
const job = startJob(
`hwlab_nodes_${options.lane}_${options.domain}_${options.action}`,
command,
`Run HWLAB ${options.lane} ${options.domain} ${options.action} for node ${options.node}`,
);
return {
ok: true,
command: `hwlab nodes ${options.domain} ${options.action} --node ${options.node} --lane ${options.lane}`,
node: options.node,
lane: options.lane,
mode: "async-job",
reason: "confirmed control-plane/mirror actions can spend tens of seconds on remote work; default is fire-and-forget to avoid silent blocking",
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
waitCommand: command.join(" "),
};
}
function rewriteDelegatedNodeResult(value: unknown, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const rewritten = rewriteDelegatedNodeValue(value, scoped);
const result = typeof rewritten === "object" && rewritten !== null && !Array.isArray(rewritten) ? rewritten as Record<string, unknown> : { value: rewritten };
return {
...result,
node: scoped.node,
commandFamily: "hwlab nodes",
};
}
function rewriteDelegatedNodeValue(value: unknown, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): unknown {
if (typeof value === "string") return rewriteDelegatedNodeString(value, scoped);
if (Array.isArray(value)) return value.map((item) => rewriteDelegatedNodeValue(item, scoped));
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteDelegatedNodeValue(item, scoped)]));
}
function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): string {
const replaceCommand = (text: string, domain: DelegatedNodeDomain) => {
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return text
.replace(new RegExp(`bun scripts/cli\\.ts hwlab g14 ${escapedDomain} ([a-z-]+)`, "gu"), `bun scripts/cli.ts hwlab nodes ${domain} $1 --node ${scoped.node}`)
.replace(new RegExp(`hwlab g14 ${escapedDomain} ([a-z-]+)`, "gu"), `hwlab nodes ${domain} $1 --node ${scoped.node}`);
};
return replaceCommand(replaceCommand(value, "control-plane"), "git-mirror");
}
function parseSecretOptions(args: string[]): NodeSecretOptions {
const [actionRaw] = args;
if (actionRaw !== "status" && actionRaw !== "ensure") {
throw new Error("secret usage: status|ensure --node NODE --lane vNN --name hwlab-vNN-openfga|hwlab-vNN-master-server-admin-api-key [--dry-run|--confirm]");
}
const node = optionValue(args, "--node") ?? "G14";
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = optionValue(args, "--lane") ?? "v03";
const lane = requiredOption(args, "--lane");
assertLane(lane);
const spec = runtimeSecretSpec({ node, lane });
const name = optionValue(args, "--name") ?? spec.openFgaSecret;
@@ -448,6 +570,30 @@ function optionValue(args: string[], name: string): string | undefined {
return value;
}
function requiredOption(args: string[], name: string): string {
const value = optionValue(args, name);
if (value === undefined) throw new Error(`${name} is required`);
return value;
}
function stripOption(args: string[], name: string): string[] {
return stripOptions(args, [name]);
}
function stripOptions(args: string[], names: readonly string[]): string[] {
const remove = new Set(names);
const without: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (remove.has(arg)) {
if (arg !== "--confirm" && arg !== "--dry-run" && arg !== "--wait") index += 1;
continue;
}
without.push(arg);
}
return without;
}
function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;