hyapp-deploly/deploy/server.mjs
2026-07-08 22:33:12 +08:00

795 lines
22 KiB
JavaScript

import { createReadStream, promises as fs, readFileSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
const deployDir = join(rootDir, "deploy");
const distDir = resolve(process.env.DEPLOY_DIST_DIR || join(rootDir, "dist"));
const apiPort = Number(process.env.DEPLOY_API_PORT || process.env.PORT || 29200);
const hyappInventoryPath = resolve(
process.env.HYAPP_DEPLOY_INVENTORY || join(deployDir, "tencent_tat", "hyapp-services.inventory.prod.example.json"),
);
const hyappTestbox = {
instanceId: process.env.HYAPP_TESTBOX_INSTANCE_ID || "lhins-q0m38zc6",
region: process.env.HYAPP_TESTBOX_REGION || "ap-jakarta",
root: process.env.HYAPP_TESTBOX_ROOT || "/opt/hyapp-server-test",
};
const hyappTestboxConfigServices =
process.env.HYAPP_TESTBOX_CONFIG_SERVICES ||
"gateway-service,room-service,wallet-service,user-service,activity-service,cron-service,game-service,notice-service,statistics-service,admin-server";
const platformInventoryPath = resolve(
process.env.DEPLOY_PLATFORM_INVENTORY || join(deployDir, "tencent_tat", "inventory.prod.example.json"),
);
const pythonBin = process.env.PYTHON_BIN || "python3";
const tencentEnvFile = process.env.TENCENT_ENV_FILE || "";
const credentialFile = resolve(process.env.TENCENT_CREDENTIALS_FILE || join(rootDir, ".env"));
const deployEnv = { ...process.env };
const edgeoneConfig = {
allowedHosts:
process.env.EDGEONE_ALLOWED_HOSTS || "h5.global-interaction.com,vivagames.global-interaction.com,asset.global-interaction.com",
zoneId: process.env.EDGEONE_ZONE_ID || "zone-3qwew52ihaj2",
zoneName: process.env.EDGEONE_ZONE_NAME || "global-interaction.com",
};
const mimeTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".ico", "image/x-icon"],
[".webp", "image/webp"],
]);
function cleanEnvValue(value) {
return String(value || "")
.trim()
.replace(/^export\s+/, "")
.replace(/^['"`]+|['"`]+$/g, "")
.trim();
}
function loadCredentialsFromFile(path) {
const keys = [
"TENCENTCLOUD_SECRET_ID",
"TENCENTCLOUD_SECRET_KEY",
"TENCENTCLOUD_REGION",
"TENCENT_SECRET_ID",
"TENCENT_SECRET_KEY",
"DEPLOY_REGION",
];
let raw = "";
try {
raw = readFileSync(path, "utf-8");
} catch {
return { loaded: [], source: path };
}
const loaded = [];
for (const key of keys) {
const patterns = [
new RegExp(`(?:^|\\n)\\s*(?:export\\s+)?${key}\\s*=\\s*([^\\n#]+)`, "m"),
new RegExp(`(?:^|\\n)\\s*${key}\\s*:\\s*([^\\n#]+)`, "m"),
];
for (const pattern of patterns) {
const match = raw.match(pattern);
if (match?.[1]) {
const value = cleanEnvValue(match[1]);
if (value && !deployEnv[key]) {
deployEnv[key] = value;
loaded.push(key);
}
break;
}
}
}
if (!deployEnv.TENCENTCLOUD_SECRET_ID && deployEnv.TENCENT_SECRET_ID) {
deployEnv.TENCENTCLOUD_SECRET_ID = deployEnv.TENCENT_SECRET_ID;
loaded.push("TENCENTCLOUD_SECRET_ID");
}
if (!deployEnv.TENCENTCLOUD_SECRET_KEY && deployEnv.TENCENT_SECRET_KEY) {
deployEnv.TENCENTCLOUD_SECRET_KEY = deployEnv.TENCENT_SECRET_KEY;
loaded.push("TENCENTCLOUD_SECRET_KEY");
}
return { loaded: [...new Set(loaded)], source: path };
}
const credentialLoadResult = loadCredentialsFromFile(credentialFile);
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
response.end(JSON.stringify(payload, null, 2));
}
function sendError(response, statusCode, message, detail = undefined) {
sendJson(response, statusCode, {
detail,
error: message,
ok: false,
});
}
async function readJson(path) {
const raw = await fs.readFile(path, "utf-8");
return JSON.parse(raw);
}
function parseServices(value, fallback) {
const source = Array.isArray(value) ? value.join(",") : value;
const services = String(source || fallback || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (services.length === 0) {
throw new Error("services is required");
}
for (const service of services) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(service)) {
throw new Error(`invalid service name: ${service}`);
}
}
return [...new Set(services)];
}
function parseHosts(value) {
if (!value) {
return [];
}
const source = Array.isArray(value) ? value.join(",") : value;
return String(source)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function readRequestJson(request) {
return new Promise((resolvePromise, reject) => {
let raw = "";
request.on("data", (chunk) => {
raw += chunk;
if (raw.length > 256 * 1024) {
reject(new Error("request body too large"));
request.destroy();
}
});
request.on("end", () => {
if (!raw.trim()) {
resolvePromise({});
return;
}
try {
resolvePromise(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
request.on("error", reject);
});
}
function runDeployScript(scriptName, args, timeoutMs = Number(process.env.DEPLOY_API_COMMAND_TIMEOUT_MS || 120_000)) {
return new Promise((resolvePromise) => {
const child = execFile(
pythonBin,
[join(deployDir, "tencent_tat", scriptName), ...args],
{
cwd: rootDir,
env: deployEnv,
timeout: timeoutMs,
},
(error, stdout, stderr) => {
let payload = null;
const text = stdout || stderr || "";
try {
payload = JSON.parse(text);
} catch {
payload = {
ok: false,
output: text,
};
}
resolvePromise({
exitCode: typeof error?.code === "number" ? error.code : 0,
ok: !error && payload?.ok !== false,
payload,
stderr,
});
},
);
child.stdin?.end();
});
}
function buildEnvArgs() {
return tencentEnvFile ? ["--env-file", tencentEnvFile] : [];
}
function parseMigrationFiles(value) {
const source = Array.isArray(value) ? value.join(",") : value;
const files = String(source || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
for (const file of files) {
if (!/^[0-9][A-Za-z0-9_.-]*\.sql$/.test(file)) {
throw new Error(`invalid migration file name: ${file}`);
}
}
return [...new Set(files)];
}
function parsePurgeUrls(value) {
const source = Array.isArray(value) ? value : String(value || "").split(/\s+/);
const urls = source
.flatMap((item) => String(item || "").split(/\s+/))
.map((item) => item.trim())
.filter(Boolean);
if (urls.length === 0) {
throw new Error("urls is required");
}
if (urls.length > 200) {
throw new Error("at most 200 urls are allowed");
}
const allowedHosts = new Set(
String(edgeoneConfig.allowedHosts || "")
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean),
);
for (const item of urls) {
let parsed;
try {
parsed = new URL(item);
} catch {
throw new Error(`invalid url: ${item}`);
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error(`invalid url protocol: ${item}`);
}
if (!allowedHosts.has(parsed.hostname.toLowerCase())) {
throw new Error(`url host is not allowed: ${parsed.hostname}`);
}
}
return [...new Set(urls)];
}
function requireConfirmed(body) {
if (body.dryRun === true) {
return;
}
if (body.confirmed !== true) {
throw new Error("confirmed=true is required for real execution");
}
}
function buildTestboxBaseArgs(action) {
return [
action,
...buildEnvArgs(),
"--region",
hyappTestbox.region,
"--instance-id",
hyappTestbox.instanceId,
"--root",
hyappTestbox.root,
];
}
function buildActionArgs(action, body) {
const services = parseServices(body.services, "");
const hosts = parseHosts(body.hosts);
const tag = String(body.tag || "").trim();
const dryRun = body.dryRun === true;
const confirmed = body.confirmed === true;
if (!dryRun && !confirmed) {
throw new Error("confirmed=true is required for real execution");
}
if (action === "deploy" && !tag) {
throw new Error("tag is required for deploy");
}
const args = [
action,
...buildEnvArgs(),
"--inventory",
hyappInventoryPath,
"--services",
services.join(","),
];
if (hosts.length > 0) {
args.push("--hosts", hosts.join(","));
}
if (action === "deploy") {
args.push("--tag", tag);
}
if (dryRun) {
args.push("--dry-run");
} else {
args.push("--yes");
}
return args;
}
async function handleApi(request, response, url) {
if (url.pathname === "/deploy/api/health") {
sendJson(response, 200, {
credentials: {
region: Boolean(deployEnv.TENCENTCLOUD_REGION || deployEnv.DEPLOY_REGION),
secretId: Boolean(deployEnv.TENCENTCLOUD_SECRET_ID || deployEnv.TENCENT_SECRET_ID),
secretKey: Boolean(deployEnv.TENCENTCLOUD_SECRET_KEY || deployEnv.TENCENT_SECRET_KEY),
source: credentialLoadResult.loaded.length > 0 ? credentialLoadResult.source : "",
},
distDir,
ok: true,
platformInventoryPath,
service: "deploy-platform-api",
});
return;
}
if (url.pathname === "/deploy/api/edgeone/purge-url") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const urls = parsePurgeUrls(body.urls);
const args = [
"purge-url",
...buildEnvArgs(),
"--zone-id",
edgeoneConfig.zoneId,
"--zone-name",
edgeoneConfig.zoneName,
"--allowed-hosts",
edgeoneConfig.allowedHosts,
"--wait",
];
for (const item of urls) {
args.push("--urls", item);
}
if (body.dryRun === true) {
args.push("--dry-run");
}
const result = await runDeployScript("edgeone_purge.py", args);
sendJson(response, result.ok ? 200 : 500, {
...result.payload,
edgeone: edgeoneConfig,
});
return;
}
if (url.pathname === "/deploy/api/hyapp/inventory") {
const inventory = await readJson(hyappInventoryPath);
sendJson(response, 200, {
hosts: inventory.hosts,
managedDependencies: inventory.managed_dependencies || {},
ok: true,
region: inventory.region,
registry: inventory.registry,
services: inventory.services,
});
return;
}
if (url.pathname === "/deploy/api/platform/inventory") {
const inventory = await readJson(platformInventoryPath);
sendJson(response, 200, {
hosts: inventory.hosts,
ok: true,
region: inventory.region,
registry: inventory.registry,
services: inventory.services,
});
return;
}
if (url.pathname === "/deploy/api/hyapp/plan") {
const services = parseServices(url.searchParams.get("services"), "gateway-service,room-service,user-service");
const action = url.searchParams.get("action") === "deploy" ? "deploy" : "plan";
const tag = String(url.searchParams.get("tag") || "").trim();
const args =
action === "deploy"
? [
"deploy",
"--dry-run",
"--inventory",
hyappInventoryPath,
"--services",
services.join(","),
"--tag",
tag || "REPLACE_TAG",
]
: ["plan", "--inventory", hyappInventoryPath, "--services", services.join(",")];
const result = await runDeployScript("hyapp_tat_deploy.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/restart") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
const result = await runDeployScript("hyapp_tat_deploy.py", buildActionArgs("restart", body));
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/deploy") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
const result = await runDeployScript("hyapp_tat_deploy.py", buildActionArgs("deploy", body));
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/admin-server/deploy") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const args = ["deploy", ...buildEnvArgs(), "--inventory", hyappInventoryPath];
if (body.dryRun === true) {
args.push("--dry-run");
} else {
args.push("--yes");
}
const result = await runDeployScript(
"hyapp_admin_server_deploy.py",
args,
Number(process.env.DEPLOY_ADMIN_SERVER_COMMAND_TIMEOUT_MS || 1_800_000),
);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/config") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const services = parseServices(body.service || body.services, "");
const hosts = parseHosts(body.hosts);
const content = String(body.content ?? "");
if (services.length !== 1) {
throw new Error("exactly one service is required");
}
if (!content.trim()) {
throw new Error("config content is required");
}
if (content.length > 80_000) {
throw new Error("config content is too large");
}
if (content.includes("***")) {
throw new Error("redacted config cannot be saved; replace masked secret fields before saving");
}
const args = [
"write-config",
...buildEnvArgs(),
"--inventory",
hyappInventoryPath,
"--services",
services.join(","),
"--content-base64",
Buffer.from(content, "utf-8").toString("base64"),
];
if (hosts.length > 0) {
args.push("--hosts", hosts.join(","));
}
if (body.restart === true) {
args.push("--restart");
}
if (body.dryRun === true) {
args.push("--dry-run");
} else {
args.push("--yes");
}
const result = await runDeployScript("hyapp_tat_deploy.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/discover") {
const services = parseServices(url.searchParams.get("services"), "gateway-service,room-service,user-service");
const executeRemote = url.searchParams.get("remote") === "1";
const includeConfig = url.searchParams.get("includeConfig") === "1" || url.searchParams.get("includeConfig") === "true";
const maxConfigBytes = Number(url.searchParams.get("maxConfigBytes") || 12000);
const args = [
"discover",
...buildEnvArgs(),
"--inventory",
hyappInventoryPath,
"--services",
services.join(","),
];
if (!executeRemote) {
args.push("--dry-run");
}
if (includeConfig) {
args.push("--include-config", "--max-config-bytes", String(Math.max(1000, Math.min(maxConfigBytes, 50000))));
}
const result = await runDeployScript("hyapp_tat_deploy.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/resources") {
const executeRemote = url.searchParams.get("remote") !== "0";
const args = ["resources", ...buildEnvArgs(), "--inventory", hyappInventoryPath];
if (!executeRemote) {
args.push("--dry-run");
}
const result = await runDeployScript("hyapp_data_resources.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/configs") {
const services = parseServices(url.searchParams.get("services"), hyappTestboxConfigServices);
const raw = url.searchParams.get("raw") === "1" || url.searchParams.get("raw") === "true";
const args = [...buildTestboxBaseArgs("configs"), "--services", services.join(",")];
if (raw && process.env.HYAPP_TESTBOX_CONFIG_ALLOW_RAW === "1") {
args.push("--raw");
}
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/status") {
const services = parseServices(url.searchParams.get("services"), hyappTestboxConfigServices);
const args = [...buildTestboxBaseArgs("status"), "--services", services.join(",")];
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/service") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const services = parseServices(body.services, hyappTestboxConfigServices);
const operation = String(body.operation || "").trim();
if (!["restart", "start", "stop", "update"].includes(operation)) {
throw new Error(`invalid service operation: ${operation}`);
}
const args = [...buildTestboxBaseArgs("service"), "--services", services.join(","), "--operation", operation];
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/timer") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const operation = String(body.operation || "").trim();
if (!["start", "stop", "restart", "status"].includes(operation)) {
throw new Error(`invalid timer operation: ${operation}`);
}
const args = [...buildTestboxBaseArgs("timer"), "--operation", operation];
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/config") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const service = parseServices(body.service, "")[0];
const content = String(body.content ?? "");
if (!content.trim()) {
throw new Error("config content is required");
}
if (content.length > 60_000) {
throw new Error("config content is too large");
}
if (content.includes("***")) {
throw new Error("redacted config cannot be saved; replace masked secret fields before saving");
}
const args = [
...buildTestboxBaseArgs("write-config"),
"--service",
service,
"--content-base64",
Buffer.from(content, "utf-8").toString("base64"),
];
if (body.restart === true) {
args.push("--restart");
}
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/migrations") {
const args = buildTestboxBaseArgs("migrations");
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/migrations/apply") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const files = parseMigrationFiles(body.files);
const args = buildTestboxBaseArgs("migrations-apply");
if (files.length > 0) {
args.push("--files", files.join(","));
}
if (body.dryRun === true) {
args.push("--dry-run");
}
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
sendError(response, 404, "deploy api route not found");
}
async function serveStatic(response, url) {
const requestedPath = decodeURIComponent(url.pathname);
const safePath = normalize(requestedPath).replace(/^(\.\.[/\\])+/, "");
const filePath = resolve(join(distDir, safePath));
if (!filePath.startsWith(distDir)) {
sendError(response, 403, "forbidden");
return;
}
let stat;
let target = filePath;
try {
stat = await fs.stat(target);
if (stat.isDirectory()) {
target = join(target, "index.html");
stat = await fs.stat(target);
}
} catch {
target = join(distDir, "index.html");
stat = await fs.stat(target);
}
response.writeHead(200, {
"content-length": stat.size,
"content-type": mimeTypes.get(extname(target)) || "application/octet-stream",
});
createReadStream(target).pipe(response);
}
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
if (url.pathname.startsWith("/deploy/api/")) {
await handleApi(request, response, url);
return;
}
await serveStatic(response, url);
} catch (error) {
sendError(response, 500, "deploy api internal error", error instanceof Error ? error.message : String(error));
}
});
server.listen(apiPort, "0.0.0.0", () => {
console.log(`deploy-platform listening on http://0.0.0.0:${apiPort}`);
console.log(`serving dist from ${distDir}`);
});