feat: kit 增加 registry 命令对接配置中心
All checks were successful
TDevOpsCICD / build-kit (push) Successful in 5m26s

This commit is contained in:
jif2.zhang 2026-09-11 14:14:18 +08:00
parent 31b50f9f43
commit 593ac96c8c
3 changed files with 506 additions and 0 deletions

View File

@ -18,6 +18,10 @@ const kitcommand = require("./kitcommand")
kitcommand.init()
kitcommand.regTo( parser )
const registry = require("./registry")
registry.init()
registry.regTo( parser )
// parser.addCmdLine("man", "打开在线帮助;", async function (){
// const uConfig = await $context.get("uConfig")

View File

@ -0,0 +1,98 @@
/**
* Registry HTTP 客户端
* 调用 Registry 配置中心 REST API
*/
const http = require("http");
const https = require("https");
class RegistryClient {
constructor(baseUrl, token) {
this.baseUrl = String(baseUrl || "").replace(/\/+$/, "");
this.token = token || "";
}
/**
* 发起 HTTP 请求
* @param {string} method - GET/POST/PUT/DELETE
* @param {string} path - API 路径
* @param {object} body - 请求体
* @returns {Promise<object>} 响应 data
*/
request(method, path, body) {
return new Promise((resolve, reject) => {
let url;
try {
url = new URL(this.baseUrl + path);
} catch (e) {
reject(new Error("Registry 地址无效: " + this.baseUrl));
return;
}
const lib = url.protocol === "https:" ? https : http;
const options = {
method,
hostname: url.hostname,
port: url.port || (url.protocol === "https:" ? 443 : 80),
path: url.pathname + url.search,
headers: { "Content-Type": "application/json" },
};
if (this.token) {
options.headers.Authorization = "Bearer " + this.token;
}
const req = lib.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch (e) {
/* 非 JSON 响应 */
}
if (parsed && parsed.success === true) {
resolve(parsed.data);
} else {
const msg = parsed && parsed.message
? parsed.message
: "HTTP " + res.statusCode;
const err = new Error(msg);
err.status = res.statusCode;
reject(err);
}
});
});
req.on("error", (e) => {
reject(new Error("Registry 不可达: " + e.message));
});
req.setTimeout(10000, () => {
req.destroy(new Error("请求超时"));
});
if (body !== undefined) {
req.write(JSON.stringify(body));
}
req.end();
});
}
listProjects() {
return this.request("GET", "/api/projects");
}
listConfigs(projectId) {
return this.request("GET", `/api/projects/${encodeURIComponent(projectId)}/configs`);
}
createConfig(projectId, data) {
return this.request("POST", `/api/projects/${encodeURIComponent(projectId)}/configs`, data);
}
updateConfig(projectId, configId, data) {
return this.request("PUT", `/api/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configId)}`, data);
}
}
module.exports = RegistryClient;

404
kit/src/registry/index.js Normal file
View File

@ -0,0 +1,404 @@
/**
* Registry 模块
* kit 命令行对接 Registry 配置管理中心
*
* 配置项通过 kit config 设置:
* registryUrl Registry 服务地址
* registryToken Registry 认证 Token
*
* 命令:
* kit registry 查看用法
* kit registry list 列出项目
* kit registry config <project[:path]> 查看配置
* kit registry config <project[:path]> <value> 设置配置
* kit registry export <project[:path]> [file] 导出配置
* kit registry import <project[:path]> <file> 导入配置
*/
const RegistryClient = require("./client");
const USAGE = [
"registry 用法:",
" kit registry list 列出 Registry 项目",
" kit registry config <project[:path]> 查看配置",
" kit registry config <project[:path]> <value> 设置配置",
" kit registry export <project[:path]> [file] 导出配置为 JSON",
" kit registry import <project[:path]> <file> 从 JSON 导入配置",
"配置项: kit config registryUrl <url> kit config registryToken <token>",
].join("\n");
/**
* 解析 project:path 语法
* @param {string} spec - 例如 CommonExternalService CommonExternalService:data/windows
*/
function parseProjectPath(spec) {
spec = String(spec || "");
const idx = spec.indexOf(":");
if (idx === -1) {
return { project: spec, path: "" };
}
return {
project: spec.substring(0, idx),
path: spec.substring(idx + 1).replace(/^\/+|\/+$/g, ""),
};
}
/**
* 规整 key去空白压缩连续斜杠去首尾斜杠
*/
function normalizeKey(key) {
return String(key || "")
.trim()
.split("/")
.map((s) => s.trim())
.filter((s) => s !== "")
.join("/");
}
/**
* 获取配置并用 path 过滤
* @returns {Promise<{all: Array, matched: Array}>}
*/
async function getConfigsFiltered(client, projectId, prefix) {
const configs = await client.listConfigs(projectId);
if (!prefix) {
return { all: configs, matched: configs };
}
const matched = configs.filter((c) => {
const p = c.path || "";
return p === prefix || p.startsWith(prefix + "/");
});
return { all: configs, matched };
}
/**
* 格式化配置值用于展示
*/
function formatValue(value, type) {
if (value === undefined || value === null) {
return "";
}
if (type === "json" && typeof value !== "string") {
return JSON.stringify(value);
}
return String(value);
}
/**
* 解析项目支持名称或 ID
*/
async function resolveProject(client, name) {
const projects = await client.listProjects();
const project = projects.find((p) => p.id === name || p.name === name);
if (!project) {
throw new Error("项目不存在: " + name);
}
return project;
}
/**
* 打印配置表格
*/
function printConfigTable(configs) {
if (!configs || configs.length === 0) {
$logger.info("无匹配的配置");
return;
}
const table = configs.map((c) => ({
路径: c.path,
: formatValue(c.value, c.type),
类型: c.type,
必填: c.required ? "✓" : "",
存在: c.exists === false ? "✗" : "✓",
}));
console.table(table);
$logger.info("共 " + configs.length + " 条配置");
}
/**
* 构建导出文件内容
*/
function buildExport(project, prefix, configs) {
return {
project: project.name,
path: prefix,
exportedAt: new Date().toISOString(),
configs: configs.map((c) => {
const fullPath = c.path || "";
let key = c.key || "";
if (prefix && fullPath === prefix) {
key = "";
} else if (prefix && fullPath.startsWith(prefix + "/")) {
key = fullPath.substring(prefix.length + 1);
}
return {
key,
path: fullPath,
value: c.value,
type: c.type,
description: c.description,
required: c.required === true,
exists: c.exists !== false,
defaultValue: c.defaultValue,
purpose: c.purpose,
valueRange: c.valueRange,
idc: c.idc || "",
environment: c.environment || "",
group: c.group || "",
};
}),
};
}
/**
* 按完整路径查找已存在的配置
*/
function findExistingByPath(configs, fullPath) {
const target = normalizeKey(fullPath);
return configs.find((c) => normalizeKey(c.path || "") === target);
}
/**
* 将完整路径分解为 key + 已启用 path 维度的值
* 通过前端剥离维度前缀还原存储 key
*/
function decomposeByDims(project, fullPath, dims) {
let key = normalizeKey(fullPath);
let idc = project.enableIdc ? String(dims.idc || "") : "";
let environment = project.enableEnvironment ? String(dims.environment || "") : "";
let group = project.enableGroup ? String(dims.group || "") : "";
if (idc && key.startsWith(idc + "/")) {
key = key.substring(idc.length + 1);
}
if (environment && key.startsWith(environment + "/")) {
key = key.substring(environment.length + 1);
}
if (group && key.startsWith(group + "/")) {
key = key.substring(group.length + 1);
}
return { key: normalizeKey(key), idc, environment, group };
}
/**
* 尝试解析 JSON 字符串
* 对于 json 类型值自动从字符串转为对象
*/
function maybeParseJson(value) {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
try {
return JSON.parse(trimmed);
} catch (e) {
// 解析失败,保持原字符串
}
}
return value;
}
/**
* 获取 Registry 客户端
*/
async function getClient() {
const uConfig = await $context.get("uConfig");
const baseUrl = uConfig.get("registryUrl");
if (!baseUrl) {
throw new Error("未配置 Registry 地址,请先运行: kit config registryUrl <url>");
}
const token = uConfig.get("registryToken") || "";
return new RegistryClient(baseUrl, token);
}
/**
* 默认导出文件名
*/
function defaultExportFile(projectName, prefix) {
const safe = String(projectName).replace(/[^a-zA-Z0-9_-]/g, "_");
const suffix = prefix ? "-" + String(prefix).replace(/[^a-zA-Z0-9_-]/g, "_") : "";
return safe + suffix + "-" + new Date().toISOString().slice(0, 10) + ".json";
}
async function cmdList(client) {
const projects = await client.listProjects();
if (!projects || projects.length === 0) {
$logger.info("Registry 上没有项目");
return;
}
const table = [];
for (const p of projects) {
let configs = [];
try {
configs = await client.listConfigs(p.id);
} catch (e) {
/* 忽略单个项目配置数获取失败 */
}
table.push({
ID: p.id,
名称: p.name,
说明: p.description,
机房: p.enableIdc ? "✓" : "✗",
环境: p.enableEnvironment ? "✓" : "✗",
分组: p.enableGroup ? "✓" : "✗",
配置数: configs.length,
});
}
console.table(table);
$logger.info("共 " + projects.length + " 个项目");
}
async function cmdConfig(client, spec, value) {
const { project: projectSpec, path: prefix } = parseProjectPath(spec);
if (!projectSpec) {
throw new Error("缺少 project用法: kit registry config <project[:path]> [value]");
}
const project = await resolveProject(client, projectSpec);
const { matched } = await getConfigsFiltered(client, project.id, prefix);
if (value === undefined || value === null) {
if (matched.length === 1 && matched[0].path === prefix) {
const c = matched[0];
$logger.info(formatValue(c.value, c.type));
} else {
printConfigTable(matched);
}
return;
}
if (matched.length === 0) {
throw new Error("配置不存在: " + prefix + ",请先用 kit registry import 导入或在 Registry 界面创建");
}
if (matched.length > 1) {
throw new Error("路径匹配到多条配置,请指定完整 key: " + prefix);
}
const target = matched[0];
const newValue = maybeParseJson(value);
const updated = await client.updateConfig(project.id, target.id, { value: newValue });
$logger.info("设置成功: " + updated.path + " = " + formatValue(updated.value, updated.type));
}
async function cmdExport(client, spec, file) {
const { project: projectSpec, path: prefix } = parseProjectPath(spec);
if (!projectSpec) {
throw new Error("缺少 project用法: kit registry export <project[:path]> [file]");
}
const project = await resolveProject(client, projectSpec);
const { matched } = await getConfigsFiltered(client, project.id, prefix);
const data = buildExport(project, prefix, matched);
const outFile = file || defaultExportFile(project.name, prefix);
const fs = require("fs");
fs.writeFileSync(outFile, JSON.stringify(data, null, 4), "utf-8");
$logger.info("已导出 " + matched.length + " 条配置到 " + outFile);
}
async function cmdImport(client, spec, file) {
const fs = require("fs");
if (!file) {
throw new Error("缺少 file用法: kit registry import <project[:path]> <file>");
}
if (!fs.existsSync(file)) {
throw new Error("文件不存在: " + file);
}
let data;
try {
const raw = fs.readFileSync(file, "utf-8").replace(/^\uFEFF/, "");
data = JSON.parse(raw);
} catch (e) {
throw new Error("JSON 解析失败: " + e.message);
}
if (!data || !Array.isArray(data.configs)) {
throw new Error("文件格式不正确,缺少 configs 数组");
}
const { project: projectSpec, path: prefix } = parseProjectPath(spec);
if (!projectSpec) {
throw new Error("缺少 project用法: kit registry import <project[:path]> <file>");
}
const project = await resolveProject(client, projectSpec);
const { all } = await getConfigsFiltered(client, project.id, "");
let created = 0;
let updated = 0;
let skipped = 0;
for (const item of data.configs) {
const baseKey = normalizeKey(item.key);
// 指定前缀时按前缀+相对key 重定位;未指定前缀时沿用导出时的完整路径,保证幂等
const targetPath = normalizeKey(prefix ? prefix + "/" + baseKey : item.path || baseKey);
if (!targetPath) {
skipped++;
continue;
}
const existing = findExistingByPath(all, targetPath);
if (existing) {
const patch = { value: item.value };
if (item.exists !== undefined) {
patch.exists = item.exists !== false;
}
await client.updateConfig(project.id, existing.id, patch);
updated++;
} else {
const { key, idc, environment, group } = decomposeByDims(project, targetPath, item);
await client.createConfig(project.id, {
key,
value: item.value,
type: item.type || "string",
description: item.description || "",
required: item.required === true,
defaultValue: item.defaultValue,
purpose: item.purpose,
valueRange: item.valueRange,
idc,
environment,
group,
});
created++;
}
}
$logger.info("导入完成: 新增 " + created + ",更新 " + updated + (skipped ? ",跳过 " + skipped : "") + " 条");
}
module.exports = {
init: function () {
return this;
},
regTo: function (parser) {
parser.addCmdLine("registry [sub] [project-path] [value]", "Registry 配置管理; sub: list/config/export/import", async function (cli) {
const sub = cli.getParamValue("sub");
const spec = cli.getParamValue("project-path");
const value = cli.getParamValue("value");
switch (sub) {
case "list": {
const client = await getClient();
await cmdList(client);
break;
}
case "config": {
const client = await getClient();
await cmdConfig(client, spec, value);
break;
}
case "export": {
const client = await getClient();
await cmdExport(client, spec, value);
break;
}
case "import": {
const client = await getClient();
await cmdImport(client, spec, value);
break;
}
default:
$logger.info(USAGE);
}
});
},
};