From 43f1853e4f79b034d66c9d3af550d2a888c460e8 Mon Sep 17 00:00:00 2001 From: jester Date: Sun, 21 Dec 2025 10:22:24 +0000 Subject: [PATCH] provisionAgent fix 12-20-25 --- src/api/handlers/provisionDev.js | 52 ++--- src/api/handlers/provisionGame.js | 68 ++---- src/api/provisionAgent.js | 366 +++++------------------------- 3 files changed, 88 insertions(+), 398 deletions(-) diff --git a/src/api/handlers/provisionDev.js b/src/api/handlers/provisionDev.js index 392a80a..a8c7b88 100644 --- a/src/api/handlers/provisionDev.js +++ b/src/api/handlers/provisionDev.js @@ -1,46 +1,20 @@ -// src/api/provisionDev.js -// DEV-SIDE request normalization + validation +// src/api/handlers/provisionDev.js export function normalizeDevRequest(body = {}) { - const { - customerId, - name, - cpuCores, - memoryMiB, - diskGiB, - portsNeeded, + if (!body.runtime) { + throw new Error("runtime is required for dev container"); + } - // dev fields - runtime, - - // canonical runtime version field for dev (matches your curl) - version, - - // legacy/alternate naming (optional) - runtimeVersion, - - // optional addons - addons, - } = body; - - if (!customerId) throw new Error("customerId required"); - if (!runtime) throw new Error("runtime required"); - const resolvedVersion = version || runtimeVersion; - if (!resolvedVersion) throw new Error("version required"); + if (!body.version) { + throw new Error("version is required for dev container"); + } return { - customerId, - name, - cpuCores, - memoryMiB, - diskGiB, - portsNeeded, - - runtime, - version: String(resolvedVersion), - - addons: Array.isArray(addons) ? addons : undefined, + customerId: body.customerId, + runtime: body.runtime, + version: body.version, + memoryMiB: body.memoryMiB || 2048, + cpuCores: body.cpuCores || 2, + portsNeeded: body.portsNeeded || 0, }; } - -export default { normalizeDevRequest }; diff --git a/src/api/handlers/provisionGame.js b/src/api/handlers/provisionGame.js index b2b1de1..ebe0948 100644 --- a/src/api/handlers/provisionGame.js +++ b/src/api/handlers/provisionGame.js @@ -1,60 +1,22 @@ -// src/api/provisionGame.js -// GAME-SIDE request normalization + validation (no payload changes yet) +// src/api/handlers/provisionGame.js export function normalizeGameRequest(body = {}) { - const { - customerId, - game, - variant, - version, - world, - name, - cpuCores, - memoryMiB, - diskGiB, - portsNeeded, - artifactPath, - javaPath, + if (!body.game) { + throw new Error("game is required"); + } - // passthrough creds (kept here just for shaping; payload stays in provisionAgent.js for now) - steamUser, - steamPass, - steamAuth, - adminUser, - adminPass, - } = body; - - if (!customerId) throw new Error("customerId required"); - if (!game) throw new Error("game required"); - if (!variant) throw new Error("variant required"); - - const gameLower = String(game).toLowerCase(); - const isMinecraft = gameLower.includes("minecraft"); + if (!body.variant) { + throw new Error("variant is required"); + } return { - customerId, - game, - variant, - version, - world, - - name, - cpuCores, - memoryMiB, - diskGiB, - portsNeeded, - - artifactPath, - javaPath, - - steamUser, - steamPass, - steamAuth, - adminUser, - adminPass, - - isMinecraft, + customerId: body.customerId, + game: body.game, + variant: body.variant, + version: body.version, + world: body.world || "world", + memoryMiB: body.memoryMiB || 2048, + cpuCores: body.cpuCores || 2, + portsNeeded: body.portsNeeded || 0, }; } - -export default { normalizeGameRequest }; diff --git a/src/api/provisionAgent.js b/src/api/provisionAgent.js index 535803a..d300737 100644 --- a/src/api/provisionAgent.js +++ b/src/api/provisionAgent.js @@ -1,12 +1,5 @@ // src/api/provisionAgent.js -// FINAL AGENT-DRIVEN PROVISIONING PIPELINE -// Supports: paper, vanilla, purpur, forge, fabric, neoforge + dev containers -// -// Updated (Dec 2025): -// - Keep V3 hostname behavior (FQDN: mc-vanilla-5072.zerolaghub.quest) -// - Decouple edge publishing from PortPool allocation -// - Minecraft does NOT allocate PortPool ports, but still publishes edge using routing port 25565 -// - Preserve game/dev validation split (normalizeGameRequest / normalizeDevRequest) +// FINAL AGENT-DRIVEN PROVISIONING PIPELINE (STABLE + SCALABLE) import "dotenv/config"; import fetch from "node-fetch"; @@ -21,7 +14,6 @@ import { } from "../services/proxmoxClient.js"; import { getCtIpWithRetry } from "../services/getCtIp.js"; -import { PortAllocationService } from "../services/portAllocator.js"; import { allocateVmid, confirmVmidAllocated, @@ -33,7 +25,6 @@ import { enqueuePublishEdge } from "../queues/postProvision.js"; import { normalizeGameRequest } from "./handlers/provisionGame.js"; import { normalizeDevRequest } from "./handlers/provisionDev.js"; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const AGENT_TEMPLATE_VMID = Number( process.env.AGENT_TEMPLATE_VMID || @@ -45,179 +36,51 @@ const AGENT_TEMPLATE_VMID = Number( const AGENT_PORT = Number(process.env.ZLH_AGENT_PORT || 18888); const AGENT_TOKEN = process.env.ZLH_AGENT_TOKEN || null; -// V3 behavior: slotHostname is FQDN built here -const ZONE = process.env.TECHNITIUM_ZONE || "zerolaghub.quest"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /* ------------------------------------------------------------- - VERSION PARSER (Minecraft only) + PAYLOAD BUILDERS (CANONICAL) ------------------------------------------------------------- */ -function parseMcVersion(ver) { - if (!ver) return { major: 0, minor: 0, patch: 0 }; - const p = String(ver).split("."); - return { - major: Number(p[0]) || 0, - minor: Number(p[1]) || 0, - patch: Number(p[2]) || 0, - }; -} -/* ------------------------------------------------------------- - JAVA RUNTIME SELECTOR (Minecraft only) -------------------------------------------------------------- */ -function pickJavaRuntimeForMc(version) { - const { major, minor, patch } = parseMcVersion(version); - - if (major > 1) return 21; - - if (major === 1) { - if (minor >= 21) return 21; - if (minor === 20 && patch >= 5) return 21; - if (minor > 20) return 21; - return 17; - } - - return 17; -} - -/* ------------------------------------------------------------- - HOSTNAME GENERATION -------------------------------------------------------------- */ -function generateSystemHostname({ ctype, game, variant, vmid }) { - if (ctype === "dev") return `dev-${vmid}`; - - const g = (game || "").toLowerCase(); - const v = (variant || "").toLowerCase(); - - let prefix = "game"; - if (g.includes("minecraft")) prefix = "mc"; - else if (g.includes("terraria")) prefix = "terraria"; - else if (g.includes("valheim")) prefix = "valheim"; - else if (g.includes("rust")) prefix = "rust"; - - let varPart = ""; - if (g.includes("minecraft")) { - if ( - ["paper", "forge", "fabric", "vanilla", "purpur", "neoforge"].includes(v) - ) { - varPart = v; - } - } - - return varPart ? `${prefix}-${varPart}-${vmid}` : `${prefix}-${vmid}`; -} - -/* ------------------------------------------------------------- - ADMIN PASSWORD GENERATOR -------------------------------------------------------------- */ -function generateAdminPassword() { - return crypto.randomBytes(12).toString("base64url"); -} - -/* ------------------------------------------------------------- - GAME PAYLOAD -------------------------------------------------------------- */ -function buildGameAgentPayload({ - vmid, - game, - variant, - version, - world, - ports, - artifactPath, - javaPath, - memoryMiB, - steamUser, - steamPass, - steamAuth, - adminUser, - adminPass, -}) { - const g = (game || "minecraft").toLowerCase(); - const v = (variant || "").toLowerCase(); - const ver = version || "1.20.1"; - const w = world || "world"; - - if (!v) throw new Error("variant is required"); - - let art = artifactPath; - let jpath = javaPath; - - if (!art && g === "minecraft") { - switch (v) { - case "paper": - case "vanilla": - case "purpur": - art = `minecraft/${v}/${ver}/server.jar`; - break; - case "forge": - art = `minecraft/forge/${ver}/forge-installer.jar`; - break; - case "fabric": - art = `minecraft/fabric/${ver}/fabric-server.jar`; - break; - case "neoforge": - art = `minecraft/neoforge/${ver}/neoforge-installer.jar`; - break; - default: - throw new Error(`Unsupported Minecraft variant: ${v}`); - } - } - - if (!jpath && g === "minecraft") { - const javaVersion = pickJavaRuntimeForMc(ver); - jpath = - javaVersion === 21 - ? "java/21/OpenJDK21.tar.gz" - : "java/17/OpenJDK17.tar.gz"; - } - - let mem = Number(memoryMiB) || 0; - if (mem <= 0) mem = ["forge", "neoforge"].includes(v) ? 4096 : 2048; - - return { - vmid, - game: g, - variant: v, - version: ver, - world: w, - ports: Array.isArray(ports) ? ports : [ports].filter(Boolean), - artifact_path: art, - java_path: jpath, - memory_mb: mem, - steam_user: steamUser || "anonymous", - steam_pass: steamPass || "", - steam_auth: steamAuth || "", - admin_user: adminUser || "admin", - admin_pass: adminPass || generateAdminPassword(), - }; -} - -/* ------------------------------------------------------------- - DEV PAYLOAD -------------------------------------------------------------- */ -function buildDevAgentPayload({ vmid, runtime, version, memoryMiB, ports }) { +function buildDevAgentPayload({ vmid, runtime, version, memoryMiB }) { if (!runtime) throw new Error("runtime required for dev container"); if (!version) throw new Error("version required for dev container"); return { vmid, - ctype: "dev", + ctype: "dev", // ← CRITICAL, AGENT CONTRACT runtime, version, memory_mb: Number(memoryMiB) || 2048, - ports: Array.isArray(ports) ? ports : [ports].filter(Boolean), + }; +} + +function buildGameAgentPayload(req) { + // req already normalized by provisionGame + return { + vmid: req.vmid, + game: req.game, + variant: req.variant, + version: req.version, + world: req.world, + ports: req.ports || [], + artifact_path: req.artifactPath, + java_path: req.javaPath, + memory_mb: req.memoryMiB, + admin_user: req.adminUser, + admin_pass: req.adminPass, }; } /* ------------------------------------------------------------- - SEND CONFIG + AGENT COMMUNICATION ------------------------------------------------------------- */ -async function sendAgentConfig({ ip, payload }) { - const url = `http://${ip}:${AGENT_PORT}/config`; - const headers = { "Content-Type": "application/json" }; - if (AGENT_TOKEN) headers["Authorization"] = `Bearer ${AGENT_TOKEN}`; - const resp = await fetch(url, { +async function sendAgentConfig({ ip, payload }) { + const headers = { "Content-Type": "application/json" }; + if (AGENT_TOKEN) headers.Authorization = `Bearer ${AGENT_TOKEN}`; + + const resp = await fetch(`http://${ip}:${AGENT_PORT}/config`, { method: "POST", headers, body: JSON.stringify(payload), @@ -229,50 +92,18 @@ async function sendAgentConfig({ ip, payload }) { } } -/* ------------------------------------------------------------- - WAIT FOR AGENT READY -------------------------------------------------------------- */ async function waitForAgentRunning({ ip, timeoutMs = 10 * 60_000 }) { - const url = `http://${ip}:${AGENT_PORT}/status`; - const headers = {}; - if (AGENT_TOKEN) headers["Authorization"] = `Bearer ${AGENT_TOKEN}`; - const deadline = Date.now() + timeoutMs; - let lastLoggedStep = null; while (Date.now() < deadline) { try { - const resp = await fetch(url, { headers }); - if (resp.ok) { - const data = await resp.json(); - const state = (data.state || "").toLowerCase(); - const step = data.installStep || data.currentStep || "unknown"; - const progress = data.progress || ""; - - if (step !== lastLoggedStep) { - console.log(`[AGENT ${ip}] state=${state} step=${step} ${progress}`); - lastLoggedStep = step; - } - - if (state === "running") { - console.log(`[AGENT ${ip}] ✓ Provisioning complete`); - return data; - } - - if (state === "error") { - const errorMsg = data.error || "agent error"; - console.error(`[AGENT ${ip}] ✗ ERROR:`, errorMsg); - throw new Error(errorMsg); - } + const res = await fetch(`http://${ip}:${AGENT_PORT}/status`); + if (res.ok) { + const data = await res.json(); + if (data.state === "running") return; + if (data.state === "error") throw new Error(data.error || "agent error"); } - } catch (err) { - if ( - !err.message.includes("ECONNREFUSED") && - !err.message.includes("fetch failed") - ) { - console.error(`[AGENT ${ip}] Poll error:`, err.message); - } - } + } catch {} await sleep(3000); } @@ -282,63 +113,33 @@ async function waitForAgentRunning({ ip, timeoutMs = 10 * 60_000 }) { /* ------------------------------------------------------------- MAIN ENTRYPOINT ------------------------------------------------------------- */ + export async function provisionAgentInstance(body = {}) { const ctype = body.ctype || "game"; - console.log(`[agentProvision] STEP 0: Starting ${ctype} container provisioning`); + if (!["game", "dev"].includes(ctype)) { + throw new Error(`invalid ctype: ${ctype}`); + } + console.log(`[agentProvision] starting ${ctype} provisioning`); + + // EARLY SPLIT — DO NOT MOVE const req = - ctype === "dev" ? normalizeDevRequest(body) : normalizeGameRequest(body); - - const gameLower = String(req.game || "").toLowerCase(); - const isMinecraft = ctype === "game" && gameLower.includes("minecraft"); + ctype === "dev" + ? normalizeDevRequest(body) + : normalizeGameRequest(body); let vmid; let ctIp; - let allocatedPorts = []; - let txnId = null; try { - console.log("[agentProvision] STEP 1: allocate VMID"); vmid = await allocateVmid(ctype); - console.log(`[agentProvision] → Allocated vmid=${vmid}`); + console.log(`[agentProvision] vmid=${vmid}`); - // Allocate ports if needed (Minecraft skips PortPool; uses 25565 via Velocity) - if (!isMinecraft && req.portsNeeded && req.portsNeeded > 0) { - console.log("[agentProvision] STEP 2: port allocation"); - txnId = crypto.randomUUID(); + const hostname = + ctype === "dev" + ? `dev-${vmid}` + : req.hostname || `game-${vmid}`; - const portObjs = await PortAllocationService.reserve({ - game: req.game, - variant: req.variant, - customerId: req.customerId, - vmid, - purpose: ctype === "game" ? "game_main" : "dev", - txnId, - count: req.portsNeeded, - }); - - allocatedPorts = Array.isArray(portObjs) - ? portObjs.map((p) => (typeof p === "object" ? p.port : p)) - : [portObjs]; - - console.log(`[agentProvision] → Allocated ports: ${allocatedPorts.join(", ")}`); - } else { - console.log("[agentProvision] STEP 2: port allocation (skipped)"); - } - - const hostname = generateSystemHostname({ - ctype, - game: req.game, - variant: req.variant, - vmid, - }); - - // V3 correct behavior: build FQDN here - const slotHostname = `${hostname}.${ZONE}`; - - console.log( - `[agentProvision] STEP 3: clone template ${AGENT_TEMPLATE_VMID} → vmid=${vmid}` - ); await cloneContainer({ templateVmid: AGENT_TEMPLATE_VMID, vmid, @@ -346,7 +147,6 @@ export async function provisionAgentInstance(body = {}) { full: 1, }); - console.log("[agentProvision] STEP 4: configure CPU/mem/bridge/tags"); await configureContainer({ vmid, cpu: req.cpuCores || 2, @@ -354,14 +154,9 @@ export async function provisionAgentInstance(body = {}) { bridge: ctype === "dev" ? "vmbr2" : "vmbr3", }); - console.log("[agentProvision] STEP 5: start container"); await startWithRetry(vmid); - - console.log("[agentProvision] STEP 6: detect container IP"); ctIp = await getCtIpWithRetry(vmid); - console.log(`[agentProvision] → ctIp=${ctIp}`); - console.log("[agentProvision] STEP 7: build agent payload"); const payload = ctype === "dev" ? buildDevAgentPayload({ @@ -369,22 +164,15 @@ export async function provisionAgentInstance(body = {}) { runtime: body.runtime, version: body.version, memoryMiB: req.memoryMiB, - ports: allocatedPorts, }) - : buildGameAgentPayload({ - vmid, - ...req, - // agent can still use ports; for minecraft, provide 25565 semantic port - ports: allocatedPorts.length > 0 ? allocatedPorts : isMinecraft ? [25565] : [], - }); + : buildGameAgentPayload({ ...req, vmid }); + + console.log(`[agentProvision] payload:`); + console.log(JSON.stringify(payload, null, 2)); - console.log("[agentProvision] STEP 8: POST /config to agent (async provision+start)"); await sendAgentConfig({ ip: ctIp, payload }); - - console.log("[agentProvision] STEP 9: wait for agent to be running via /status"); await waitForAgentRunning({ ip: ctIp }); - console.log("[agentProvision] STEP 10: DB save"); await prisma.containerInstance.create({ data: { vmid, @@ -392,67 +180,33 @@ export async function provisionAgentInstance(body = {}) { ctype, hostname, ip: ctIp, - allocatedPorts, // matches schema payload, agentState: "running", agentLastSeen: new Date(), }, }); - // STEP 11: commit ports ONLY if allocated from PortPool - if (allocatedPorts.length > 0) { - console.log("[agentProvision] STEP 11: commit ports"); - await PortAllocationService.commit({ vmid, ports: allocatedPorts }); - } else { - console.log("[agentProvision] STEP 11: commit ports (skipped - none allocated)"); - } + if (!payload.ctype) { + throw new Error("Payload missing ctype (game|dev)"); +} - // STEP 12: publish edge for ALL game servers (Minecraft included) if (ctype === "game") { - console.log("[agentProvision] STEP 12: publish edge"); - - const edgePorts = - allocatedPorts.length > 0 ? allocatedPorts : isMinecraft ? [25565] : []; - await enqueuePublishEdge({ vmid, - slotHostname, // FQDN (V3 behavior) - instanceHostname: hostname, // short (optional, kept for compatibility) - ports: edgePorts, + instanceHostname: hostname, ctIp, game: req.game, - txnId, }); - } else { - console.log("[agentProvision] STEP 12: publish edge (skipped - dev container)"); } await confirmVmidAllocated(vmid); - console.log("[agentProvision] COMPLETE: success"); - return { vmid, hostname, ip: ctIp, ports: allocatedPorts }; + return { vmid, hostname, ip: ctIp }; } catch (err) { - console.error("[agentProvision] ERROR:", err.message); - - // Rollback ports on failure - if (vmid && allocatedPorts.length > 0) { - try { - await PortAllocationService.releaseByVmid(vmid); - console.log(`[agentProvision] → Rolled back ports for vmid=${vmid}`); - } catch (rollbackErr) { - console.error("[agentProvision] → Port rollback failed:", rollbackErr.message); - } - } - if (vmid) { - try { - await deleteContainer(vmid); - } catch {} - try { - await releaseVmid(vmid); - } catch {} + try { await deleteContainer(vmid); } catch {} + try { await releaseVmid(vmid); } catch {} } - throw err; } }