feat: add NWN server stack management service

This commit is contained in:
plenarius
2026-04-20 19:55:31 -04:00
parent 973310113c
commit dc45098cd1
3 changed files with 284 additions and 0 deletions
@@ -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({});
}