Provisioning split 9-19-25

This commit is contained in:
jester 2025-12-19 19:13:35 +00:00
parent 9138add35c
commit b23d982428
3 changed files with 185 additions and 201 deletions

View File

@ -0,0 +1,36 @@
// src/api/provisionDev.js
// DEV-SIDE request normalization + validation (payload not implemented yet)
export function normalizeDevRequest(body = {}) {
const {
customerId,
name,
cpuCores,
memoryMiB,
diskGiB,
portsNeeded,
// dev-specific fields (future)
runtime,
runtimeVersion,
addons,
} = body;
if (!customerId) throw new Error("customerId required");
// NOTE: Do NOT require game/variant/world for dev.
// Payload work is explicitly deferred per instruction.
return {
customerId,
name,
cpuCores,
memoryMiB,
diskGiB,
portsNeeded,
runtime,
runtimeVersion,
addons,
};
}
export default { normalizeDevRequest };

View File

@ -0,0 +1,60 @@
// src/api/provisionGame.js
// GAME-SIDE request normalization + validation (no payload changes yet)
export function normalizeGameRequest(body = {}) {
const {
customerId,
game,
variant,
version,
world,
name,
cpuCores,
memoryMiB,
diskGiB,
portsNeeded,
artifactPath,
javaPath,
// 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");
return {
customerId,
game,
variant,
version,
world,
name,
cpuCores,
memoryMiB,
diskGiB,
portsNeeded,
artifactPath,
javaPath,
steamUser,
steamPass,
steamAuth,
adminUser,
adminPass,
isMinecraft,
};
}
export default { normalizeGameRequest };

View File

@ -1,13 +1,18 @@
// src/api/provisionAgent.js // src/api/provisionAgent.js
// FINAL AGENT-DRIVEN PROVISIONING PIPELINE // FINAL AGENT-DRIVEN PROVISIONING PIPELINE
// Supports: paper, vanilla, purpur, forge, fabric, neoforge + Steam creds passthrough // Supports: paper, vanilla, purpur, forge, fabric, neoforge + dev containers
//
// Phase 12-14-25:
// - Orchestrator remains unified
// - Game/Dev validation split
// - Dev containers provision like game infra, diverge at runtime semantics
import "dotenv/config"; import "dotenv/config";
import fetch from "node-fetch"; import fetch from "node-fetch";
import crypto from "crypto"; 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,
@ -24,6 +29,9 @@ import {
import { enqueuePublishEdge } from "../queues/postProvision.js"; 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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const AGENT_TEMPLATE_VMID = Number( const AGENT_TEMPLATE_VMID = Number(
@ -37,7 +45,7 @@ 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;
/* ------------------------------------------------------------- /* -------------------------------------------------------------
VERSION PARSER VERSION PARSER (Minecraft only)
------------------------------------------------------------- */ ------------------------------------------------------------- */
function parseMcVersion(ver) { function parseMcVersion(ver) {
if (!ver) return { major: 0, minor: 0, patch: 0 }; if (!ver) return { major: 0, minor: 0, patch: 0 };
@ -50,7 +58,7 @@ function parseMcVersion(ver) {
} }
/* ------------------------------------------------------------- /* -------------------------------------------------------------
JAVA RUNTIME SELECTOR JAVA RUNTIME SELECTOR (Minecraft only)
------------------------------------------------------------- */ ------------------------------------------------------------- */
function pickJavaRuntimeForMc(version) { function pickJavaRuntimeForMc(version) {
const { major, minor, patch } = parseMcVersion(version); const { major, minor, patch } = parseMcVersion(version);
@ -70,7 +78,9 @@ function pickJavaRuntimeForMc(version) {
/* ------------------------------------------------------------- /* -------------------------------------------------------------
HOSTNAME GENERATION HOSTNAME GENERATION
------------------------------------------------------------- */ ------------------------------------------------------------- */
function generateSystemHostname({ game, variant, vmid }) { function generateSystemHostname({ ctype, game, variant, vmid }) {
if (ctype === "dev") return `dev-${vmid}`;
const g = (game || "").toLowerCase(); const g = (game || "").toLowerCase();
const v = (variant || "").toLowerCase(); const v = (variant || "").toLowerCase();
@ -97,9 +107,9 @@ function generateAdminPassword() {
} }
/* ------------------------------------------------------------- /* -------------------------------------------------------------
BUILD AGENT PAYLOAD GAME PAYLOAD (UNCHANGED)
------------------------------------------------------------- */ ------------------------------------------------------------- */
function buildAgentPayload({ function buildGameAgentPayload({
vmid, vmid,
game, game,
variant, variant,
@ -120,12 +130,11 @@ function buildAgentPayload({
const ver = version || "1.20.1"; const ver = version || "1.20.1";
const w = world || "world"; const w = world || "world";
if (!v) throw new Error("variant is required (paper, forge, fabric, vanilla, purpur)"); if (!v) throw new Error("variant is required");
let art = artifactPath; let art = artifactPath;
let jpath = javaPath; let jpath = javaPath;
// --------- VARIANT → ARTIFACT PATH ---------
if (!art && g === "minecraft") { if (!art && g === "minecraft") {
switch (v) { switch (v) {
case "paper": case "paper":
@ -133,25 +142,20 @@ function buildAgentPayload({
case "purpur": case "purpur":
art = `minecraft/${v}/${ver}/server.jar`; art = `minecraft/${v}/${ver}/server.jar`;
break; break;
case "forge": case "forge":
art = `minecraft/forge/${ver}/forge-installer.jar`; art = `minecraft/forge/${ver}/forge-installer.jar`;
break; break;
case "fabric": case "fabric":
art = `minecraft/fabric/${ver}/fabric-server.jar`; art = `minecraft/fabric/${ver}/fabric-server.jar`;
break; break;
case "neoforge": case "neoforge":
art = `minecraft/neoforge/${ver}/neoforge-installer.jar`; art = `minecraft/neoforge/${ver}/neoforge-installer.jar`;
break; break;
default: default:
throw new Error(`Unsupported Minecraft variant: ${v}`); throw new Error(`Unsupported Minecraft variant: ${v}`);
} }
} }
// --------- JAVA RUNTIME SELECTOR ----------
if (!jpath && g === "minecraft") { if (!jpath && g === "minecraft") {
const javaVersion = pickJavaRuntimeForMc(ver); const javaVersion = pickJavaRuntimeForMc(ver);
jpath = jpath =
@ -160,18 +164,9 @@ function buildAgentPayload({
: "java/17/OpenJDK17.tar.gz"; : "java/17/OpenJDK17.tar.gz";
} }
// --------- MEMORY DEFAULTS ----------
let mem = Number(memoryMiB) || 0; let mem = Number(memoryMiB) || 0;
if (mem <= 0) mem = ["forge", "neoforge"].includes(v) ? 4096 : 2048; if (mem <= 0) mem = ["forge", "neoforge"].includes(v) ? 4096 : 2048;
// Steam + admin credentials (persisted, optional)
const resolvedSteamUser = steamUser || "anonymous";
const resolvedSteamPass = steamPass || "";
const resolvedSteamAuth = steamAuth || "";
const resolvedAdminUser = adminUser || "admin";
const resolvedAdminPass = adminPass || generateAdminPassword();
return { return {
vmid, vmid,
game: g, game: g,
@ -182,18 +177,32 @@ function buildAgentPayload({
artifact_path: art, artifact_path: art,
java_path: jpath, java_path: jpath,
memory_mb: mem, memory_mb: mem,
steam_user: steamUser || "anonymous",
steam_user: resolvedSteamUser, steam_pass: steamPass || "",
steam_pass: resolvedSteamPass, steam_auth: steamAuth || "",
steam_auth: resolvedSteamAuth, admin_user: adminUser || "admin",
admin_pass: adminPass || generateAdminPassword(),
admin_user: resolvedAdminUser,
admin_pass: resolvedAdminPass,
}; };
} }
/* ------------------------------------------------------------- /* -------------------------------------------------------------
SEND CONFIG triggers async provision+start in agent DEV PAYLOAD (NEW, MINIMAL, CANONICAL)
------------------------------------------------------------- */
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",
runtime,
version,
memory_mb: Number(memoryMiB) || 2048,
};
}
/* -------------------------------------------------------------
SEND CONFIG
------------------------------------------------------------- */ ------------------------------------------------------------- */
async function sendAgentConfig({ ip, payload }) { async function sendAgentConfig({ ip, payload }) {
const url = `http://${ip}:${AGENT_PORT}/config`; const url = `http://${ip}:${AGENT_PORT}/config`;
@ -213,7 +222,7 @@ async function sendAgentConfig({ ip, payload }) {
} }
/* ------------------------------------------------------------- /* -------------------------------------------------------------
WAIT FOR AGENT READY (poll /status) WAIT FOR AGENT READY
------------------------------------------------------------- */ ------------------------------------------------------------- */
async function waitForAgentRunning({ ip, timeoutMs = 10 * 60_000 }) { async function waitForAgentRunning({ ip, timeoutMs = 10 * 60_000 }) {
const url = `http://${ip}:${AGENT_PORT}/status`; const url = `http://${ip}:${AGENT_PORT}/status`;
@ -221,230 +230,109 @@ async function waitForAgentRunning({ ip, timeoutMs = 10 * 60_000 }) {
if (AGENT_TOKEN) headers["Authorization"] = `Bearer ${AGENT_TOKEN}`; 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 resp = await fetch(url, { headers });
if (!resp.ok) { if (resp.ok) {
last = new Error(`/status HTTP ${resp.status}`); const data = await resp.json();
} else { const state = (data.state || "").toLowerCase();
const data = await resp.json().catch(() => ({})); if (state === "running") return data;
const state = (data.state || data.status || "").toLowerCase(); if (state === "error") throw new Error(data.error || "agent error");
// Agent's state machine:
// 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})` : ""}`);
} }
} catch {}
last = new Error(`agent state=${state || "unknown"}`);
}
} catch (err) {
last = err;
}
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 ctype = body.ctype || "game";
customerId,
game,
variant,
version,
world,
ctype: rawCtype,
name,
cpuCores,
memoryMiB,
diskGiB,
portsNeeded,
artifactPath,
javaPath,
// NEW optional fields const req =
steamUser, ctype === "dev"
steamPass, ? normalizeDevRequest(body)
steamAuth, : normalizeGameRequest(body);
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 vmid;
let allocatedPortsMap = null;
let gamePorts = [];
let ctIp; let ctIp;
let instanceHostname;
try { try {
console.log("[agentProvision] STEP 1: allocate VMID");
vmid = await allocateVmid(ctype); vmid = await allocateVmid(ctype);
instanceHostname = generateSystemHostname({ game, variant, vmid }); const hostname = generateSystemHostname({
ctype,
console.log("[agentProvision] STEP 2: port allocation"); game: req.game,
if (!isMinecraft && (portsNeeded ?? 0) > 0) { variant: req.variant,
gamePorts = await PortAllocationService.reserve({
vmid, vmid,
count: portsNeeded,
portType: "game",
}); });
allocatedPortsMap = { game: gamePorts };
} else {
gamePorts = [25565];
allocatedPortsMap = { game: gamePorts };
}
const node = process.env.PROXMOX_NODE || "zlh-prod1";
const bridge = ctype === "dev" ? "vmbr2" : "vmbr3";
const cpu = cpuCores ? Number(cpuCores) : 2;
const memory = memoryMiB ? Number(memoryMiB) : 2048;
const description = name
? `${name} (customer=${customerId}; vmid=${vmid}; agent=v1)`
: `customer=${customerId}; vmid=${vmid}; agent=v1`;
const tags = [
`cust-${customerId}`,
`type-${ctype}`,
`game-${game}`,
variant ? `var-${variant}` : null,
]
.filter(Boolean)
.join(",");
console.log(
`[agentProvision] STEP 3: clone template ${AGENT_TEMPLATE_VMID} → vmid=${vmid}`
);
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");
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");
await startWithRetry(vmid); await startWithRetry(vmid);
console.log("[agentProvision] STEP 6: detect container IP"); ctIp = await getCtIpWithRetry(vmid);
const ip = await getCtIpWithRetry(vmid, node, 12, 10_000);
if (!ip) throw new Error("Failed to detect container IP");
ctIp = ip;
console.log(`[agentProvision] ctIp=${ctIp}`); const payload =
ctype === "dev"
console.log("[agentProvision] STEP 7: build agent payload"); ? buildDevAgentPayload({
const payload = buildAgentPayload({
vmid, vmid,
game, runtime: body.runtime,
variant, version: body.version,
version, memoryMiB: req.memoryMiB,
world, })
ports: gamePorts, : buildGameAgentPayload({
artifactPath, vmid,
javaPath, ...req,
memoryMiB,
steamUser,
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 });
await waitForAgentRunning({ ip: ctIp });
console.log("[agentProvision] STEP 9: wait for agent to be running via /status"); await prisma.containerInstance.create({
const agentResult = await waitForAgentRunning({ ip: ctIp });
console.log("[agentProvision] STEP 10: DB save");
const instance = 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 (!isMinecraft && gamePorts.length) {
await PortAllocationService.commit({
vmid,
ports: gamePorts,
portType: "game",
});
}
console.log("[agentProvision] STEP 12: publish edge");
await enqueuePublishEdge({ await enqueuePublishEdge({
vmid, vmid,
slotHostname: instanceHostname, instanceHostname: hostname,
instanceHostname,
ports: gamePorts,
ctIp, ctIp,
game, game: req.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); if (vmid) {
try { await deleteContainer(vmid); } catch {}
try { try { await releaseVmid(vmid); } catch {}
if (vmid) await PortAllocationService.releaseByVmid(vmid); }
} catch {}
try {
if (vmid) await deleteContainer(vmid);
} catch {}
try {
if (vmid) await releaseVmid(vmid);
} catch {}
throw err; throw err;
} }
} }