mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
FactorioServer.fromOptions finds the Factorio install and generates the RCON password before constructing, so _version and _dataDir are never null and setTargetVersion and init are gone. checkForUpdates is now a module function called before fromOptions. Instance.fromConfig resolves the version, downloads Factorio if needed and builds the server, then constructs the instance with it. It runs before the instance is connected, so the starting and stopped statuses and the version requests go through the host link. FactorioVersionsRequest and LatestReleasesRequest now accept host as a source, and the host tracks starting instances so a second start during a download is rejected. Plugin loading stays a separate loadPlugins step after the instance is connected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
442 lines
14 KiB
JavaScript
442 lines
14 KiB
JavaScript
import assert from "node:assert/strict";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { Host, Instance, InstanceConnection } from "@clusterio/host";
|
|
import { ControlConnection, Controller, HostConnection, InstanceRecord } from "@clusterio/controller";
|
|
import { MockServer } from "../mock.js";
|
|
|
|
// Although there are no imports, the file has side-effects such as "before" which can effect tests
|
|
import "./index.js";
|
|
|
|
const addr = lib.Address.fromShorthand;
|
|
|
|
class Control extends lib.Link { }
|
|
|
|
/**
|
|
* @param {Controller} controller
|
|
* @param {number} hostId
|
|
*/
|
|
function connectHost(controller, hostId, plugins) {
|
|
const [controllerSide, hostSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ hostId }));
|
|
const registerData = new lib.RegisterHostData("", "0.0.0", hostId, plugins);
|
|
const hostConnection = new HostConnection(registerData, controllerSide, controller, "host-a.test");
|
|
controller.wsServer.hostConnections.set(hostId, hostConnection);
|
|
const hostConfig = new lib.HostConfig("host");
|
|
hostConfig.set("host.id", hostId);
|
|
return new Host(hostSide, hostConfig, []);
|
|
}
|
|
|
|
/**
|
|
* @param {Controller} controller
|
|
* @param {Host} host
|
|
* @param {number} instanceId
|
|
*/
|
|
function connectInstance(controller, host, instanceId) {
|
|
const hostAddress = addr({ hostId: host.config.get("host.id")});
|
|
const instanceAddress = addr({ instanceId });
|
|
const [instanceSide, hostSide] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
|
|
const instanceConfig = new lib.InstanceConfig("host");
|
|
instanceConfig.set("instance.id", instanceId);
|
|
instanceConfig.set("instance.assigned_host", host.config.get("host.id"));
|
|
host.assignedInstances.set(instanceId, { path: "invalid", config: instanceConfig });
|
|
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
|
|
const instance = new Instance(host, instanceSide, "invalid", instanceConfig, new MockServer());
|
|
const instanceConnection = new InstanceConnection(hostSide, host, instance);
|
|
host.instanceConnections.set(instanceId, instanceConnection);
|
|
return instance;
|
|
}
|
|
|
|
/**
|
|
* @param {Controller} controller
|
|
* @param {number} hostId
|
|
*/
|
|
function connectControl(controller, controlId) {
|
|
const [controllerSide, controlSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ controlId }));
|
|
const registerData = new lib.RegisterControlData("", "0.0.0");
|
|
const user = controller.users.getOrCreateUser("test");
|
|
const controlConnection = new ControlConnection(registerData, controllerSide, controller, user, controlId);
|
|
controller.wsServer.controlConnections.set(controlId, controlConnection);
|
|
return new Control(controlSide);
|
|
}
|
|
|
|
class TestRequest {
|
|
static type = "request";
|
|
static src = ["controller", "host", "instance", "control"];
|
|
static dst = ["controller", "host", "instance", "control"];
|
|
static permission = null;
|
|
}
|
|
|
|
class TestEvent {
|
|
static type = "event";
|
|
static src = ["controller", "host", "instance", "control"];
|
|
static dst = ["controller", "host", "instance", "control"];
|
|
static permission = null;
|
|
}
|
|
|
|
class PluginEvent {
|
|
static type = "event";
|
|
static src = ["controller", "host", "instance", "control"];
|
|
static dst = ["controller", "host", "instance", "control"];
|
|
static permission = null;
|
|
static plugin = "test";
|
|
}
|
|
|
|
lib.Link.register(TestRequest);
|
|
lib.Link.register(TestEvent);
|
|
lib.Link.register(PluginEvent);
|
|
|
|
describe("Integration of link routing", function() {
|
|
/** @type {Controller} */
|
|
let controller;
|
|
/** @type {Host} */
|
|
let hostA;
|
|
/** @type {Host} */
|
|
let hostB;
|
|
/** @type {Instance} */
|
|
let instanceA1;
|
|
/** @type {Instance} */
|
|
let instanceA2;
|
|
/** @type {Instance} */
|
|
let instanceB1;
|
|
/** @type {Control} */
|
|
let controlA;
|
|
/** @type {Control} */
|
|
let controlB;
|
|
|
|
beforeEach(function() {
|
|
controller = new Controller({}, [], new lib.ControllerConfig("controller"));
|
|
hostA = connectHost(controller, 10, []);
|
|
hostB = connectHost(controller, 20, []);
|
|
instanceA1 = connectInstance(controller, hostA, 11);
|
|
instanceA2 = connectInstance(controller, hostA, 12);
|
|
instanceB1 = connectInstance(controller, hostB, 21);
|
|
controlA = connectControl(controller, 100);
|
|
controlB = connectControl(controller, 101);
|
|
});
|
|
|
|
/** @returns {Controller | Host | Instance} */
|
|
function get(name) {
|
|
return {
|
|
controller,
|
|
hostA,
|
|
hostB,
|
|
instanceA1,
|
|
instanceA2,
|
|
instanceB1,
|
|
controlA,
|
|
controlB,
|
|
}[name];
|
|
}
|
|
|
|
// Combinations to try. Equivalent paths have been removed.
|
|
const parties = [
|
|
["controller", ["hostA", "instanceA1", "controlA"]],
|
|
["hostA", ["controller", "hostB", "instanceA1", "instanceB1", "controlA"]],
|
|
["instanceA1", ["controller", "hostA", "hostB", "instanceA2", "instanceB1", "controlA"]],
|
|
["controlA", ["controller", "hostA", "instanceA1", "controlB"]],
|
|
];
|
|
|
|
function handle(dst, Message, handler) {
|
|
if (dst instanceof Controller) {
|
|
for (const host of dst.wsServer.hostConnections.values()) {
|
|
host.handle(Message, handler);
|
|
}
|
|
for (const control of dst.wsServer.controlConnections.values()) {
|
|
control.handle(Message, handler);
|
|
}
|
|
} else if (dst instanceof Host) {
|
|
for (const instance of dst.instanceConnections.values()) {
|
|
instance.handle(Message, handler);
|
|
}
|
|
dst.handle(Message, handler);
|
|
} else {
|
|
dst.handle(Message, handler);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Controller | Host | Instance} link
|
|
* @param {Address} dst
|
|
*/
|
|
function send(link, dst, message) {
|
|
// This logic exists becasue .sendTo doesn't forward all kinds of
|
|
// messages to the correct location from all places. Idealy this
|
|
// would not be neccessary and .sendTo would do the right thing.
|
|
if (link instanceof Host) {
|
|
const instance = link.instanceConnections.get(dst.id);
|
|
if (instance) {
|
|
return instance.sendTo(dst, message);
|
|
}
|
|
}
|
|
return link.sendTo(dst, message);
|
|
}
|
|
|
|
describe("request routing", function() {
|
|
for (const [srcName, dstNames] of parties) {
|
|
it(`should route from ${srcName}`, async function() {
|
|
for (const dstName of dstNames) {
|
|
let called;
|
|
const dst = get(dstName);
|
|
handle(dst, TestRequest, async () => { called = true; });
|
|
const dstAddr = dst instanceof Controller ? addr("controller") : dst.connector.src;
|
|
await send(get(srcName), dstAddr, new TestRequest());
|
|
assert(called, `${dstName} handler was not called`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("event routing", function() {
|
|
for (const [srcName, dstNames] of parties) {
|
|
it(`should route from ${srcName}`, async function() {
|
|
for (const dstName of dstNames) {
|
|
let called;
|
|
const dst = get(dstName);
|
|
handle(dst, TestEvent, async () => { called = true; });
|
|
const dstAddr = dst instanceof Controller ? addr("controller") : dst.connector.src;
|
|
send(get(srcName), dstAddr, new TestEvent());
|
|
assert(called, `${dstName} handler was not called`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @param {Controller | Host | Instance | Control} link
|
|
* @param {"allHosts" | "allInstances" | "allControls"} dst
|
|
*/
|
|
function bcast(link, dst, message) {
|
|
// This logic exists becasue .sendTo doesn't forward all kinds of
|
|
// messages to the correct location from all places. Idealy this
|
|
// would not be neccessary and .sendTo would do the right thing.
|
|
if (link instanceof Host) {
|
|
const plugin = message.constructor.plugin;
|
|
if (dst === "allInstances") {
|
|
for (const instance of link.instanceConnections.values()) {
|
|
if (plugin && !instance.plugins.has(plugin)) {
|
|
continue;
|
|
}
|
|
instance.sendTo(dst, message);
|
|
}
|
|
}
|
|
if (plugin && !link.serverPlugins.has(plugin)) {
|
|
return;
|
|
}
|
|
}
|
|
link.sendTo(dst, message);
|
|
}
|
|
|
|
function bhandle(srcName, dst, Event, canReach = (dstB) => true) {
|
|
const asserts = [];
|
|
const dsts = {
|
|
allHosts: ["hostA", "hostB"],
|
|
allInstances: ["instanceA1", "instanceA2", "instanceB1"],
|
|
allControls: ["controlA", "controlB"],
|
|
};
|
|
for (const dstName of dsts[dst]) {
|
|
if (srcName === dstName) { continue; }
|
|
let called;
|
|
handle(get(dstName), Event, async () => { called = true; });
|
|
const should = canReach(dstName);
|
|
asserts.push(() => assert(
|
|
!should ^ called,
|
|
`${dstName} handler was ${should ? "not " : ""}called from ${srcName}`,
|
|
));
|
|
}
|
|
return asserts;
|
|
}
|
|
|
|
describe("event broadcast", function() {
|
|
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
|
|
it(`should broadcast from ${srcName}`, async function() {
|
|
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
|
|
const dstAsserts = bhandle(srcName, dstName, TestEvent);
|
|
bcast(get(srcName), dstName, new TestEvent());
|
|
for (const dstAssert of dstAsserts) {
|
|
dstAssert();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
const graph = {
|
|
controller: ["hostA", "hostB", "controlA", "controlB"],
|
|
hostA: ["controller", "instanceA1", "instanceA2"],
|
|
hostB: ["controller", "instanceB1"],
|
|
instanceA1: ["hostA"],
|
|
instanceA2: ["hostA"],
|
|
instanceB1: ["hostB"],
|
|
controlA: ["controller"],
|
|
controlB: ["controller"],
|
|
};
|
|
|
|
// Returns true if the path from src to dst goes through via
|
|
function goesVia(src, dst, via) {
|
|
// Places we've been to
|
|
const checked = new Set();
|
|
// Places left to check
|
|
const paths = new Set([src]);
|
|
while (paths.size) {
|
|
const [candidate] = paths;
|
|
paths.delete(candidate);
|
|
if (candidate === via) {
|
|
continue;
|
|
}
|
|
if (checked.has(candidate)) {
|
|
continue;
|
|
}
|
|
if (candidate === dst) {
|
|
return false;
|
|
}
|
|
checked.add(candidate);
|
|
for (const newCandidate of graph[candidate]) {
|
|
paths.add(newCandidate);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
assert(goesVia("instanceA1", "instanceB1", "controller"));
|
|
assert(!goesVia("instanceA1", "instanceA2", "controller"));
|
|
assert(goesVia("instanceA1", "hostB", "hostA"));
|
|
assert(!goesVia("instanceA1", "hostB", "controlA"));
|
|
assert(goesVia("controlA", "controlB", "controller"));
|
|
assert(goesVia("controlA", "hostA", "controller"));
|
|
|
|
function addPluginExceptFor(exName) {
|
|
const ex = get(exName);
|
|
const exAddr = ex instanceof Controller ? addr("controller") : ex.connector.src;
|
|
for (const name of [
|
|
"controller", "hostA", "hostB", "instanceA1", "instanceA2", "instanceB1", "controlA", "controlB",
|
|
]) {
|
|
const party = get(name);
|
|
if (party instanceof Controller) {
|
|
for (const host of party.wsServer.hostConnections.values()) {
|
|
if (!host.connector.dst.equals(exAddr)) {
|
|
host.plugins.set("test", "0.0.0");
|
|
}
|
|
}
|
|
for (const control of party.wsServer.controlConnections.values()) {
|
|
if (!control.connector.dst.equals(exAddr)) {
|
|
// No way to set plugins
|
|
}
|
|
}
|
|
} else if (party instanceof Host) {
|
|
for (const instance of party.instanceConnections.values()) {
|
|
if (!instance.connector.dst.equals(exAddr)) {
|
|
instance.plugins.set("test", "0.0.0");
|
|
}
|
|
}
|
|
if (!party.connector.dst.equals(exAddr)) {
|
|
party.serverPlugins.set("test", "0.0.0");
|
|
}
|
|
} else if (party instanceof Instance) {
|
|
// Currently does not handle plugins
|
|
} else if (party instanceof Control) {
|
|
// Currently does not handle plugins
|
|
}
|
|
}
|
|
}
|
|
|
|
// When plugin events are broadcast the party sending the event should
|
|
// filter it out if the next hop it is forwarded to does not have plugin
|
|
// the event was defined in loaded.
|
|
describe("event broadcast with controller missing plugin", function() {
|
|
for (const srcName of ["hostA", "instanceA1", "controlA"]) {
|
|
it(`should broadcast from ${srcName}`, async function() {
|
|
if (srcName === "controlA") {
|
|
this.skip(); // Not implemented
|
|
}
|
|
addPluginExceptFor("controller");
|
|
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
|
|
const dstAsserts = bhandle(
|
|
srcName, dstName, PluginEvent, dstB => !goesVia(srcName, dstB, "controller"),
|
|
);
|
|
bcast(get(srcName), dstName, new PluginEvent());
|
|
for (const dstAssert of dstAsserts) {
|
|
dstAssert();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("event broadcast with host missing plugin", function() {
|
|
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
|
|
it(`should broadcast from ${srcName}`, async function() {
|
|
addPluginExceptFor("hostB");
|
|
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
|
|
const dstAsserts = bhandle(
|
|
srcName, dstName, PluginEvent, dstB => !goesVia(srcName, dstB, "hostB"),
|
|
);
|
|
bcast(get(srcName), dstName, new PluginEvent());
|
|
for (const dstAssert of dstAsserts) {
|
|
dstAssert();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("event broadcast with instance missing plugin", function() {
|
|
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
|
|
it(`should broadcast from ${srcName}`, async function() {
|
|
addPluginExceptFor("instanceA2");
|
|
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
|
|
const dstAsserts = bhandle(
|
|
srcName, dstName, PluginEvent, dstB => !goesVia(srcName, dstB, "instanceA2"),
|
|
);
|
|
bcast(get(srcName), dstName, new PluginEvent());
|
|
for (const dstAssert of dstAsserts) {
|
|
dstAssert();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("event broadcast with control missing plugin", function() {
|
|
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
|
|
it(`should broadcast from ${srcName}`, async function() {
|
|
this.skip(); // Currently not implemented.
|
|
addPluginExceptFor("controlB");
|
|
for (const dstName of ["allHosts", "allInstances", "allControls"]) {
|
|
const dstAsserts = bhandle(
|
|
srcName, dstName, PluginEvent, dstB => !goesVia(srcName, dstB, "controlB"),
|
|
);
|
|
bcast(get(srcName), dstName, new PluginEvent());
|
|
for (const dstAssert of dstAsserts) {
|
|
dstAssert();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("event should not be routable on loopback", function() {
|
|
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
|
|
it(`should not loopback on ${srcName}`, async function() {
|
|
const src = get(srcName);
|
|
const dstAddr = src instanceof Controller ? addr("controller") : src.connector.src;
|
|
assert.throws(
|
|
() => send(src, dstAddr, new TestEvent()),
|
|
new Error(`Message would return back to sender ${dstAddr}.`)
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("request should not be routable on loopback", function() {
|
|
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
|
|
it(`should not loopback on ${srcName}`, async function() {
|
|
const src = get(srcName);
|
|
const dstAddr = src instanceof Controller ? addr("controller") : src.connector.src;
|
|
assert.throws(
|
|
() => send(src, dstAddr, new TestRequest()),
|
|
new Error(`Message would return back to sender ${dstAddr}.`)
|
|
);
|
|
});
|
|
}
|
|
});
|
|
});
|