mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Controller.fromConfig and Host.fromConfig now take the config, then the plugin infos, then the logger or connector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
400 lines
16 KiB
JavaScript
400 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import dgram from "node:dgram";
|
|
import events from "node:events";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import Host, { _discoverInstances, HostRouter } from "@clusterio/host/dist/node/src/Host.js";
|
|
import Instance from "@clusterio/host/dist/node/src/Instance.js";
|
|
import { HostConnector } from "@clusterio/host/dist/node/host.js";
|
|
import "../setup_logging.js";
|
|
|
|
describe("host/src/Host", function() {
|
|
describe("discoverInstances()", function() {
|
|
it("should discover test instance", async function() {
|
|
const instancePath = path.join("test", "file", "instances");
|
|
const instances = await _discoverInstances(instancePath);
|
|
|
|
const configPath = path.join(instancePath, "test", "instance.json");
|
|
const referenceConfig = new lib.InstanceConfig("host", {
|
|
...Host.instanceConfigWarning,
|
|
"instance.id": 1,
|
|
"instance.name": "test",
|
|
}, configPath);
|
|
|
|
assert.deepEqual(instances, new Map([
|
|
[1, {
|
|
config: referenceConfig,
|
|
path: path.join(instancePath, "test"),
|
|
}],
|
|
]));
|
|
});
|
|
});
|
|
|
|
describe("class HostRouter", function() {
|
|
describe(".wakeInstance()", function() {
|
|
it("should log the message of a start error that has no stack", async function() {
|
|
// RequestError leaves its stack undefined
|
|
const err = new lib.RequestError("No available port left to assign from host.factorio_port_range");
|
|
const mockHost = {
|
|
_shuttingDown: false,
|
|
_connectInstance: async () => { throw err; },
|
|
instanceLogMeta: () => ({}),
|
|
};
|
|
const responses = [];
|
|
const origin = { connector: { sendResponseError: response => responses.push(response) } };
|
|
const message = { dst: lib.Address.fromShorthand({ instanceId: 1 }) };
|
|
const logged = [];
|
|
const originalError = lib.logger.error;
|
|
lib.logger.error = text => { logged.push(text); };
|
|
try {
|
|
new HostRouter(mockHost).wakeInstance(origin, message);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
} finally {
|
|
lib.logger.error = originalError;
|
|
}
|
|
assert.equal(responses.length, 1);
|
|
assert.equal(logged.length, 1);
|
|
assert.match(logged[0], /No available port left to assign/);
|
|
});
|
|
});
|
|
});
|
|
describe("class Host", function() {
|
|
describe(".fromConfig()", function() {
|
|
const modsDirectory = path.join("temp", "test", "host_from_config", "mods");
|
|
beforeEach(async function() {
|
|
await fs.rm(path.dirname(modsDirectory), { recursive: true, force: true });
|
|
});
|
|
it("should create the mods directory and load the mod store", async function() {
|
|
const hostConfig = new lib.HostConfig("host", { "host.mods_directory": modsDirectory });
|
|
const host = await Host.fromConfig(hostConfig, [], new HostConnector(hostConfig, []), true, false);
|
|
assert(host instanceof Host);
|
|
assert.equal(host.canRestart, true);
|
|
assert.equal(host.recoveryMode, false);
|
|
assert.equal(host.modStore.modsDirectory, modsDirectory);
|
|
await fs.access(modsDirectory);
|
|
});
|
|
});
|
|
describe(".handleSyncUserListsEvent()", function() {
|
|
const hostAddress = lib.Address.fromShorthand({ hostId: 1 });
|
|
const instanceAddress = lib.Address.fromShorthand({ instanceId: 1 });
|
|
let mockHost, hostConnector, instanceConnector;
|
|
before(function() {
|
|
[hostConnector, instanceConnector] = lib.VirtualConnector.makePair(hostAddress, instanceAddress);
|
|
instanceConnector.on("message", function(message) {
|
|
Instance.prototype._validateMessage(message);
|
|
});
|
|
});
|
|
beforeEach(function() {
|
|
mockHost = {
|
|
adminlist: new Set(),
|
|
whitelist: new Set(),
|
|
banlist: new Map(),
|
|
broadcasts: [],
|
|
broadcastEventToInstance(event) {
|
|
this.broadcasts.push(event);
|
|
hostConnector.sendEvent(event, instanceAddress);
|
|
},
|
|
handleSyncUserListsEvent: Host.prototype.handleSyncUserListsEvent,
|
|
syncLists(adminlist, banlist, whitelist) {
|
|
return this.handleSyncUserListsEvent(
|
|
new lib.SyncUserListsEvent(adminlist, banlist, whitelist)
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
it("should broadcast new entries to adminlist", async function() {
|
|
await mockHost.syncLists(["admin1"], [], []);
|
|
await mockHost.syncLists(["admin1", "admin2"], [], []);
|
|
|
|
assert.deepEqual(mockHost.broadcasts, [
|
|
new lib.InstanceAdminlistUpdateEvent("admin1", true),
|
|
new lib.InstanceAdminlistUpdateEvent("admin2", true),
|
|
]);
|
|
});
|
|
|
|
it("should broadcast removals from adminlist", async function() {
|
|
mockHost.adminlist.add("admin1").add("admin2");
|
|
await mockHost.syncLists(["admin1"], [], []);
|
|
assert.deepEqual(mockHost.broadcasts, [
|
|
new lib.InstanceAdminlistUpdateEvent("admin2", false),
|
|
]);
|
|
});
|
|
|
|
it("should broadcast new entries to whitelist", async function() {
|
|
await mockHost.syncLists([], [], ["player1"]);
|
|
await mockHost.syncLists([], [], ["player1", "player2"]);
|
|
|
|
assert.deepEqual(mockHost.broadcasts, [
|
|
new lib.InstanceWhitelistUpdateEvent("player1", true),
|
|
new lib.InstanceWhitelistUpdateEvent("player2", true),
|
|
]);
|
|
});
|
|
|
|
it("should broadcast removals from whitelist", async function() {
|
|
mockHost.whitelist.add("player1").add("player2");
|
|
await mockHost.syncLists([], [], ["player1"]);
|
|
|
|
assert.deepEqual(mockHost.broadcasts, [
|
|
new lib.InstanceWhitelistUpdateEvent("player2", false),
|
|
]);
|
|
});
|
|
|
|
it("should broadcast new entries to banlist", async function() {
|
|
await mockHost.syncLists([], [["badie1", "greifing"]], []);
|
|
await mockHost.syncLists([], [["badie1", "greifing"], ["badie2", "annoying"]], []);
|
|
|
|
assert.deepEqual(mockHost.broadcasts, [
|
|
new lib.InstanceBanlistUpdateEvent("badie1", true, "greifing"),
|
|
new lib.InstanceBanlistUpdateEvent("badie2", true, "annoying"),
|
|
]);
|
|
});
|
|
|
|
it("should broadcast removals to banlist", async function() {
|
|
mockHost.banlist.set("badie1", "greifing").set("badie2", "annoying");
|
|
await mockHost.syncLists([], [["badie1", "greifing"]], []);
|
|
|
|
assert.deepEqual(mockHost.broadcasts, [
|
|
new lib.InstanceBanlistUpdateEvent("badie2", false, ""),
|
|
]);
|
|
});
|
|
});
|
|
describe(".checkRestartRequired()", async function() {
|
|
let host;
|
|
const hostVersion = (
|
|
await import("@clusterio/host/package.json", { with: { type: "json" }})
|
|
).default.version;
|
|
before(function() {
|
|
lib.addPluginConfigFields([{ name: "restart_test", hostEntrypoint: "host.js" }]);
|
|
});
|
|
beforeEach(function() {
|
|
// This can be used more generally, but i did not want to interfere with handleSyncUserListsEvent
|
|
const pluginInfos = [{
|
|
name: "restart_test",
|
|
packagePath: fileURLToPath(import.meta.resolve("@clusterio/host/package.json")),
|
|
version: hostVersion,
|
|
}];
|
|
const hostConfig = new lib.HostConfig("host", { "host.version": hostVersion });
|
|
const hostConnector = new HostConnector(hostConfig, pluginInfos);
|
|
host = new Host(hostConnector, hostConfig, pluginInfos);
|
|
});
|
|
it("returns false when no changes are present", async function() {
|
|
host.pluginInfos[0].hostEntrypoint = true;
|
|
host.pluginInfos[0].instanceEntrypoint = true;
|
|
const result = await host.checkRestartRequired();
|
|
assert.equal(result, false);
|
|
assert.equal(host.config.restartRequired, false);
|
|
});
|
|
it("returns false when an unloaded plugin version changes", async function() {
|
|
host.pluginInfos[0].version = "0.0.0";
|
|
host.pluginInfos[0].hostEntrypoint = true;
|
|
host.pluginInfos[0].instanceEntrypoint = true;
|
|
host.config.set("restart_test.load_plugin", false);
|
|
host.config.restartRequired = false; // Setting load_plugin requires a restart
|
|
const result = await host.checkRestartRequired();
|
|
assert.equal(result, false);
|
|
assert.equal(host.config.restartRequired, false);
|
|
});
|
|
it("returns true when the config flag is set", async function() {
|
|
host.config.restartRequired = true;
|
|
const result = await host.checkRestartRequired();
|
|
assert.equal(result, true);
|
|
assert.equal(host.config.restartRequired, true);
|
|
});
|
|
it("returns true when clusterio version changes", async function() {
|
|
host.config.set("host.version", "0.0.0");
|
|
const result = await host.checkRestartRequired();
|
|
assert.equal(result, true);
|
|
assert.equal(host.config.restartRequired, true);
|
|
});
|
|
it("returns true when a host plugin version changes", async function() {
|
|
host.pluginInfos[0].version = "0.0.0";
|
|
host.pluginInfos[0].hostEntrypoint = true;
|
|
const result = await host.checkRestartRequired();
|
|
assert.equal(result, true);
|
|
assert.equal(host.config.restartRequired, true);
|
|
});
|
|
it("returns true when an instance plugin version changes", async function() {
|
|
host.pluginInfos[0].version = "0.0.0";
|
|
host.pluginInfos[0].instanceEntrypoint = true;
|
|
const result = await host.checkRestartRequired();
|
|
assert.equal(result, true);
|
|
assert.equal(host.config.restartRequired, true);
|
|
});
|
|
});
|
|
describe(".assignFreeGamePort()", function() {
|
|
let host;
|
|
let held;
|
|
let instanceConfig;
|
|
beforeEach(async function() {
|
|
const hostConfig = new lib.HostConfig("host");
|
|
host = new Host(new HostConnector(hostConfig, []), hostConfig, []);
|
|
instanceConfig = new lib.InstanceConfig("host", { "instance.id": 1 });
|
|
host.assignedInstances.set(1, { config: instanceConfig, path: "" });
|
|
held = dgram.createSocket("udp4");
|
|
held.bind(0, "127.0.0.1");
|
|
await events.once(held, "listening");
|
|
});
|
|
afterEach(function() {
|
|
held.close();
|
|
});
|
|
it("replaces a stored port another process is bound to", async function() {
|
|
const port = held.address().port;
|
|
host.config.set("host.factorio_port_range", `${port}-${port + 1}`);
|
|
instanceConfig.set("factorio.host_assigned_game_port", port);
|
|
assert.equal(await host.assignFreeGamePort(1), port + 1);
|
|
assert.equal(instanceConfig.get("factorio.host_assigned_game_port"), port + 1);
|
|
});
|
|
it("keeps a stored port that is free", async function() {
|
|
const port = held.address().port;
|
|
host.config.set("host.factorio_port_range", `${port}-${port + 1}`);
|
|
instanceConfig.set("factorio.host_assigned_game_port", port + 1);
|
|
assert.equal(await host.assignFreeGamePort(1), port + 1);
|
|
});
|
|
it("rejects when every port in the range is taken", async function() {
|
|
host.config.set("host.factorio_port_range", String(held.address().port));
|
|
await assert.rejects(
|
|
host.assignFreeGamePort(1),
|
|
/No available port left to assign from host.factorio_port_range \(1 in use by other processes\)/
|
|
);
|
|
});
|
|
});
|
|
describe(".handlePluginInstallRequest()", function() {
|
|
let host;
|
|
let _fetch;
|
|
beforeEach(function() {
|
|
const pluginInfos = [{ name: "restart_test", npmPackage: "restart_test", version: "1.0.0" }];
|
|
const hostConfig = new lib.HostConfig("host", { "host.allow_plugin_install": true });
|
|
const hostConnector = new HostConnector(hostConfig, pluginInfos);
|
|
host = new Host(hostConnector, hostConfig, pluginInfos);
|
|
_fetch = global.fetch;
|
|
global.fetch = async () => ({ ok: true });
|
|
});
|
|
afterEach(function() {
|
|
global.fetch = _fetch;
|
|
});
|
|
it("marks a restart as required for a new plugin", async function() {
|
|
await host.handlePluginInstallRequest(new lib.PluginInstallRequest("new_plugin"));
|
|
assert.equal(host.config.restartRequired, true);
|
|
});
|
|
it("leaves the flag alone for an already loaded plugin", async function() {
|
|
await host.handlePluginInstallRequest(new lib.PluginInstallRequest("restart_test"));
|
|
assert.equal(host.config.restartRequired, false);
|
|
});
|
|
it("rejects when installs are disabled", async function() {
|
|
host.config.set("host.allow_plugin_install", false);
|
|
host.config.restartRequired = false; // Setting allow_plugin_install requires a restart
|
|
await assert.rejects(
|
|
host.handlePluginInstallRequest(new lib.PluginInstallRequest("new_plugin")),
|
|
/Plugin installs are disabled/
|
|
);
|
|
assert.equal(host.config.restartRequired, false);
|
|
});
|
|
});
|
|
describe(".checkRestartDowngrade()", async function() {
|
|
let host;
|
|
const hostVersion = (
|
|
await import("@clusterio/host/package.json", { with: { type: "json" }})
|
|
).default.version;
|
|
beforeEach(function() {
|
|
const hostConfig = new lib.HostConfig("host", { "host.version": hostVersion });
|
|
const hostConnector = new HostConnector(hostConfig, []);
|
|
host = new Host(hostConnector, hostConfig, []);
|
|
});
|
|
it("returns null when the installed version matches", async function() {
|
|
assert.equal(await host.checkRestartDowngrade(), null);
|
|
});
|
|
it("returns null when the installed version is newer", async function() {
|
|
host.config.set("host.version", "0.0.0");
|
|
assert.equal(await host.checkRestartDowngrade(), null);
|
|
});
|
|
it("returns both versions when the installed version is older", async function() {
|
|
host.config.set("host.version", "999.0.0");
|
|
assert.deepEqual(await host.checkRestartDowngrade(), {
|
|
installedVersion: hostVersion,
|
|
runningVersion: "999.0.0",
|
|
});
|
|
});
|
|
});
|
|
describe(".handleInstanceAssignInternalRequest()", function() {
|
|
const instancesDir = path.join("temp", "test", "host_assign");
|
|
let host;
|
|
beforeEach(async function() {
|
|
await fs.rm(instancesDir, { recursive: true, force: true });
|
|
await fs.mkdir(instancesDir, { recursive: true });
|
|
const hostConfig = new lib.HostConfig("host", { "host.instances_directory": instancesDir });
|
|
const hostConnector = new HostConnector(hostConfig, []);
|
|
host = new Host(hostConnector, hostConfig, []);
|
|
host.send = () => {};
|
|
});
|
|
function assignRequest(id, name) {
|
|
const config = new lib.InstanceConfig("controller", { "instance.id": id, "instance.name": name });
|
|
return new lib.InstanceAssignInternalRequest(id, config.toRemote("host"));
|
|
}
|
|
|
|
it("creates the folders for a new instance", async function() {
|
|
await host.handleInstanceAssignInternalRequest(assignRequest(1, "new"));
|
|
const instancePath = path.join(instancesDir, "new");
|
|
assert.equal(host.discoveredInstances.get(1).path, instancePath);
|
|
await fs.access(path.join(instancePath, "saves"));
|
|
await fs.access(path.join(instancePath, "script-output"));
|
|
});
|
|
it("recreates missing folders for a discovered instance", async function() {
|
|
const instancePath = path.join(instancesDir, "existing");
|
|
await fs.mkdir(instancePath);
|
|
const configPath = path.join(instancePath, "instance.json");
|
|
host.discoveredInstances.set(2, {
|
|
path: instancePath,
|
|
config: new lib.InstanceConfig("host", {
|
|
...Host.instanceConfigWarning,
|
|
"instance.id": 2,
|
|
"instance.name": "existing",
|
|
}, configPath),
|
|
});
|
|
|
|
await host.handleInstanceAssignInternalRequest(assignRequest(2, "existing"));
|
|
await fs.access(path.join(instancePath, "saves"));
|
|
await fs.access(path.join(instancePath, "script-output"));
|
|
});
|
|
});
|
|
describe(".handleHostRestartRequest()", function() {
|
|
let mockHost;
|
|
beforeEach(function() {
|
|
process.exitCode = undefined;
|
|
mockHost = {
|
|
canRestart: true,
|
|
shutdownCalled: false,
|
|
async checkRestartDowngrade() { return null; },
|
|
shutdown() { this.shutdownCalled = true; },
|
|
handleHostRestartRequest: Host.prototype.handleHostRestartRequest,
|
|
};
|
|
});
|
|
afterEach(function() {
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
it("restarts when the installed version is not older", async function() {
|
|
await mockHost.handleHostRestartRequest();
|
|
assert.equal(process.exitCode, 1);
|
|
assert.equal(mockHost.shutdownCalled, true);
|
|
});
|
|
it("rejects a downgrade without shutting down the host", async function() {
|
|
mockHost.checkRestartDowngrade = async () => ({
|
|
installedVersion: "1.0.0",
|
|
runningVersion: "2.0.0",
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => mockHost.handleHostRestartRequest(),
|
|
/Stop the host before starting the older version manually/
|
|
);
|
|
assert.equal(process.exitCode, undefined);
|
|
assert.equal(mockHost.shutdownCalled, false);
|
|
});
|
|
});
|
|
});
|
|
});
|