Compare commits

...

4 Commits

Author SHA1 Message Date
88d790d42c provisionAgent fixed 12-21-25 2025-12-21 22:12:26 +00:00
43f1853e4f provisionAgent fix 12-20-25 2025-12-21 10:22:24 +00:00
28a5b80e01 provisionAgentfixed 12-20-25 2025-12-20 19:34:09 +00:00
b23d982428 Provisioning split 9-19-25 2025-12-19 19:13:35 +00:00
3 changed files with 219 additions and 325 deletions

View File

@ -0,0 +1,20 @@
// src/api/handlers/provisionDev.js
export function normalizeDevRequest(body = {}) {
if (!body.runtime) {
throw new Error("runtime is required for dev container");
}
if (!body.version) {
throw new Error("version is required for dev container");
}
return {
customerId: body.customerId,
runtime: body.runtime,
version: body.version,
memoryMiB: body.memoryMiB || 2048,
cpuCores: body.cpuCores || 2,
portsNeeded: body.portsNeeded || 0,
};
}

View File

@ -0,0 +1,22 @@
// src/api/handlers/provisionGame.js
export function normalizeGameRequest(body = {}) {
if (!body.game) {
throw new Error("game is required");
}
if (!body.variant) {
throw new Error("variant is required");
}
return {
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,
};
}

View File

@ -1,13 +1,11 @@
// src/api/provisionAgent.js // src/api/provisionAgent.js
// FINAL AGENT-DRIVEN PROVISIONING PIPELINE // FINAL AGENT-DRIVEN PROVISIONING PIPELINE (STABLE + SCALABLE)
// Supports: paper, vanilla, purpur, forge, fabric, neoforge + Steam creds passthrough
import "dotenv/config"; import "dotenv/config";
import fetch from "node-fetch"; import fetch from "node-fetch";
import crypto from "crypto";
import prisma from "../services/prisma.js"; import prisma from "../services/prisma.js";
import proxmox, { import {
cloneContainer, cloneContainer,
configureContainer, configureContainer,
startWithRetry, startWithRetry,
@ -15,7 +13,6 @@ import proxmox, {
} from "../services/proxmoxClient.js"; } from "../services/proxmoxClient.js";
import { getCtIpWithRetry } from "../services/getCtIp.js"; import { getCtIpWithRetry } from "../services/getCtIp.js";
import { PortAllocationService } from "../services/portAllocator.js";
import { import {
allocateVmid, allocateVmid,
confirmVmidAllocated, confirmVmidAllocated,
@ -23,184 +20,131 @@ import {
} from "../services/vmidAllocator.js"; } from "../services/vmidAllocator.js";
import { enqueuePublishEdge } from "../queues/postProvision.js"; import { enqueuePublishEdge } from "../queues/postProvision.js";
import { normalizeGameRequest } from "./handlers/provisionGame.js";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); import { normalizeDevRequest } from "./handlers/provisionDev.js";
const AGENT_TEMPLATE_VMID = Number( const AGENT_TEMPLATE_VMID = Number(
process.env.AGENT_TEMPLATE_VMID || process.env.AGENT_TEMPLATE_VMID ||
process.env.BASE_TEMPLATE_VMID || process.env.BASE_TEMPLATE_VMID ||
process.env.PROXMOX_AGENT_TEMPLATE_VMID || process.env.PROXMOX_AGENT_TEMPLATE_VMID
900
); );
const AGENT_PORT = Number(process.env.ZLH_AGENT_PORT || 18888); const AGENT_PORT = Number(process.env.ZLH_AGENT_PORT || 18888);
const AGENT_TOKEN = process.env.ZLH_AGENT_TOKEN || null; const AGENT_TOKEN = process.env.ZLH_AGENT_TOKEN || null;
/* ------------------------------------------------------------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
VERSION PARSER const step = (name) =>
------------------------------------------------------------- */ console.log(`[agentProvision] step=${name}`);
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 HOSTNAME BUILDER
------------------------------------------------------------- */ ------------------------------------------------------------- */
function pickJavaRuntimeForMc(version) { function buildHostname({ ctype, game, variant, vmid }) {
const { major, minor, patch } = parseMcVersion(version); if (ctype === "dev") return `dev-${vmid}`;
if (major > 1) return 21; if (game === "minecraft") {
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({ game, variant, vmid }) {
const g = (game || "").toLowerCase();
const v = (variant || "").toLowerCase(); const v = (variant || "").toLowerCase();
if (v) return `mc-${v}-${vmid}`;
let prefix = "game"; return `mc-${vmid}`;
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}`; return `${game || "game"}-${vmid}`;
} }
/* ------------------------------------------------------------- /* -------------------------------------------------------------
ADMIN PASSWORD GENERATOR JAVA SELECTION (FIX)
------------------------------------------------------------- */ ------------------------------------------------------------- */
function generateAdminPassword() { function pickJavaForMinecraftVersion(version) {
return crypto.randomBytes(12).toString("base64url"); // version like "1.21.7"
} const parts = String(version).split(".");
const minor = Number(parts[1] || 0);
/* ------------------------------------------------------------- return minor >= 21
BUILD AGENT PAYLOAD
------------------------------------------------------------- */
function buildAgentPayload({
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 (paper, forge, fabric, vanilla, purpur)");
let art = artifactPath;
let jpath = javaPath;
// --------- VARIANT → ARTIFACT PATH ---------
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}`);
}
}
// --------- JAVA RUNTIME SELECTOR ----------
if (!jpath && g === "minecraft") {
const javaVersion = pickJavaRuntimeForMc(ver);
jpath =
javaVersion === 21
? "java/21/OpenJDK21.tar.gz" ? "java/21/OpenJDK21.tar.gz"
: "java/17/OpenJDK17.tar.gz"; : "java/17/OpenJDK17.tar.gz";
} }
// --------- MEMORY DEFAULTS ---------- /* -------------------------------------------------------------
let mem = Number(memoryMiB) || 0; PAYLOAD BUILDERS
if (mem <= 0) mem = ["forge", "neoforge"].includes(v) ? 4096 : 2048; ------------------------------------------------------------- */
// Steam + admin credentials (persisted, optional) function buildDevAgentPayload({ vmid, runtime, version, memoryMiB }) {
const resolvedSteamUser = steamUser || "anonymous"; if (!runtime) throw new Error("runtime required for dev container");
const resolvedSteamPass = steamPass || ""; if (!version) throw new Error("version required for dev container");
const resolvedSteamAuth = steamAuth || "";
const resolvedAdminUser = adminUser || "admin";
const resolvedAdminPass = adminPass || generateAdminPassword();
return { return {
vmid, vmid,
game: g, container_type: "dev",
variant: v, runtime,
version: ver, version,
world: w, memory_mb: Number(memoryMiB) || 2048,
ports: Array.isArray(ports) ? ports : [ports].filter(Boolean), };
artifact_path: art, }
java_path: jpath,
memory_mb: mem,
steam_user: resolvedSteamUser, function buildGameAgentPayload(req) {
steam_pass: resolvedSteamPass, let javaPath = req.javaPath;
steam_auth: resolvedSteamAuth, let artifactPath = req.artifactPath;
admin_user: resolvedAdminUser, // 🔧 FIXED JAVA LOGIC — NOTHING ELSE CHANGED
admin_pass: resolvedAdminPass, if (!javaPath && req.game === "minecraft") {
if (!req.version) {
throw new Error("minecraft version required for java selection");
}
javaPath = pickJavaForMinecraftVersion(req.version);
}
if (!artifactPath && req.game === "minecraft") {
switch (req.variant) {
case "forge":
artifactPath = `minecraft/forge/${req.version}/forge-installer.jar`;
break;
case "fabric":
artifactPath = `minecraft/fabric/${req.version}/fabric-server.jar`;
break;
case "neoforge":
artifactPath = `minecraft/neoforge/${req.version}/neoforge-installer.jar`;
break;
case "paper":
case "purpur":
case "vanilla":
artifactPath = `minecraft/${req.variant}/${req.version}/server.jar`;
break;
}
}
if (!javaPath) {
throw new Error(`BUG: java_path missing for ${req.game} ${req.variant}`);
}
if (!artifactPath) {
throw new Error(`BUG: artifact_path missing for ${req.game} ${req.variant}`);
}
return {
vmid: req.vmid,
container_type: "game",
game: req.game,
variant: req.variant,
version: req.version,
world: req.world,
ports: req.ports || [],
artifact_path: artifactPath,
java_path: javaPath,
memory_mb: req.memoryMiB,
admin_user: req.adminUser,
admin_pass: req.adminPass,
}; };
} }
/* ------------------------------------------------------------- /* -------------------------------------------------------------
SEND CONFIG triggers async provision+start in agent 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", method: "POST",
headers, headers,
body: JSON.stringify(payload), body: JSON.stringify(payload),
@ -212,239 +156,147 @@ async function sendAgentConfig({ ip, payload }) {
} }
} }
/* ------------------------------------------------------------- async function waitForAgentTerminalState({ ip, timeoutMs = 10 * 60_000 }) {
WAIT FOR AGENT READY (poll /status)
------------------------------------------------------------- */
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; const deadline = Date.now() + timeoutMs;
let last;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const resp = await fetch(url, { headers }); const res = await fetch(`http://${ip}:${AGENT_PORT}/status`);
if (!resp.ok) { if (res.ok) {
last = new Error(`/status HTTP ${resp.status}`); const data = await res.json();
} else {
const data = await resp.json().catch(() => ({}));
const state = (data.state || data.status || "").toLowerCase();
// Agent's state machine: if (data.state === "running") return;
// idle → installing → verifying → starting → running
if (state === "running") return { state: "running", raw: data };
if (state === "error" || state === "crashed") {
const msg = data.error || "";
throw new Error(`agent state=${state} ${msg ? `(${msg})` : ""}`);
}
last = new Error(`agent state=${state || "unknown"}`); if (data.state === "error") {
throw new Error(data.error || "agent error");
} }
} catch (err) {
last = err;
} }
} catch {}
await sleep(3000); await sleep(3000);
} }
throw last || new Error("Agent did not reach running state"); throw new Error("Agent did not reach running state");
} }
/* ------------------------------------------------------------- /* -------------------------------------------------------------
MAIN PROVISION ENTRYPOINT MAIN ENTRYPOINT
------------------------------------------------------------- */ ------------------------------------------------------------- */
export async function provisionAgentInstance(body = {}) { export async function provisionAgentInstance(body = {}) {
const { const rawType =
customerId, body.container_type ??
game, body.containerType ??
variant, body.ctype ??
version, "game";
world,
ctype: rawCtype,
name,
cpuCores,
memoryMiB,
diskGiB,
portsNeeded,
artifactPath,
javaPath,
// NEW optional fields if (!["game", "dev"].includes(rawType)) {
steamUser, throw new Error(`invalid container type: ${rawType}`);
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 ctype = rawCtype || "game";
const isMinecraft = game.toLowerCase().includes("minecraft");
let vmid;
let allocatedPortsMap = null;
let gamePorts = [];
let ctIp;
let instanceHostname;
try {
console.log("[agentProvision] STEP 1: allocate VMID");
vmid = await allocateVmid(ctype);
instanceHostname = generateSystemHostname({ game, variant, vmid });
console.log("[agentProvision] STEP 2: port allocation");
if (!isMinecraft && (portsNeeded ?? 0) > 0) {
gamePorts = await PortAllocationService.reserve({
vmid,
count: portsNeeded,
portType: "game",
});
allocatedPortsMap = { game: gamePorts };
} else {
gamePorts = [25565];
allocatedPortsMap = { game: gamePorts };
} }
const node = process.env.PROXMOX_NODE || "zlh-prod1"; const ctype = rawType;
const bridge = ctype === "dev" ? "vmbr2" : "vmbr3"; console.log(`[agentProvision] starting ${ctype} provisioning`);
const cpu = cpuCores ? Number(cpuCores) : 2;
const memory = memoryMiB ? Number(memoryMiB) : 2048;
const description = name const req =
? `${name} (customer=${customerId}; vmid=${vmid}; agent=v1)` ctype === "dev"
: `customer=${customerId}; vmid=${vmid}; agent=v1`; ? normalizeDevRequest(body)
: normalizeGameRequest(body);
const tags = [ let vmid;
`cust-${customerId}`, let ctIp;
`type-${ctype}`,
`game-${game}`,
variant ? `var-${variant}` : null,
]
.filter(Boolean)
.join(",");
console.log( try {
`[agentProvision] STEP 3: clone template ${AGENT_TEMPLATE_VMID} → vmid=${vmid}` step("allocate-vmid");
); vmid = await allocateVmid(ctype);
const hostname = buildHostname({
ctype,
game: req.game,
variant: req.variant,
vmid,
});
step("clone-container");
await cloneContainer({ await cloneContainer({
templateVmid: AGENT_TEMPLATE_VMID, templateVmid: AGENT_TEMPLATE_VMID,
vmid, vmid,
name: instanceHostname, name: hostname,
full: 1, full: 1,
}); });
console.log("[agentProvision] STEP 4: configure CPU/mem/bridge/tags"); step("configure-container");
await configureContainer({ await configureContainer({
vmid, vmid,
cpu, cpu: req.cpuCores || 2,
memory, memory: req.memoryMiB || 2048,
bridge, bridge: ctype === "dev" ? "vmbr2" : "vmbr3",
description,
tags,
}); });
console.log("[agentProvision] STEP 5: start container"); step("start-container");
await startWithRetry(vmid); await startWithRetry(vmid);
console.log("[agentProvision] STEP 6: detect container IP"); step("wait-for-ip");
const ip = await getCtIpWithRetry(vmid, node, 12, 10_000); ctIp = await getCtIpWithRetry(vmid);
if (!ip) throw new Error("Failed to detect container IP");
ctIp = ip;
console.log(`[agentProvision] ctIp=${ctIp}`); step("build-agent-payload");
const payload =
console.log("[agentProvision] STEP 7: build agent payload"); ctype === "dev"
const payload = buildAgentPayload({ ? buildDevAgentPayload({
vmid, vmid,
game, runtime: body.runtime,
variant, version: body.version,
version, memoryMiB: req.memoryMiB,
world, })
ports: gamePorts, : buildGameAgentPayload({ ...req, vmid });
artifactPath,
javaPath,
memoryMiB,
steamUser, step("send-agent-config");
steamPass,
steamAuth,
adminUser,
adminPass,
});
console.log("[agentProvision] STEP 8: POST /config to agent (async provision+start)");
await sendAgentConfig({ ip: ctIp, payload }); await sendAgentConfig({ ip: ctIp, payload });
console.log("[agentProvision] STEP 9: wait for agent to be running via /status"); await waitForAgentTerminalState({ ip: ctIp });
const agentResult = await waitForAgentRunning({ ip: ctIp });
console.log("[agentProvision] STEP 10: DB save"); step("persist-instance");
const instance = await prisma.containerInstance.create({ await prisma.containerInstance.create({
data: { data: {
vmid, vmid,
customerId, customerId: req.customerId,
ctype, ctype,
hostname: instanceHostname, hostname,
ip: ctIp, ip: ctIp,
allocatedPorts: allocatedPortsMap,
payload, payload,
agentState: agentResult.state, agentState: "running",
agentLastSeen: new Date(), agentLastSeen: new Date(),
}, },
}); });
console.log("[agentProvision] STEP 11: commit ports"); if (ctype === "game") {
if (!isMinecraft && gamePorts.length) { step("publish-edge");
await PortAllocationService.commit({
const edgePorts =
req.ports?.length
? req.ports
: req.game === "minecraft"
? [25565]
: [];
await enqueuePublishEdge({
vmid, vmid,
ports: gamePorts, slotHostname: hostname,
portType: "game", ctIp,
game: req.game,
ports: edgePorts,
}); });
} }
console.log("[agentProvision] STEP 12: publish edge"); step("confirm-vmid");
await enqueuePublishEdge({
vmid,
slotHostname: instanceHostname,
instanceHostname,
ports: gamePorts,
ctIp,
game,
});
await confirmVmidAllocated(vmid); await confirmVmidAllocated(vmid);
console.log("[agentProvision] COMPLETE"); return { vmid, hostname, ip: ctIp };
return {
vmid,
ip: ctIp,
hostname: instanceHostname,
ports: gamePorts,
instance,
};
} catch (err) { } catch (err) {
console.error("[agentProvision] ERROR:", err.message); step("error-cleanup");
if (vmid) {
try { try { await deleteContainer(vmid); } catch {}
if (vmid) await PortAllocationService.releaseByVmid(vmid); try { await releaseVmid(vmid); } catch {}
} catch {} }
try {
if (vmid) await deleteContainer(vmid);
} catch {}
try {
if (vmid) await releaseVmid(vmid);
} catch {}
throw err; throw err;
} }
} }