feat: add NWN server stack management service
This commit is contained in:
@@ -10,6 +10,7 @@ import dockerRouter from "./routes/docker.js";
|
|||||||
import editorRouter from "./routes/editor.js";
|
import editorRouter from "./routes/editor.js";
|
||||||
import terminalRouter from "./routes/terminal.js";
|
import terminalRouter from "./routes/terminal.js";
|
||||||
import buildRouter from "./routes/build.js";
|
import buildRouter from "./routes/build.js";
|
||||||
|
import serverRouter from "./routes/server.js";
|
||||||
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
||||||
import { attachLspWebSocket } from "./services/lsp.service.js";
|
import { attachLspWebSocket } from "./services/lsp.service.js";
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ app.use("/api/docker", dockerRouter);
|
|||||||
app.use("/api/editor", editorRouter);
|
app.use("/api/editor", editorRouter);
|
||||||
app.use("/api/terminal", terminalRouter);
|
app.use("/api/terminal", terminalRouter);
|
||||||
app.use("/api/build", buildRouter);
|
app.use("/api/build", buildRouter);
|
||||||
|
app.use("/api/server", serverRouter);
|
||||||
|
|
||||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||||
app.use(express.static(frontendDist));
|
app.use(express.static(frontendDist));
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
startServer,
|
||||||
|
stopServer,
|
||||||
|
restartServer,
|
||||||
|
getServerStatus,
|
||||||
|
generateServerConfig,
|
||||||
|
seedDatabase,
|
||||||
|
} from "../services/server.service.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/status", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const status = await getServerStatus();
|
||||||
|
res.json(status);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/start", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await startServer();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/stop", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await stopServer();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/restart", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await restartServer();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/generate-config", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await generateServerConfig();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/seed-db", async (req, res) => {
|
||||||
|
const { cdKey, playerName } = req.body;
|
||||||
|
if (!cdKey || !playerName)
|
||||||
|
return res.status(400).json({ error: "cdKey and playerName required" });
|
||||||
|
try {
|
||||||
|
await seedDatabase(cdKey, playerName);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { getWorkspacePath } from "./workspace.service.js";
|
||||||
|
import { getDockerClient } from "./docker.service.js";
|
||||||
|
import { broadcast } from "./ws.service.js";
|
||||||
|
import { generateServerEnv } from "../config/env-template.js";
|
||||||
|
|
||||||
|
const NWSERVER_NAME = "layonara-nwserver";
|
||||||
|
const MARIADB_NAME = "layonara-mariadb";
|
||||||
|
const NETWORK_NAME = "layonara-forge";
|
||||||
|
|
||||||
|
export async function generateServerConfig(): Promise<void> {
|
||||||
|
const configDir = path.join(getWorkspacePath(), "config");
|
||||||
|
const envContent = generateServerEnv();
|
||||||
|
await fs.writeFile(path.join(configDir, ".env"), envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerStatus(): Promise<{ nwserver: string; mariadb: string }> {
|
||||||
|
const docker = getDockerClient();
|
||||||
|
const containers = await docker.listContainers({ all: true });
|
||||||
|
|
||||||
|
const nwserver = containers.find((c) =>
|
||||||
|
c.Names.some((n) => n.includes(NWSERVER_NAME)),
|
||||||
|
);
|
||||||
|
const mariadb = containers.find((c) =>
|
||||||
|
c.Names.some((n) => n.includes(MARIADB_NAME)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nwserver: nwserver?.State || "not created",
|
||||||
|
mariadb: mariadb?.State || "not created",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServer(): Promise<void> {
|
||||||
|
broadcast("docker", "server:starting", {});
|
||||||
|
const docker = getDockerClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await docker.getNetwork(NETWORK_NAME).inspect();
|
||||||
|
} catch {
|
||||||
|
await docker.createNetwork({ Name: NETWORK_NAME });
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = path.join(getWorkspacePath(), "config", ".env");
|
||||||
|
try {
|
||||||
|
await fs.access(envPath);
|
||||||
|
} catch {
|
||||||
|
await generateServerConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
const envContent = await fs.readFile(envPath, "utf-8");
|
||||||
|
const env = envContent
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l.trim() && !l.startsWith("#"))
|
||||||
|
.map((l) => l.trim());
|
||||||
|
|
||||||
|
await startMariaDB(docker, env);
|
||||||
|
await waitForContainer(docker, MARIADB_NAME, 30000);
|
||||||
|
await startNWServer(docker, env);
|
||||||
|
|
||||||
|
broadcast("docker", "server:started", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMariaDB(docker: ReturnType<typeof getDockerClient>, env: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const existing = docker.getContainer(MARIADB_NAME);
|
||||||
|
const info = await existing.inspect();
|
||||||
|
if (info.State.Running) return;
|
||||||
|
await existing.start();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* container doesn't exist */
|
||||||
|
}
|
||||||
|
|
||||||
|
const mysqlPassword =
|
||||||
|
env.find((e) => e.startsWith("MYSQL_PASSWORD="))?.split("=")[1] || "forge";
|
||||||
|
const mysqlRootPassword =
|
||||||
|
env.find((e) => e.startsWith("MYSQL_ROOT_PASSWORD="))?.split("=")[1] || "forge";
|
||||||
|
|
||||||
|
const container = await docker.createContainer({
|
||||||
|
Image: "mariadb:10.11",
|
||||||
|
name: MARIADB_NAME,
|
||||||
|
Env: [
|
||||||
|
`MYSQL_ROOT_PASSWORD=${mysqlRootPassword}`,
|
||||||
|
`MYSQL_DATABASE=nwn`,
|
||||||
|
`MYSQL_USER=nwn`,
|
||||||
|
`MYSQL_PASSWORD=${mysqlPassword}`,
|
||||||
|
],
|
||||||
|
HostConfig: {
|
||||||
|
NetworkMode: NETWORK_NAME,
|
||||||
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
|
},
|
||||||
|
Healthcheck: {
|
||||||
|
Test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"],
|
||||||
|
Interval: 10000000000,
|
||||||
|
Timeout: 5000000000,
|
||||||
|
Retries: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await container.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNWServer(docker: ReturnType<typeof getDockerClient>, env: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const existing = docker.getContainer(NWSERVER_NAME);
|
||||||
|
const info = await existing.inspect();
|
||||||
|
if (info.State.Running) return;
|
||||||
|
await existing.start();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* container doesn't exist */
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspacePath = getWorkspacePath();
|
||||||
|
const container = await docker.createContainer({
|
||||||
|
Image: "ghcr.io/plenarius/unified:latest",
|
||||||
|
name: NWSERVER_NAME,
|
||||||
|
Env: env,
|
||||||
|
ExposedPorts: { "5121/udp": {}, "5121/tcp": {} },
|
||||||
|
HostConfig: {
|
||||||
|
NetworkMode: NETWORK_NAME,
|
||||||
|
PortBindings: {
|
||||||
|
"5121/udp": [{ HostPort: "5121" }],
|
||||||
|
"5121/tcp": [{ HostPort: "5121" }],
|
||||||
|
},
|
||||||
|
Binds: [
|
||||||
|
`${workspacePath}/server/hak:/nwn/home/hak:ro`,
|
||||||
|
`${workspacePath}/server/tlk:/nwn/home/tlk:ro`,
|
||||||
|
`${workspacePath}/server/override:/nwn/home/override:ro`,
|
||||||
|
`${workspacePath}/server/portraits:/nwn/home/portraits:ro`,
|
||||||
|
`${workspacePath}/server/modules:/nwn/home/modules`,
|
||||||
|
`${workspacePath}/server/servervault:/nwn/home/servervault`,
|
||||||
|
`${workspacePath}/server/database:/nwn/home/database`,
|
||||||
|
`${workspacePath}/server/development:/nwn/home/development`,
|
||||||
|
`${workspacePath}/logs:/nwn/run/logs.0`,
|
||||||
|
],
|
||||||
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
|
},
|
||||||
|
Tty: true,
|
||||||
|
OpenStdin: true,
|
||||||
|
});
|
||||||
|
await container.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForContainer(
|
||||||
|
docker: ReturnType<typeof getDockerClient>,
|
||||||
|
name: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(name);
|
||||||
|
const info = await container.inspect();
|
||||||
|
if (info.State.Running) return;
|
||||||
|
} catch {
|
||||||
|
/* not ready yet */
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopServer(): Promise<void> {
|
||||||
|
broadcast("docker", "server:stopping", {});
|
||||||
|
const docker = getDockerClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await docker.getContainer(NWSERVER_NAME).stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped or doesn't exist */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await docker.getContainer(MARIADB_NAME).stop();
|
||||||
|
} catch {
|
||||||
|
/* already stopped or doesn't exist */
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast("docker", "server:stopped", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartServer(): Promise<void> {
|
||||||
|
await stopServer();
|
||||||
|
await startServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedDatabase(cdKey: string, playerName: string): Promise<void> {
|
||||||
|
const docker = getDockerClient();
|
||||||
|
const container = docker.getContainer(MARIADB_NAME);
|
||||||
|
|
||||||
|
const exec = await container.exec({
|
||||||
|
Cmd: [
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn < /app/db/schema.sql 2>&1 || true`,
|
||||||
|
],
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
});
|
||||||
|
await exec.start({});
|
||||||
|
|
||||||
|
const dmExec = await container.exec({
|
||||||
|
Cmd: [
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${cdKey}', '${playerName}', 1);" 2>&1`,
|
||||||
|
],
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
});
|
||||||
|
await dmExec.start({});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user