- STEP 0: Starting provisioning (game/dev indicator) - STEP 1: allocate VMID - STEP 2: port allocation (or skip message if not needed) - STEP 3: clone template - STEP 4: configure CPU/mem/bridge/tags - STEP 5: start container - STEP 6: detect container IP - STEP 7: build agent payload - STEP 8: POST /config to agent - STEP 9: wait for agent running via /status - STEP 10: DB save - STEP 11: commit ports - STEP 12: publish edge - Matches original provision.js logging format
439 lines
13 KiB
JavaScript
439 lines
13 KiB
JavaScript
// src/api/provisionAgent.js
|
|
// FINAL AGENT-DRIVEN PROVISIONING PIPELINE
|
|
// 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 {
|
|
cloneContainer,
|
|
configureContainer,
|
|
startWithRetry,
|
|
deleteContainer,
|
|
} from "../services/proxmoxClient.js";
|
|
|
|
import { getCtIpWithRetry } from "../services/getCtIp.js";
|
|
import { PortAllocationService } from "../services/portAllocator.js";
|
|
import {
|
|
allocateVmid,
|
|
confirmVmidAllocated,
|
|
releaseVmid,
|
|
} from "../services/vmidAllocator.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 AGENT_TEMPLATE_VMID = Number(
|
|
process.env.AGENT_TEMPLATE_VMID ||
|
|
process.env.BASE_TEMPLATE_VMID ||
|
|
process.env.PROXMOX_AGENT_TEMPLATE_VMID ||
|
|
900
|
|
);
|
|
|
|
const AGENT_PORT = Number(process.env.ZLH_AGENT_PORT || 18888);
|
|
const AGENT_TOKEN = process.env.ZLH_AGENT_TOKEN || null;
|
|
const ZONE = process.env.TECHNITIUM_ZONE || "zerolaghub.quest";
|
|
|
|
/* -------------------------------------------------------------
|
|
VERSION PARSER (Minecraft only)
|
|
------------------------------------------------------------- */
|
|
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 }) {
|
|
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,
|
|
ports: Array.isArray(ports) ? ports : [ports].filter(Boolean),
|
|
};
|
|
}
|
|
|
|
/* -------------------------------------------------------------
|
|
SEND CONFIG
|
|
------------------------------------------------------------- */
|
|
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, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text().catch(() => "");
|
|
throw new Error(`/config failed (${resp.status}): ${text}`);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------
|
|
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 || "";
|
|
|
|
// Log state changes and 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);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Only log non-connection errors (agent might not be up yet)
|
|
if (!err.message.includes("ECONNREFUSED") && !err.message.includes("fetch failed")) {
|
|
console.error(`[AGENT ${ip}] Poll error:`, err.message);
|
|
}
|
|
}
|
|
await sleep(3000);
|
|
}
|
|
|
|
throw new Error("Agent did not reach running state");
|
|
}
|
|
|
|
/* -------------------------------------------------------------
|
|
MAIN ENTRYPOINT
|
|
------------------------------------------------------------- */
|
|
export async function provisionAgentInstance(body = {}) {
|
|
const ctype = body.ctype || "game";
|
|
|
|
console.log(`[agentProvision] STEP 0: Starting ${ctype} container provisioning`);
|
|
|
|
const req =
|
|
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}`);
|
|
|
|
// Allocate ports if needed
|
|
if (req.portsNeeded && req.portsNeeded > 0) {
|
|
console.log(`[agentProvision] STEP 2: port allocation`);
|
|
txnId = crypto.randomUUID();
|
|
|
|
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,
|
|
});
|
|
|
|
// Extract port numbers from objects
|
|
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 - no ports needed)`);
|
|
}
|
|
|
|
const hostname = generateSystemHostname({
|
|
ctype,
|
|
game: req.game,
|
|
variant: req.variant,
|
|
vmid,
|
|
});
|
|
|
|
// Generate FQDN for DNS/EdgePublisher
|
|
const slotHostname = `${hostname}.${ZONE}`;
|
|
|
|
console.log(`[agentProvision] STEP 3: clone template ${AGENT_TEMPLATE_VMID} → vmid=${vmid}`);
|
|
await cloneContainer({
|
|
templateVmid: AGENT_TEMPLATE_VMID,
|
|
vmid,
|
|
name: hostname,
|
|
full: 1,
|
|
});
|
|
|
|
console.log(`[agentProvision] STEP 4: configure CPU/mem/bridge/tags`);
|
|
await configureContainer({
|
|
vmid,
|
|
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`);
|
|
ctIp = await getCtIpWithRetry(vmid);
|
|
console.log(`[agentProvision] → ctIp=${ctIp}`);
|
|
|
|
console.log(`[agentProvision] STEP 7: build agent payload`);
|
|
// Build payload WITH ports
|
|
const payload =
|
|
ctype === "dev"
|
|
? buildDevAgentPayload({
|
|
vmid,
|
|
runtime: body.runtime,
|
|
version: body.version,
|
|
memoryMiB: req.memoryMiB,
|
|
ports: allocatedPorts,
|
|
})
|
|
: buildGameAgentPayload({
|
|
vmid,
|
|
...req,
|
|
ports: allocatedPorts,
|
|
});
|
|
|
|
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,
|
|
customerId: req.customerId,
|
|
ctype,
|
|
hostname,
|
|
ip: ctIp,
|
|
ports: allocatedPorts,
|
|
payload,
|
|
agentState: "running",
|
|
agentLastSeen: new Date(),
|
|
},
|
|
});
|
|
|
|
// Enqueue EdgePublisher with ALL required fields
|
|
if (allocatedPorts.length > 0) {
|
|
console.log(`[agentProvision] STEP 11: commit ports`);
|
|
await PortAllocationService.commit({ vmid, ports: allocatedPorts });
|
|
|
|
console.log(`[agentProvision] STEP 12: publish edge`);
|
|
await enqueuePublishEdge({
|
|
vmid,
|
|
slotHostname, // ← FQDN for DNS records
|
|
instanceHostname: hostname, // ← Short hostname
|
|
ports: allocatedPorts, // ← CRITICAL
|
|
ctIp,
|
|
game: req.game,
|
|
txnId,
|
|
});
|
|
} else {
|
|
console.log(`[agentProvision] STEP 11-12: port commit + edge publish (skipped - no ports)`);
|
|
}
|
|
|
|
await confirmVmidAllocated(vmid);
|
|
|
|
console.log(`[agentProvision] COMPLETE: success`);
|
|
return { vmid, hostname, ip: ctIp, ports: allocatedPorts };
|
|
} 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 {}
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export default { provisionAgentInstance };
|