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", 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}`); });