refactor: add node-scoped HWLAB lane CLI
This commit is contained in:
+149
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user