Files
2026-06-09 03:13:32 +08:00

281 lines
11 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readDeployConfig, writeDeployConfig } from "./src/deploy-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultDeployPath = "deploy/deploy.yaml";
const defaultHost = "74.48.78.17";
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write(`${usage()}\n`);
process.exit(0);
}
const deploy = await readDeployConfig(repoRoot, args.deployConfig);
const result = await configureLane(deploy, args);
if (args.write) {
await writeDeployConfig(repoRoot, args.deployConfig, deploy);
for (const file of result.copiedFiles) {
await mkdir(path.dirname(path.join(repoRoot, file.to)), { recursive: true });
await writeFile(path.join(repoRoot, file.to), file.content, "utf8");
}
}
process.stdout.write(`${JSON.stringify({
ok: true,
command: "hwlab-lane-expand",
action: args.write ? "configured" : "planned",
deployConfig: args.deployConfig,
lane: result.lane,
fromLane: result.fromLane,
wrote: args.write,
copiedFiles: result.copiedFiles.map((file) => ({ from: file.from, to: file.to })),
config: laneSummary(result.config),
next: {
render: `node scripts/run-bun.mjs scripts/gitops-render.mjs --lane ${result.lane} --source-branch ${result.config.sourceBranch} --gitops-branch ${result.config.gitopsBranch} --catalog-path ${result.config.artifactCatalog} --image-tag-mode ${result.config.imageTagMode ?? "full"}`,
controlPlane: `bun scripts/cli.ts hwlab node control-plane apply --lane ${result.lane} --confirm`,
trigger: `bun scripts/cli.ts hwlab node control-plane trigger-current --lane ${result.lane} --confirm`
}
}, null, 2)}\n`);
function parseArgs(argv) {
const parsed = {
action: "configure",
lane: null,
fromLane: "v02",
deployConfig: defaultDeployPath,
host: defaultHost,
write: false,
force: false,
copyLaneFiles: true,
help: false,
webPort: null,
apiPort: null,
publicUrl: null
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "configure" || arg === "plan") parsed.action = arg;
else if (arg === "--lane") parsed.lane = readOption(argv, ++index, arg);
else if (arg === "--from") parsed.fromLane = readOption(argv, ++index, arg);
else if (arg === "--deploy-config") parsed.deployConfig = readOption(argv, ++index, arg);
else if (arg === "--host") parsed.host = readOption(argv, ++index, arg);
else if (arg === "--web-port") parsed.webPort = parsePort(readOption(argv, ++index, arg), arg);
else if (arg === "--api-port") parsed.apiPort = parsePort(readOption(argv, ++index, arg), arg);
else if (arg === "--public-url") parsed.publicUrl = readOption(argv, ++index, arg).replace(/\/+$/u, "");
else if (arg === "--write") parsed.write = true;
else if (arg === "--force") parsed.force = true;
else if (arg === "--no-copy-lane-files") parsed.copyLaneFiles = false;
else if (arg === "--help" || arg === "-h") parsed.help = true;
else throw new Error(`unknown argument ${arg}`);
}
if (!parsed.help) {
assertLane(parsed.lane, "--lane");
assertLane(parsed.fromLane, "--from");
assert.notEqual(parsed.lane, parsed.fromLane, "--lane must differ from --from");
}
return parsed;
}
function readOption(argv, index, name) {
const value = argv[index];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function parsePort(value, name) {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error(`${name} must be a TCP port`);
return parsed;
}
function usage() {
return [
"usage: node scripts/hwlab-lane-expand.mjs configure --lane v03 [--from v02] [--write]",
"",
"Create or preview a new HWLAB additive runtime lane by editing deploy/deploy.yaml only.",
"The command derives branch, namespace, GitOps branch, artifact catalog, runtime path, FRP ports,",
"and lane-specific SecretRef names from the target lane id so v04/v05/v06 do not require code edits.",
"",
"options:",
" --lane LANE target lane id such as v03, v04, v05",
" --from LANE source lane template; default: v02",
" --deploy-config PATH default: deploy/deploy.yaml",
" --host HOST public host for default legacy endpoints; default: 74.48.78.17",
" --web-port PORT override derived web FRP port",
" --api-port PORT override derived API FRP port",
" --public-url URL override derived HTTPS public endpoint",
" --write write deploy config and copied lane prompt files",
" --force replace an existing target lane",
" --no-copy-lane-files do not copy lane-scoped source files referenced by config"
].join("\n");
}
async function configureLane(deploy, options) {
deploy.lanes ??= {};
const source = deploy.lanes[options.fromLane];
assert.ok(source && typeof source === "object", `deploy.lanes.${options.fromLane} is required`);
if (deploy.lanes[options.lane] && !options.force) {
throw new Error(`deploy.lanes.${options.lane} already exists; pass --force to replace it`);
}
const sourceInfo = laneInfo(options.fromLane, { host: options.host });
const targetInfo = laneInfo(options.lane, {
host: options.host,
webPort: options.webPort,
apiPort: options.apiPort,
publicUrl: options.publicUrl
});
const replacements = laneReplacements(source, sourceInfo, targetInfo);
const target = deepTransform(clone(source), replacements);
target.name = targetInfo.versionName;
target.node = target.node ?? source.node;
assert.ok(target.node, "target lane node is required; set deploy.lanes.<lane>.node or source lane node");
target.sourceBranch = targetInfo.sourceBranch;
target.gitopsBranch = targetInfo.gitopsBranch;
target.namespace = targetInfo.namespace;
target.endpoint = targetInfo.publicUrl;
target.publicEndpoints = {
...(target.publicEndpoints ?? {}),
frontend: targetInfo.publicUrl,
api: targetInfo.publicUrl,
legacyFrontend: `http://${options.host}:${targetInfo.webPort}`,
legacyApi: `http://${options.host}:${targetInfo.apiPort}`
};
target.artifactCatalog = `deploy/artifact-catalog.${options.lane}.json`;
target.runtimePath = `runtime-${options.lane}`;
target.imageTagMode = target.imageTagMode ?? "full";
deploy.lanes[options.lane] = target;
const copiedFiles = options.copyLaneFiles ? await copiedLaneFiles(target, replacements, sourceInfo, targetInfo) : [];
return { lane: options.lane, fromLane: options.fromLane, config: target, copiedFiles };
}
function assertLane(value, label) {
if (!/^v[0-9]{2,}$/u.test(String(value ?? ""))) throw new Error(`${label} must look like v03, v04, v05`);
}
function laneInfo(lane, options = {}) {
assertLane(lane, "lane");
const minor = Number.parseInt(lane.slice(1), 10);
const webPort = options.webPort ?? 17666 + minor * 1000;
const apiPort = options.apiPort ?? webPort + 1;
const versionName = `v0.${minor}`;
const host = options.host ?? defaultHost;
const hostLabel = host.replaceAll(".", "-");
return {
lane,
minor,
host,
versionName,
sourceBranch: versionName,
gitopsBranch: `${versionName}-gitops`,
namespace: `hwlab-${lane}`,
webPort,
apiPort,
publicUrl: options.publicUrl ?? `https://hwlab-${lane}.${hostLabel}.nip.io`
};
}
function laneReplacements(sourceConfig, sourceInfo, targetInfo) {
const sourceLegacy = sourceConfig?.publicEndpoints ?? {};
return [
[sourceInfo.versionName, targetInfo.versionName],
[sourceInfo.sourceBranch, targetInfo.sourceBranch],
[sourceInfo.gitopsBranch, targetInfo.gitopsBranch],
[sourceInfo.namespace, targetInfo.namespace],
[sourceInfo.lane, targetInfo.lane],
[`runtime-${sourceInfo.lane}`, `runtime-${targetInfo.lane}`],
[`artifact-catalog.${sourceInfo.lane}.json`, `artifact-catalog.${targetInfo.lane}.json`],
[`usr_${sourceInfo.lane}_admin`, `usr_${targetInfo.lane}_admin`],
[`${sourceInfo.lane}-redacted-presence-only`, `${targetInfo.lane}-redacted-presence-only`],
[String(sourceLegacy.legacyFrontend ?? `http://${sourceInfo.host}:${sourceInfo.webPort}`), `http://${targetInfo.host}:${targetInfo.webPort}`],
[String(sourceLegacy.legacyApi ?? `http://${sourceInfo.host}:${sourceInfo.apiPort}`), `http://${targetInfo.host}:${targetInfo.apiPort}`],
[String(sourceLegacy.frontend ?? sourceConfig?.endpoint ?? ""), targetInfo.publicUrl],
[String(sourceLegacy.api ?? sourceConfig?.endpoint ?? ""), targetInfo.publicUrl]
].filter(([from]) => from);
}
function deepTransform(value, replacements) {
if (typeof value === "string") return replaceAll(value, replacements);
if (Array.isArray(value)) return value.map((item) => deepTransform(item, replacements));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [replaceAll(key, replacements), deepTransform(item, replacements)]));
}
return value;
}
function replaceAll(value, replacements) {
let text = String(value);
for (const [from, to] of replacements) text = text.split(from).join(to);
return text;
}
async function copiedLaneFiles(target, replacements, sourceInfo, targetInfo) {
const paths = new Set();
collectRepoPaths(target, paths);
const copied = [];
for (const targetPath of paths) {
if (!targetPath.includes(targetInfo.lane) && !targetPath.includes(targetInfo.versionName)) continue;
const sourcePath = targetPath
.split(targetInfo.lane).join(sourceInfo.lane)
.split(targetInfo.versionName).join(sourceInfo.versionName);
const sourceAbs = path.join(repoRoot, sourcePath);
const targetAbs = path.join(repoRoot, targetPath);
if (sourceAbs === targetAbs) continue;
let raw;
try {
raw = await readFile(sourceAbs, "utf8");
} catch (error) {
if (error?.code === "ENOENT") continue;
throw error;
}
try {
await readFile(targetAbs, "utf8");
continue;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
copied.push({ from: sourcePath, to: targetPath, content: replaceAll(raw, replacements) });
}
return copied;
}
function collectRepoPaths(value, result) {
if (typeof value === "string") {
if (/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/u.test(value) && !value.startsWith("http")) result.add(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) collectRepoPaths(item, result);
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) collectRepoPaths(item, result);
}
}
function laneSummary(config) {
return {
name: config.name,
sourceBranch: config.sourceBranch,
gitopsBranch: config.gitopsBranch,
namespace: config.namespace,
artifactCatalog: config.artifactCatalog,
runtimePath: config.runtimePath,
publicEndpoints: config.publicEndpoints,
serviceCount: Array.isArray(config.envReuseServices) ? config.envReuseServices.length : null
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}