281 lines
11 KiB
JavaScript
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 ?? 19466 + minor * 100;
|
|
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));
|
|
}
|