From 00cf0f59ec00c4f312708ff89c955cf1b15c6089 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:36:01 +0000 Subject: [PATCH 1/3] Refuse to start controller with no users --- README.md | 2 +- packages/controller/controller.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a1c54886..42e51dbb 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi ## Running Clusterio -After completing the installation start up the controller and at least one host separately. +After completing the installation and creating your admin user, start up the controller and at least one host separately. The installer provides the `run-controller` and `run-host` scripts to make this simple. Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in. diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index e4299534..c83b3d30 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -281,6 +281,7 @@ async function initialize(): Promise { type: "boolean", nargs: 0, default: false, describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected", }); + yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true }); yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 }); yargs.option("dev-plugin", { hidden: true, type: "array" }); }) @@ -417,6 +418,14 @@ async function startup() { ...await Controller.bootstrap(controllerConfig) ); + // Refuse to start if there are no users loaded + if (args.checkUserCount && controller.userManager.users.size === 0) { + logger.fatal( + "Cannot start controller, no users loaded.\n" + + "Try `npx clusteriocontroller bootstrap create-admin `"); + process.exit(1); + } + let secondSigint = false; process.on("SIGINT", () => { if (secondSigint) { From bccb6527a711c45d519a032ca1c634b455548272 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Wed, 7 Jan 2026 01:20:40 +0000 Subject: [PATCH 2/3] Add lock file for configs --- packages/controller/controller.ts | 14 ++- packages/ctl/ctl.ts | 3 +- packages/host/host.ts | 6 +- packages/lib/index.ts | 1 + packages/lib/src/LockFile.ts | 163 ++++++++++++++++++++++++++++ packages/lib/src/shared_commands.ts | 8 +- test/integration/clusterio.js | 41 ++++++- test/integration/index.js | 17 +-- test/lib/LockFile.test.js | 126 +++++++++++++++++++++ 9 files changed, 361 insertions(+), 18 deletions(-) create mode 100644 packages/lib/src/LockFile.ts create mode 100644 test/lib/LockFile.test.js diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index c83b3d30..3eabb814 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -127,7 +127,8 @@ void new lib.Gauge( async function handleBootstrapCommand( args: any, - controllerConfig: lib.ControllerConfig + controllerConfig: lib.ControllerConfig, + controllerConfigLock: lib.LockFile, ): Promise { let subCommand = args._[1]; @@ -141,6 +142,8 @@ async function handleBootstrapCommand( await userManager.load(path.join(databaseDirectory, "users.json")); if (subCommand === "create-admin") { + await controllerConfigLock.acquire(); // Also needed to write to the database files + if (!args.name) { logger.error("name cannot be blank"); process.exitCode = 1; @@ -156,6 +159,7 @@ async function handleBootstrapCommand( admin.roleIds.add(adminRole.id); admin.isAdmin = true; await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json")); + await controllerConfigLock.release(); } else if (subCommand === "generate-user-token") { let user = userManager.getByName(args.name); @@ -353,6 +357,7 @@ async function initialize(): Promise { let controllerConfig; const controllerConfigPath = args.config; + const controllerConfigLock = new lib.LockFile(`${controllerConfigPath}.lock`); logger.info(`Loading config from ${controllerConfigPath}`); try { controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath); @@ -378,10 +383,11 @@ async function initialize(): Promise { } if (command === "config") { - await lib.handleConfigCommand(args, controllerConfig); - + await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock); } else if (command === "bootstrap") { - await handleBootstrapCommand(args, controllerConfig); + await handleBootstrapCommand(args, controllerConfig, controllerConfigLock); + } else if (shouldRun) { + await controllerConfigLock.acquire(); // Hold the lock until process exit } return { diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index bafb43af..6d2816f1 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -236,6 +236,7 @@ export async function initialize( let controlConfig; const controlConfigPath = args.config; + const controlConfigLock = new lib.LockFile(`${controlConfigPath}.lock`); logger.verbose(`Loading config from ${controlConfigPath}`); try { controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath); @@ -257,7 +258,7 @@ export async function initialize( // Handle the control-config command before trying to connect. if (args._[0] === "control-config") { - await lib.handleConfigCommand(args, controlConfig); + await lib.handleConfigCommand(args, controlConfig, controlConfigLock); return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false }; } diff --git a/packages/host/host.ts b/packages/host/host.ts index 6a3b1a3f..259fb31c 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -169,6 +169,7 @@ async function startHost() { let hostConfig; const hostConfigPath = args.config; + const hostConfigLock = new lib.LockFile(`${hostConfigPath}.lock`); logger.info(`Loading config from ${hostConfigPath}`); try { hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath); @@ -186,10 +187,13 @@ async function startHost() { hostConfig.set("host.version", version); // Allows tracking last loaded version if (command === "config") { - await lib.handleConfigCommand(args, hostConfig); + await lib.handleConfigCommand(args, hostConfig, hostConfigLock); return; } + // From this point on the host should run + await hostConfigLock.acquire(); + // If we get here the command was run await fs.ensureDir(hostConfig.get("host.instances_directory")); await fs.ensureDir(hostConfig.get("host.mods_directory")); diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 36e0d492..204cc7d3 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -31,6 +31,7 @@ export * from "./src/subscriptions"; export * from "./src/datastore"; export * from "./src/load_plugin_list"; export * from "./src/rce_ops"; +export * from "./src/LockFile"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; diff --git a/packages/lib/src/LockFile.ts b/packages/lib/src/LockFile.ts new file mode 100644 index 00000000..5cb6ecce --- /dev/null +++ b/packages/lib/src/LockFile.ts @@ -0,0 +1,163 @@ +import { FileHandle, open, unlink, readFile } from "fs/promises"; +import { unlinkSync } from "fs"; +import assert from "assert"; + +/** + * Error thrown when the lock file already exists. + */ +export class LockFileExistsError extends Error { + constructor( + readonly filePath: string + ) { + super(`Cannot acquire lock, a valid lock file exists at ${filePath}`); + this.name = "LockFileExistsError"; + } +} + +/** + * Error thrown when the acquire or release called during an invalid state. + */ +export class LockFileBusyError extends Error { + constructor(message: string) { + super(message); + this.name = "LockFileBusyError"; + } +} + +type LockState = "idle" | "acquiring" | "acquired" | "releasing"; + +/** + * Cross-platform lock file utility for Node.js. + */ +export class LockFile { + private handle: FileHandle | null = null; + private state: LockState = "idle"; + + constructor( + private readonly filePath: string + ) {} + + /** + * Acquire the lock file. + * Throws LockFileExistsError if already locked. + * Throws LockFileBusyError if not in idle state. + */ + async acquire(): Promise { + if (this.state !== "idle") { + throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`); + } + + this.state = "acquiring"; + + if (await this.isStale()) { + await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed + } + + try { + this.handle = await open(this.filePath, "wx"); + await this.handle.write(`${process.pid}\n`); + this.attachExitHandlers(); + this.state = "acquired"; + } catch (err: any) { + this.state = "idle"; + if (err?.code === "EEXIST") { + throw new LockFileExistsError(this.filePath); + } + throw err; + } + } + + /** + * Release the lock file. + * Throws LockFileBusyError if not in acquired state. + */ + async release(): Promise { + if (this.state !== "acquired") { + throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`); + } + + this.state = "releasing"; + this.detachExitHandlers(); + + // Ignored: file may have already been removed by another process + try { await this.handle?.close(); } catch {} + try { await unlink(this.filePath); } catch {} + + this.handle = null; + this.state = "idle"; + } + + /** + * Check whether the lock is currently held by this process. + * True only if the lock is acquired and the PID in the file + * matches the current process. + */ + async isHeld(): Promise { + if (this.state !== "acquired") { + return false; + } + + try { + const pidInLock = Number(await readFile(this.filePath, "utf8")); + return pidInLock === process.pid; + } catch { + // Ignored: file may not exist or be unreadable, so lock is not held + return false; + } + } + + /** + * Determine whether an existing lock is stale. + */ + async isStale(): Promise { + try { + const pidInLock = Number(await readFile(this.filePath, "utf8")); + if (!Number.isFinite(pidInLock)) { return true; } + // signal 0 is a special case to test if a process exists + process.kill(pidInLock, 0); + return false; + } catch { + return true; + } + } + + /** + * Attach process handlers, called when transitioning to "acquired" + */ + private attachExitHandlers(): void { + assert(this.state === "acquiring"); + process.on("SIGINT", this.handleExit); + process.on("SIGTERM", this.handleExit); + process.on("SIGHUP", this.handleExit); + process.on("uncaughtException", this.handleExit); + // eslint-disable-next-line node/no-sync + process.on("exit", this.handleExitSync); + } + + /** + * Detach process handlers, called when transitioning to "idle" + */ + private async detachExitHandlers(): Promise { + assert(this.state === "releasing"); + process.off("SIGINT", this.handleExit); + process.off("SIGTERM", this.handleExit); + process.off("SIGHUP", this.handleExit); + process.off("uncaughtException", this.handleExit); + // eslint-disable-next-line node/no-sync + process.off("exit", this.handleExitSync); + } + + // Event handler for async signals / exceptions + private handleExit = async (): Promise => { + await this.release(); + }; + + // Event handler for synchronous process exit + private handleExitSync = (): void => { + try { + unlinkSync(this.filePath); + } catch (err) { + // Ignored: file may have already been removed by another process + } + }; +} diff --git a/packages/lib/src/shared_commands.ts b/packages/lib/src/shared_commands.ts index e65f925f..d9f6eb09 100644 --- a/packages/lib/src/shared_commands.ts +++ b/packages/lib/src/shared_commands.ts @@ -8,6 +8,7 @@ import * as libConfig from "./config"; import * as libFileOps from "./file_ops"; import { logger } from "./logging"; import * as libHelpers from "./helpers"; +import { LockFile } from "./LockFile"; function print(...content: any[]) { @@ -130,11 +131,12 @@ export function configCommand(yargs: any) { * * @param args - yargs args object. * @param instance - Config instance. - * @param configPath - Path to configuration file. + * @param lockFile - Lockfile required if attempting to write */ export async function handleConfigCommand( args: Record, instance: libConfig.Config, + lockFile: LockFile, ) { let command = (args._ as string[])[1]; @@ -155,6 +157,8 @@ export async function handleConfigCommand( } } else if (command === "set") { + await lockFile.acquire(); + if (args.stdin) { args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); @@ -172,5 +176,7 @@ export async function handleConfigCommand( throw err; } } + + await lockFile.release(); } } diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 948dd556..8532b57a 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -13,7 +13,7 @@ const { wait } = lib; const testStrings = require("../lib/factorio/test_strings"); const { TestControl, TestControlConnector, url, controlToken, slowTest, - exec, execCtl, execCtlProcess, execHost, sendRcon, getControl, + execCtl, execCtlProcess, execController, execHost, sendRcon, getControl, spawnNode, instancesDir, factorioDir, } = require("./index"); @@ -109,12 +109,31 @@ describe("Integration of Clusterio", function() { describe("clusteriocontroller", function() { describe("bootstrap generate-user-token", function() { it("work for existing user", async function() { - await exec("node ../../packages/controller bootstrap generate-user-token test"); + await execController("bootstrap generate-user-token test"); }); it("fails if user does not exist", async function() { await assert.rejects( - exec("node ../../packages/controller bootstrap generate-user-token invalid") + execController("bootstrap generate-user-token invalid") + ); + }); + }); + + describe("bootstrap create-admin", function() { + it("refuses to modify the database files", async function() { + await assert.rejects( + execController("bootstrap create-admin BootstrapAdminTest") + ); + }); + }); + + describe("config", function() { + it("can read the config file", async function() { + await execController("config list"); + }); + it("refuses to modify the config files", async function() { + await assert.rejects( + execController("confit set controller.name ConfigEditTest") ); }); }); @@ -143,6 +162,9 @@ describe("Integration of Clusterio", function() { await events.once(connectorB, "resume"); await connectorB.close(1000, ""); }); + it("refuses to start a second process", async function() { + await assert.rejects(execController("run")); + }); }); describe("queryLogRequestHandler", function() { @@ -245,6 +267,19 @@ describe("Integration of Clusterio", function() { await stopAltHost(hostProcess); } }); + describe("config", function() { + it("can read the config file", async function() { + await execHost("config list"); + }); + it("refuses to modify the config files", async function() { + await assert.rejects( + execHost("confit set host.name ConfigEditTest") + ); + }); + }); + it("refuses to start a second process", async function() { + await assert.rejects(execHost("run")); + }); }); describe("clusterioctl", function() { diff --git a/test/integration/index.js b/test/integration/index.js index 529b0c08..7d507040 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -173,26 +173,26 @@ let controllerConfigPath = path.join("temp", "test", "config-controller.json"); let hostConfigPath = path.join("temp", "test", "config-host.json"); let controlConfigPath = path.join("temp", "test", "config-control.json"); -async function exec(command, options = {}) { +function exec(command, options = {}) { // Uncomment to show commands run in tests // console.log(command); options = { cwd: path.join("temp", "test"), ...options }; - return await util.promisify(child_process.exec)(command, options); + return util.promisify(child_process.exec)(command, options); } -async function execController(...args) { +function execController(...args) { args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`; - return await exec(...args); + return exec(...args); } -async function execHost(...args) { +function execHost(...args) { args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`; - return await exec(...args); + return exec(...args); } -async function execCtlProcess(...args) { +function execCtlProcess(...args) { args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`; - return await exec(...args); + return exec(...args); } let inExecCtl = false; @@ -394,6 +394,7 @@ module.exports = { TestControl, TestControlConnector, TestHostConnector, + execController, execCtlProcess, slowTest, get, diff --git a/test/lib/LockFile.test.js b/test/lib/LockFile.test.js new file mode 100644 index 00000000..7034ecaf --- /dev/null +++ b/test/lib/LockFile.test.js @@ -0,0 +1,126 @@ +/* eslint-disable node/no-sync */ +"use strict"; +const assert = require("assert").strict; +const fs = require("fs-extra"); +const path = require("path"); + +const { LockFile } = require("@clusterio/lib"); + +const baseDir = path.join("temp", "test", "lock_file"); + +describe("class LockFile", function () { + /** @type {string} */ + let filePath; + /** @type {LockFile} */ + let lock; + + before(async function () { + await fs.ensureDir(baseDir); + }); + + beforeEach(function() { + filePath = path.join(baseDir, `${Date.now()}-${Math.random()}.lock`); + lock = new LockFile(filePath); + }); + + afterEach(async function () { + await fs.unlink(filePath).catch(() => {}); + }); + + describe("acquire()", function () { + it("should create the lock file when acquiring", async function () { + await lock.acquire(); + assert.ok(fs.existsSync(filePath)); + }); + + it("should throw LockFileBusyError if called when not idle", async function () { + await lock.acquire(); + await assert.rejects(lock.acquire(), { name: "LockFileBusyError" }); + }); + + it("should remove stale lock file and acquire it", async function () { + await fs.writeFile(filePath, "999999\n"); // stale lock + await lock.acquire(); + assert.ok(fs.existsSync(filePath)); + }); + + it("should throw if existing lock file is not stale", async function () { + await fs.writeFile(filePath, `${process.pid}\n`); + await assert.rejects(lock.acquire(), { name: "LockFileExistsError" }); + }); + }); + + describe("release()", function () { + it("should remove the lock file when releasing", async function () { + await lock.acquire(); + await lock.release(); + assert.ok(!fs.existsSync(filePath)); + }); + + it("should throw LockFileBusyError if called when not acquired", async function () { + await assert.rejects(lock.release(), { name: "LockFileBusyError" }); + }); + }); + + describe("isHeld()", function () { + it("should return true only when lock file contains current PID", async function () { + await lock.acquire(); + const held = await lock.isHeld(); + assert.equal(held, true); + }); + + it("should return false when lock file does not exist", async function () { + const held = await lock.isHeld(); + assert.equal(held, false); + }); + + it("should return false if lock file contains different PID", async function () { + await fs.writeFile(filePath, "999999\n"); // simulate another process + const held = await lock.isHeld(); + assert.equal(held, false); + }); + }); + + describe("isStale()", function () { + it("should return true if lock file does not exist", async function () { + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return true for invalid PID in lock file", async function () { + await fs.writeFile(filePath, "abc\n"); // non-numeric PID + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return true for non-existent process PID", async function () { + await fs.writeFile(filePath, "999999\n"); // assume PID does not exist + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return false for current process PID", async function () { + await fs.writeFile(filePath, `${process.pid}\n`); + const stale = await lock.isStale(); + assert.equal(stale, false); + }); + }); + + describe("attach/detach exit handlers", function () { + it("should attach exit handler on acquire and detach on release", async function () { + await lock.acquire(); + const listenersBefore = process.listeners("exit").slice(); + assert.ok(listenersBefore.includes(lock.handleExitSync)); + + await lock.release(); + const listenersAfter = process.listeners("exit").slice(); + assert.ok(!listenersAfter.includes(lock.handleExitSync)); + }); + }); + + describe("handleExitSync()", function () { + it("should not throw if file already removed", function () { + assert.doesNotThrow(() => lock.handleExitSync()); // simulate non-existent file + }); + }); +}); From d48b731ea4269700fc7f99a983f843776038120a Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:48:00 +0000 Subject: [PATCH 3/3] Add lockfile bypass --- packages/controller/controller.ts | 10 ++- packages/ctl/ctl.ts | 10 ++- packages/host/host.ts | 10 ++- test/integration/clusterio.js | 123 +++++++++++++++++++++++++++--- test/integration/index.js | 9 ++- test/lib/LockFile.test.js | 7 ++ 6 files changed, 148 insertions(+), 21 deletions(-) diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 3eabb814..5e3a4838 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -255,6 +255,7 @@ async function initialize(): Promise { default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("config", "Manage Controller config", lib.configCommand) .command("bootstrap", "Bootstrap access to cluster", yargs => { @@ -355,9 +356,14 @@ async function initialize(): Promise { lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let controllerConfig; const controllerConfigPath = args.config; - const controllerConfigLock = new lib.LockFile(`${controllerConfigPath}.lock`); + const controllerConfigLockPath = `${controllerConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let controllerConfig; + const controllerConfigLock = new lib.LockFile(controllerConfigLockPath); logger.info(`Loading config from ${controllerConfigPath}`); try { controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath); diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index 6d2816f1..793dd1ab 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -189,6 +189,7 @@ export async function initialize( default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("control-config", "Manage Control config", lib.configCommand) .wrap(parser.terminalWidth()) @@ -234,9 +235,14 @@ export async function initialize( .parse(argv) as CtlArguments ; - let controlConfig; const controlConfigPath = args.config; - const controlConfigLock = new lib.LockFile(`${controlConfigPath}.lock`); + const controlConfigLockPath = `${controlConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let controlConfig; + const controlConfigLock = new lib.LockFile(controlConfigLockPath); logger.verbose(`Loading config from ${controlConfigPath}`); try { controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath); diff --git a/packages/host/host.ts b/packages/host/host.ts index 259fb31c..c3f539a5 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -113,6 +113,7 @@ async function startHost() { default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("config", "Manage Host config", lib.configCommand) .command("run", "Run host", yargs => { @@ -167,9 +168,14 @@ async function startHost() { lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let hostConfig; const hostConfigPath = args.config; - const hostConfigLock = new lib.LockFile(`${hostConfigPath}.lock`); + const hostConfigLockPath = `${hostConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let hostConfig; + const hostConfigLock = new lib.LockFile(hostConfigLockPath); logger.info(`Loading config from ${hostConfigPath}`); try { hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 8532b57a..3fe1805f 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -14,7 +14,7 @@ const testStrings = require("../lib/factorio/test_strings"); const { TestControl, TestControlConnector, url, controlToken, slowTest, execCtl, execCtlProcess, execController, execHost, sendRcon, getControl, - spawnNode, instancesDir, factorioDir, + spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath, } = require("./index"); @@ -120,22 +120,46 @@ describe("Integration of Clusterio", function() { }); describe("bootstrap create-admin", function() { - it("refuses to modify the database files", async function() { + it("should refuse to modify the database files when locked", async function() { await assert.rejects( execController("bootstrap create-admin BootstrapAdminTest") ); }); + it("should modify the database files when locked if bypass option is given", async function() { + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("bootstrap create-admin BootstrapAdminTest2 --bypass-lock-file"); + } finally { + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + } + + const json = await fs.readJSON(path.join(databaseDir, "users.json")); + assert.equal(Object.values(json).some(user => user.name === "BootstrapAdminTest2"), true); + }); }); describe("config", function() { it("can read the config file", async function() { await execController("config list"); }); - it("refuses to modify the config files", async function() { + it("should refuse to modify the config file while locked", async function() { await assert.rejects( - execController("confit set controller.name ConfigEditTest") + execController("config set controller.name ConfigEditTest") ); }); + it("should modify the config file while locked if bypass option is given", async function() { + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("config set controller.name ConfigEditTest2 --bypass-lock-file"); + } finally { + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + } + + const json = await fs.readJSON(path.join(controllerConfigPath)); + assert.equal(json["controller.name"], "ConfigEditTest2"); + }); }); describe("run", function() { @@ -162,9 +186,38 @@ describe("Integration of Clusterio", function() { await events.once(connectorB, "resume"); await connectorB.close(1000, ""); }); - it("refuses to start a second process", async function() { + it("should refuse to start a second process while locked", async function() { await assert.rejects(execController("run")); }); + it("should start a second process while locked if bypass option is given", async function() { + slowTest(this); + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("run --bypass-lock-file"); + } catch (err) { + assert.equal(/Error: Server listening failed/.test(err.stderr), true); + } + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + }); + it("should refuse to start when no users are loaded", async function() { + const dir = path.join("temp", "test", "empty_controller"); + await fs.emptyDir(dir); + await assert.rejects(execController("run", { cwd: dir })); + }); + it("should start when no users are loaded if bypass option given", async function() { + slowTest(this); + const dir = path.join("temp", "test", "empty_controller"); + await fs.emptyDir(dir); + const child = await spawnNode( + "altController", + "../../../packages/controller run --no-check-user-count", + /Started controller/, + { cwd: dir }, + ); + assert.ok(child.kill()); + assert.equal(child.killed, true); + }); }); describe("queryLogRequestHandler", function() { @@ -271,14 +324,62 @@ describe("Integration of Clusterio", function() { it("can read the config file", async function() { await execHost("config list"); }); - it("refuses to modify the config files", async function() { - await assert.rejects( - execHost("confit set host.name ConfigEditTest") - ); + it("should refuse to modify the config file while locked", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await assert.rejects( + execHost(`--config ${config} config set host.name ConfigEditTest`) + ); + } finally { + await stopAltHost(hostProcess); + } + }); + it("should modify the config file while locked if bypass option is given", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await execHost(`--config ${config} config set host.name ConfigEditTest2 --bypass-lock-file`); + } finally { + await stopAltHost(hostProcess); + } + + const json = await fs.readJSON(path.join("temp", "test", config)); + assert.equal(json["host.name"], "ConfigEditTest2"); }); }); - it("refuses to start a second process", async function() { - await assert.rejects(execHost("run")); + describe("run", function() { + it("should refuse to start a second process while locked", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await assert.rejects( + execHost(`--config ${config} run`) + ); + } finally { + await stopAltHost(hostProcess); + } + }); + it("should start a second process while locked if bypass option is given", async function() { + slowTest(this); + let hostProcessA, hostProcessB; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcessA = await startAltHost(); + hostProcessB = await spawnNode( + "alt-host-b:", `../../packages/host run --config ${config} --bypass-lock-file`, /Started host/ + ); + } finally { + await stopAltHost(hostProcessA); + await stopAltHost(hostProcessB); + } + }); }); }); diff --git a/test/integration/index.js b/test/integration/index.js index 7d507040..c47f18f7 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -229,7 +229,8 @@ function getControl() { return control; } -function spawn(name, cmd, waitFor) { +/** @returns {Promise} */ +function spawn(name, cmd, waitFor, options = {}) { const silent = process.env.SILENT_TEST; const bootstrap = !controllerProcess || !hostProcess; @@ -242,7 +243,7 @@ function spawn(name, cmd, waitFor) { return new Promise((resolve, reject) => { log(cmd); let parts = cmd.split(" "); - let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") }); + let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test"), ...options }); let stdout = new LineSplitter({ readableObjectMode: true }); let stderr = new LineSplitter({ readableObjectMode: true }); let onDataOut = line => { @@ -266,8 +267,8 @@ function spawn(name, cmd, waitFor) { }); } -async function spawnNode(name, cmd, waitFor) { - return await spawn(name, `node --enable-source-maps ${cmd}`, waitFor); +function spawnNode(name, cmd, waitFor, options) { + return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options); } before(async function() { diff --git a/test/lib/LockFile.test.js b/test/lib/LockFile.test.js index 7034ecaf..6bceeb06 100644 --- a/test/lib/LockFile.test.js +++ b/test/lib/LockFile.test.js @@ -79,6 +79,13 @@ describe("class LockFile", function () { const held = await lock.isHeld(); assert.equal(held, false); }); + + it("should return false if lock file was deleted while held", async function() { + await lock.acquire(); + await fs.unlink(filePath); + const held = await lock.isHeld(); + assert.equal(held, false); + }); }); describe("isStale()", function () {