/* * ════════════════════════════════════════════════════════════════ * 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 };