Provisioning split 9-19-25
This commit is contained in:
parent
9138add35c
commit
b23d982428
36
src/api/handlers/provisionDev.js
Normal file
36
src/api/handlers/provisionDev.js
Normal 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 };
|
||||
60
src/api/handlers/provisionGame.js
Normal file
60
src/api/handlers/provisionGame.js
Normal 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 };
|
||||
@ -1,13 +1,18 @@
|
||||
// src/api/provisionAgent.js
|
||||
// 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 fetch from "node-fetch";
|
||||
import crypto from "crypto";
|
||||
|
||||
import prisma from "../services/prisma.js";
|
||||
import proxmox, {
|
||||
import {
|
||||
cloneContainer,
|
||||
configureContainer,
|
||||
startWithRetry,
|
||||
@ -24,6 +29,9 @@ import {
|
||||
|
||||
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(
|
||||
@ -37,7 +45,7 @@ const AGENT_PORT = Number(process.env.ZLH_AGENT_PORT || 18888);
|
||||
const AGENT_TOKEN = process.env.ZLH_AGENT_TOKEN || null;
|
||||
|
||||
/* -------------------------------------------------------------
|
||||
VERSION PARSER
|
||||
VERSION PARSER (Minecraft only)
|
||||
------------------------------------------------------------- */
|
||||
function parseMcVersion(ver) {
|
||||
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) {
|
||||
const { major, minor, patch } = parseMcVersion(version);
|
||||
@ -70,7 +78,9 @@ function pickJavaRuntimeForMc(version) {
|
||||
/* -------------------------------------------------------------
|
||||
HOSTNAME GENERATION
|
||||
------------------------------------------------------------- */
|
||||
function generateSystemHostname({ game, variant, vmid }) {
|
||||
function generateSystemHostname({ ctype, game, variant, vmid }) {
|
||||
if (ctype === "dev") return `dev-${vmid}`;
|
||||
|
||||
const g = (game || "").toLowerCase();
|
||||
const v = (variant || "").toLowerCase();
|
||||
|
||||
@ -97,9 +107,9 @@ function generateAdminPassword() {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------
|
||||
BUILD AGENT PAYLOAD
|
||||
GAME PAYLOAD (UNCHANGED)
|
||||
------------------------------------------------------------- */
|
||||
function buildAgentPayload({
|
||||
function buildGameAgentPayload({
|
||||
vmid,
|
||||
game,
|
||||
variant,
|
||||
@ -120,12 +130,11 @@ function buildAgentPayload({
|
||||
const ver = version || "1.20.1";
|
||||
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 jpath = javaPath;
|
||||
|
||||
// --------- VARIANT → ARTIFACT PATH ---------
|
||||
if (!art && g === "minecraft") {
|
||||
switch (v) {
|
||||
case "paper":
|
||||
@ -133,25 +142,20 @@ function buildAgentPayload({
|
||||
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 =
|
||||
@ -160,18 +164,9 @@ function buildAgentPayload({
|
||||
: "java/17/OpenJDK17.tar.gz";
|
||||
}
|
||||
|
||||
// --------- MEMORY DEFAULTS ----------
|
||||
let mem = Number(memoryMiB) || 0;
|
||||
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 {
|
||||
vmid,
|
||||
game: g,
|
||||
@ -182,18 +177,32 @@ function buildAgentPayload({
|
||||
artifact_path: art,
|
||||
java_path: jpath,
|
||||
memory_mb: mem,
|
||||
|
||||
steam_user: resolvedSteamUser,
|
||||
steam_pass: resolvedSteamPass,
|
||||
steam_auth: resolvedSteamAuth,
|
||||
|
||||
admin_user: resolvedAdminUser,
|
||||
admin_pass: resolvedAdminPass,
|
||||
steam_user: steamUser || "anonymous",
|
||||
steam_pass: steamPass || "",
|
||||
steam_auth: steamAuth || "",
|
||||
admin_user: adminUser || "admin",
|
||||
admin_pass: adminPass || generateAdminPassword(),
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------
|
||||
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 }) {
|
||||
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 }) {
|
||||
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}`;
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const resp = await fetch(url, { headers });
|
||||
if (!resp.ok) {
|
||||
last = new Error(`/status HTTP ${resp.status}`);
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const state = (data.state || data.status || "").toLowerCase();
|
||||
|
||||
// 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})` : ""}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
const state = (data.state || "").toLowerCase();
|
||||
if (state === "running") return data;
|
||||
if (state === "error") throw new Error(data.error || "agent error");
|
||||
}
|
||||
|
||||
last = new Error(`agent state=${state || "unknown"}`);
|
||||
}
|
||||
} catch (err) {
|
||||
last = err;
|
||||
}
|
||||
|
||||
} catch {}
|
||||
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 = {}) {
|
||||
const {
|
||||
customerId,
|
||||
game,
|
||||
variant,
|
||||
version,
|
||||
world,
|
||||
ctype: rawCtype,
|
||||
name,
|
||||
cpuCores,
|
||||
memoryMiB,
|
||||
diskGiB,
|
||||
portsNeeded,
|
||||
artifactPath,
|
||||
javaPath,
|
||||
const ctype = body.ctype || "game";
|
||||
|
||||
// NEW optional fields
|
||||
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 ctype = rawCtype || "game";
|
||||
const isMinecraft = game.toLowerCase().includes("minecraft");
|
||||
const req =
|
||||
ctype === "dev"
|
||||
? normalizeDevRequest(body)
|
||||
: normalizeGameRequest(body);
|
||||
|
||||
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({
|
||||
const hostname = generateSystemHostname({
|
||||
ctype,
|
||||
game: req.game,
|
||||
variant: req.variant,
|
||||
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({
|
||||
templateVmid: AGENT_TEMPLATE_VMID,
|
||||
vmid,
|
||||
name: instanceHostname,
|
||||
name: hostname,
|
||||
full: 1,
|
||||
});
|
||||
|
||||
console.log("[agentProvision] STEP 4: configure CPU/mem/bridge/tags");
|
||||
await configureContainer({
|
||||
vmid,
|
||||
cpu,
|
||||
memory,
|
||||
bridge,
|
||||
description,
|
||||
tags,
|
||||
cpu: req.cpuCores || 2,
|
||||
memory: req.memoryMiB || 2048,
|
||||
bridge: ctype === "dev" ? "vmbr2" : "vmbr3",
|
||||
});
|
||||
|
||||
console.log("[agentProvision] STEP 5: start container");
|
||||
await startWithRetry(vmid);
|
||||
|
||||
console.log("[agentProvision] STEP 6: detect container IP");
|
||||
const ip = await getCtIpWithRetry(vmid, node, 12, 10_000);
|
||||
if (!ip) throw new Error("Failed to detect container IP");
|
||||
ctIp = ip;
|
||||
ctIp = await getCtIpWithRetry(vmid);
|
||||
|
||||
console.log(`[agentProvision] ctIp=${ctIp}`);
|
||||
|
||||
console.log("[agentProvision] STEP 7: build agent payload");
|
||||
const payload = buildAgentPayload({
|
||||
const payload =
|
||||
ctype === "dev"
|
||||
? buildDevAgentPayload({
|
||||
vmid,
|
||||
game,
|
||||
variant,
|
||||
version,
|
||||
world,
|
||||
ports: gamePorts,
|
||||
artifactPath,
|
||||
javaPath,
|
||||
memoryMiB,
|
||||
|
||||
steamUser,
|
||||
steamPass,
|
||||
steamAuth,
|
||||
adminUser,
|
||||
adminPass,
|
||||
runtime: body.runtime,
|
||||
version: body.version,
|
||||
memoryMiB: req.memoryMiB,
|
||||
})
|
||||
: buildGameAgentPayload({
|
||||
vmid,
|
||||
...req,
|
||||
});
|
||||
|
||||
console.log("[agentProvision] STEP 8: POST /config to agent (async provision+start)");
|
||||
await sendAgentConfig({ ip: ctIp, payload });
|
||||
await waitForAgentRunning({ ip: ctIp });
|
||||
|
||||
console.log("[agentProvision] STEP 9: wait for agent to be running via /status");
|
||||
const agentResult = await waitForAgentRunning({ ip: ctIp });
|
||||
|
||||
console.log("[agentProvision] STEP 10: DB save");
|
||||
const instance = await prisma.containerInstance.create({
|
||||
await prisma.containerInstance.create({
|
||||
data: {
|
||||
vmid,
|
||||
customerId,
|
||||
customerId: req.customerId,
|
||||
ctype,
|
||||
hostname: instanceHostname,
|
||||
hostname,
|
||||
ip: ctIp,
|
||||
allocatedPorts: allocatedPortsMap,
|
||||
payload,
|
||||
agentState: agentResult.state,
|
||||
agentState: "running",
|
||||
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({
|
||||
vmid,
|
||||
slotHostname: instanceHostname,
|
||||
instanceHostname,
|
||||
ports: gamePorts,
|
||||
instanceHostname: hostname,
|
||||
ctIp,
|
||||
game,
|
||||
game: req.game,
|
||||
});
|
||||
|
||||
await confirmVmidAllocated(vmid);
|
||||
|
||||
console.log("[agentProvision] COMPLETE");
|
||||
|
||||
return {
|
||||
vmid,
|
||||
ip: ctIp,
|
||||
hostname: instanceHostname,
|
||||
ports: gamePorts,
|
||||
instance,
|
||||
};
|
||||
return { vmid, hostname, ip: ctIp };
|
||||
} catch (err) {
|
||||
console.error("[agentProvision] ERROR:", err.message);
|
||||
|
||||
try {
|
||||
if (vmid) await PortAllocationService.releaseByVmid(vmid);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (vmid) await deleteContainer(vmid);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (vmid) await releaseVmid(vmid);
|
||||
} catch {}
|
||||
|
||||
if (vmid) {
|
||||
try { await deleteContainer(vmid); } catch {}
|
||||
try { await releaseVmid(vmid); } catch {}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user