feat: kit 增加 registry 命令对接配置中心
All checks were successful
TDevOpsCICD / build-kit (push) Successful in 5m26s
All checks were successful
TDevOpsCICD / build-kit (push) Successful in 5m26s
This commit is contained in:
parent
31b50f9f43
commit
593ac96c8c
@ -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")
|
||||
|
||||
98
kit/src/registry/client.js
Normal file
98
kit/src/registry/client.js
Normal 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
404
kit/src/registry/index.js
Normal 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);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user