guanghulab/server/secrets-vault/lib/refresh-inference.js

194 lines
6.8 KiB
JavaScript
Raw Permalink Normal View History

/*
*
* AutoDL 推理端点本地刷新逻辑 (PR-5 落地 baton-005 D )
* 守护: 铸渊 · ICE-GL-ZY001
*
*
* baton-005 自检题 1: AutoDL 端口变, GitHub Secrets 改要 5 步等同步,
* 这里是为什么我们要本地 vault 1 步保存立即生效.
*
* 流程 (POST /admin/secrets/autodl/save_and_refresh):
* 1. vault 里的 ZY_AUTODL_HOST + ZY_AUTODL_PORT (本次 save 已落)
* 2. 探活 https://host:port/v1/health (重试 3 次)
* 3. 不通 不写 endpoint, 返回中文错误
* 4. 组装 inference-endpoint.json ( refresh-autodl-endpoint.yml
* 格式一致) 写到 PORTAL_DATA_DIR/inference-endpoint.json
* 5. (可选) pm2 reload portal 通过 pm2 二进制. portal 自己也会感知
* mtime 变化, 所以 reload 失败不阻断流程.
*
* 设计原则:
* cc-002 不在探活/写文件流程里塞任何 system prompt 痕迹
* cc-003 端点彻底动态, 写完立刻可用
* cc-004 任意一步失败都给中文回执, 不甩英文 stacktrace
*/
"use strict";
const fs = require("fs");
const http = require("http");
const https = require("https");
const path = require("path");
const { spawn } = require("child_process");
const { URL } = require("url");
function pick(u) {
return u.protocol === "https:" ? https : http;
}
function probe(url, timeoutMs) {
return new Promise((resolve) => {
let u;
try {
u = new URL(url);
} catch (e) {
return resolve({ ok: false, message: "推理端点 URL 不合法: " + e.message });
}
const req = pick(u).request(
u,
{ method: "GET", timeout: timeoutMs || 10000 },
(res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
if (res.statusCode !== 200) {
return resolve({
ok: false,
message: `推理端返回 ${res.statusCode}, 不像在跑`
});
}
try {
const j = JSON.parse(body);
resolve({ ok: true, body: j });
} catch (_) {
resolve({ ok: false, message: "推理端响应不是合法 JSON" });
}
});
}
);
req.on("error", (e) => resolve({ ok: false, message: "探活连接失败: " + e.message }));
req.on("timeout", () => {
req.destroy();
resolve({ ok: false, message: "探活超时 (10s)" });
});
req.end();
});
}
async function probeWithRetry(url, retries) {
for (let i = 0; i < retries; i++) {
const r = await probe(url, 10000);
if (r.ok) return r;
if (i < retries - 1) await new Promise((res) => setTimeout(res, 2000));
}
return { ok: false, message: "推理端探活 3 次都失败 (URL=" + url + ")" };
}
/**
* 探活成功后写 inference-endpoint.json + 尝试 pm2 reload
*
* @param {object} opts
* @param {string} opts.host
* @param {string|number} opts.port
* @param {string} [opts.scheme] 'https' | 'http'
* @param {string} opts.endpointPath 写入文件 (绝对路径)
* @param {boolean} [opts.skipPmReload] 测试时关
*/
async function saveAndRefresh(opts) {
const host = String(opts.host || "").trim();
const port = String(opts.port || "").trim();
const scheme = opts.scheme === "http" ? "http" : "https";
if (!host) return { ok: false, code: "host_empty", message: "AutoDL 连接地址 (host) 还没填." };
if (!/^[0-9]+$/.test(port)) {
return { ok: false, code: "port_invalid", message: "AutoDL 端口必须是纯数字, 当前=" + port };
}
const portN = parseInt(port, 10);
if (portN < 1 || portN > 65535) {
return { ok: false, code: "port_out_of_range", message: "AutoDL 端口越界 (1..65535)" };
}
const url = `${scheme}://${host}:${portN}`;
// 1. 探活
const health = await probeWithRetry(`${url}/v1/health`, 3);
if (!health.ok) {
return {
ok: false,
code: "health_failed",
message:
"推理端不可达, 没改成. 常见原因: AutoDL 实例没开机 / 端口抄错 / setup-inference.sh 还没跑完. 详情: " +
health.message
};
}
const h = health.body || {};
const gpu = h.gpu || {};
const tier = h.tier || {};
const model = h.model || {};
// 2. 组装 endpoint payload — 与 refresh-autodl-endpoint.yml 一致
const endpoint = {
_sovereign: "TCS-0002∞ · 国作登字-2026-A-00037559",
_守护: "铸渊 · ICE-GL-ZY001",
_写入来源: "secrets-vault save_and_refresh (本地一步保存)",
endpoint_url: url,
refreshed_at: new Date().toISOString(),
refreshed_by_run_id: null,
gpu: { name: gpu.name || "unknown", memory_gb: gpu.memory_gb || gpu.memory || "0" },
tier: { size_tier: tier.size_tier || "unknown", quantization: tier.quantization || "unknown" },
active_model: model.name || "unknown"
};
// 3. 写文件 (原子 rename)
try {
fs.mkdirSync(path.dirname(opts.endpointPath), { recursive: true });
const tmp = opts.endpointPath + ".tmp." + process.pid;
fs.writeFileSync(tmp, JSON.stringify(endpoint, null, 2), { mode: 0o644 });
fs.renameSync(tmp, opts.endpointPath);
} catch (e) {
return {
ok: false,
code: "write_failed",
message: "写 inference-endpoint.json 失败: " + e.message
};
}
// 4. 尝试 pm2 reload portal (可选 · 失败不挡)
let pm2_msg = "未尝试 pm2 reload (skipPmReload=true)";
if (!opts.skipPmReload) {
pm2_msg = await tryPmReload();
}
return {
ok: true,
code: "ok",
message: "已切到新端点 " + url + ", portal 会自动读到. " + pm2_msg,
endpoint
};
}
function tryPmReload() {
return new Promise((resolve) => {
let pm2Bin = process.env.PM2_BIN || "pm2";
const portalProcessName = process.env.PM2_PORTAL_PROCESS_NAME || "guanghulab-portal";
const child = spawn(pm2Bin, ["reload", portalProcessName], {
stdio: ["ignore", "pipe", "pipe"],
timeout: 15000
});
let stderr = "";
child.stderr.on("data", (c) => (stderr += c.toString()));
child.on("error", (e) => {
resolve("(pm2 不可用: " + e.message + ", portal 会通过 mtime 自动感知端点变更)");
});
child.on("close", (code) => {
if (code === 0) resolve("(已 pm2 reload portal)");
else
resolve(
"(pm2 reload 失败 code=" +
code +
", portal 会通过 mtime 自动感知端点变更; stderr=" +
stderr.slice(0, 120) +
")"
);
});
});
}
module.exports = { saveAndRefresh, probe, probeWithRetry };