Files
factorio-clusterio/test/integration/index.js
T
Danielv123 4a49a198f0 Pair hosts when they start instead of in the installer
The installer only writes the controller URL and host name. Running the
host requests to join the cluster if it has not been approved yet, or if
the controller no longer accepts its token, and waits until an admin
approves it. Once approved it saves its id and token and starts as
normal. A rejected host exits with code 8.

clusteriohost run --user-token approves the host right away with the
token of a user allowed to approve hosts. The token is not saved.

This moves the pairing client into the host package, where it uses the
same certificate setup as the host's connection to the controller, and
removes --no-pair and the rerun handling from the installer.
2026-10-30 21:13:14 +01:00

467 lines
15 KiB
JavaScript

/* eslint-disable no-console */
import path from "node:path";
import fs from "node:fs/promises";
import { readFileSync } from "node:fs";
import child_process from "node:child_process";
import jwt from "jsonwebtoken";
import util from "node:util";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { LineSplitter, logger } from "@clusterio/lib";
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
import { pairHost } from "@clusterio/host";
import "../setup_logging.js";
// Make sure permissions from plugins are loaded
import "../../plugins/global_chat/dist/node/index.js";
import "../../plugins/player_auth/dist/node/index.js";
import "../../plugins/research_sync/dist/node/index.js";
import "../../plugins/statistics_exporter/dist/node/index.js";
import "../../plugins/subspace_storage/dist/node/index.js";
export class TestControl extends lib.Link {
constructor(connector, subscribe = true) {
super(connector);
this.hostUpdates = [];
this.hostPairingUpdates = [];
this.instanceUpdates = [];
this.saveUpdates = [];
this.modUpdates = [];
this.modPackUpdates = [];
this.userUpdates = [];
this.connector.on("connect", () => {
if (!subscribe) {
return;
}
this.send(
new lib.SubscriptionRequest(lib.HostUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host pairing subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.InstanceDetailsUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.InstanceSaveDetailsUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting save subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.ModUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting mod subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.ModPackUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting mod pack subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.UserUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting user subscriptions:\n${err.stack}`));
});
this.handle(lib.AccountUpdateEvent);
this.handle(lib.HostUpdatesEvent, this.handleHostUpdatesEvent.bind(this));
this.handle(lib.HostPairingUpdatesEvent, this.handleHostPairingUpdatesEvent.bind(this));
this.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsUpdatesEvent.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.ModUpdatesEvent, this.handleModUpdatesEvent.bind(this));
this.handle(lib.ModPackUpdatesEvent, this.handleModPackUpdatesEvent.bind(this));
this.handle(lib.UserUpdatesEvent, this.handleUserUpdatesEvent.bind(this));
}
async handleHostUpdatesEvent(event) {
this.hostUpdates.push(...event.updates);
}
async handleHostPairingUpdatesEvent(event) {
this.hostPairingUpdates.push(...event.updates);
}
async handleInstanceDetailsUpdatesEvent(event) {
this.instanceUpdates.push(...event.updates);
}
async handleInstanceSaveDetailsUpdatesEvent(event) {
this.saveUpdates.push(event);
}
async handleModUpdatesEvent(event) {
this.modUpdates.push(...event.updates);
}
async handleModPackUpdatesEvent(event) {
this.modPackUpdates.push(...event.updates);
}
async handleUserUpdatesEvent(event) {
this.userUpdates.push(...event.updates);
}
async setLogSubscriptions(args) {
// Do nothing
}
}
export class TestControlConnector extends lib.WebSocketClientConnector {
register() {
this.sendHandshake(
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this.token, "test",
)
)
);
}
}
export class TestHostConnector extends lib.WebSocketClientConnector {
register() {
this.sendHandshake(
new lib.MessageRegisterHost(
new lib.RegisterHostData(
this.token, "test",
this.hostId, {},
)
)
);
}
}
// Mark that this test or suite of tests requires a factorio install to run.
export function requiresFactorio(testOrSuite) {
if (testOrSuite.skip) {
if (!haveFactorioInstall) {
testOrSuite.skip();
}
} else {
before(function() {
if (!haveFactorioInstall) {
this.skip();
}
});
}
}
export function hasFactorio() {
return haveFactorioInstall;
}
export function getFactorioVersion() {
return latestFactorioVersion;
}
export async function get(urlPath) {
const url = new URL("https://localhost:4443");
url.pathname = urlPath;
let res = await fetch(url);
if (res.status !== 200) {
const body = Buffer.from(await res.arrayBuffer());
throw new Error(`Got response code ${res.status}, content: ${body.toString()}`);
}
return res;
}
function loadJSON(filePath) {
try {
// eslint-disable-next-line node/no-sync
return JSON.parse(readFileSync(filePath, "utf8"));
} catch (err) {
if (err.code === "ENOENT") {
return undefined;
}
throw err;
}
}
function getFactorioDir(hostConfig) {
const factorioDir = hostConfig?.["host.factorio_directory"] ?? "factorio";
return path.join(factorioDir);
}
let controllerProcess;
let hostProcess;
let control;
let haveFactorioInstall;
let latestFactorioVersion;
const baseHostConfig = loadJSON("config-host.json");
export let url = "https://localhost:4443/";
// Signed in the root before hook, as the user has to exist before the token is issued
export let controlToken;
// Id of the test host, assigned by the controller when it is paired in the root before hook
export let hostId;
export let instancesDir = path.join("temp", "test", "instances");
export let modsDir = path.join("temp", "test", "mods");
export let databaseDir = path.join("temp", "test", "database");
export let factorioDir = getFactorioDir(baseHostConfig);
let pluginListPath = path.join("temp", "test", "plugin-list.json");
export let controllerConfigPath = path.join("temp", "test", "config-controller.json");
export let hostConfigPath = path.join("temp", "test", "config-host.json");
export let ctlConfigPath = path.join("temp", "test", "config-ctl.json");
function childOptions(options) {
return {
cwd: path.join("temp", "test"),
env: { ...process.env },
...options,
};
}
export function exec(command, options = {}) {
// Uncomment to show commands run in tests
// console.log(command);
return util.promisify(child_process.exec)(command, childOptions(options));
}
export function execController(...args) {
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
return exec(...args);
}
export function execHost(...args) {
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
return exec(...args);
}
export function execCtlProcess(...args) {
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
return exec(...args);
}
let inExecCtl = false;
export async function execCtl(command) {
inExecCtl = true;
process.chdir("temp/test");
try {
const initArgs = await initializeCtl(command, control.hooks, true);
if (!initArgs.shouldRun) {
return;
}
control.config = initArgs.ctlConfig;
const targetCommand = selectTargetCommand(initArgs.args, initArgs.rootCommands);
await targetCommand.run(initArgs.args, control);
} finally {
process.chdir("../..");
inExecCtl = false;
}
}
afterEach(function() {
if (inExecCtl) {
// This is needed in case a test times out and never runs the finally block
process.chdir("../..");
}
});
export async function sendRcon(instanceId, command) {
return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command));
}
export function getControl() {
return control;
}
/**
* Pair a new host with the test controller, approved right away with the admin token
*
* @param {string} name - Name to give the host.
* @returns {Promise<{ hostId: number, token: string, name: string }>} the paired host.
*/
export async function pairTestHost(name) {
return await pairHost({ controllerUrl: url, name, token: controlToken });
}
/**
* Write a new host config for a paired host, with the fields the installer and pairing set
*
* @param {string} configPath - Path to write the config to.
* @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing.
* @param {Record<string, unknown>} fields - Other fields to set in the config.
*/
export async function writeHostConfig(configPath, pairing, fields = {}) {
const hostConfig = new lib.HostConfig("host");
hostConfig.set("host.id", pairing.hostId);
hostConfig.set("host.name", pairing.name);
hostConfig.set("host.controller_url", url);
hostConfig.set("host.controller_token", pairing.token);
for (const [name, value] of Object.entries(fields)) {
hostConfig.set(name, value);
}
await fs.writeFile(configPath, JSON.stringify(hostConfig, null, "\t"), { mode: 0o600 });
}
/** @returns {Promise<child_process.ChildProcess>} */
export function spawn(name, cmd, waitFor, options = {}) {
const silent = process.env.SILENT_TEST;
const bootstrap = !controllerProcess || !hostProcess;
function log(...args) {
if (!silent || bootstrap) {
console.log(...args);
}
}
return new Promise((resolve, reject) => {
log(cmd);
let parts = cmd.split(" ");
let process = child_process.spawn(parts[0], parts.slice(1), childOptions(options));
let stdout = new LineSplitter({ readableObjectMode: true });
let stderr = new LineSplitter({ readableObjectMode: true });
let onDataOut = line => {
line = line.toString("utf8");
if (waitFor.test(line)) {
if (silent) {
stdout.off("data", onDataOut);
stderr.off("data", onDataErr);
}
resolve(process);
}
log(name, line);
};
let onDataErr = line => {
log(name, line.toString("utf8"));
};
stdout.on("data", onDataOut);
stderr.on("data", onDataErr);
process.stdout.pipe(stdout);
process.stderr.pipe(stderr);
});
}
export function spawnNode(name, cmd, waitFor, options) {
return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options);
}
before(async function() {
this.timeout(40000);
const silent = process.env.SILENT_TEST;
if (silent) {
console.log("SILENT_TEST is present in env, loggers after bootstrap will be muted.");
}
// If fast test is enabled then output that it is
if (process.env.FAST_TEST) {
console.log("FAST_TEST is present in env, slow tests will be skipped.");
}
if (process.env.NO_EXTERNAL_TEST) {
console.log("NO_EXTERNAL_TEST is present in env, tests depending on external APIs will be skipped.");
}
await fs.rm(instancesDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(modsDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(databaseDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(pluginListPath, { force: true });
await fs.rm(controllerConfigPath, { force: true });
await fs.rm(hostConfigPath, { force: true });
await fs.rm(ctlConfigPath, { force: true });
await fs.mkdir(path.join("temp", "test"), { recursive: true });
console.log("Building Mods");
await fs.mkdir(modsDir, { recursive: true });
await lib.build({
build: true,
pack: true,
sourceDir: "packages/host/lua/clusterio_lib",
outputDir: modsDir,
});
console.log("Setting Controller Config");
await execController("config create");
await execController("config set controller.auth_secret TestSecretDoNotUse");
await execController("config set controller.http_port 8880");
await execController("config set controller.https_port 4443");
await execController("config set controller.heartbeat_interval 0.25");
await execController("config set controller.session_timeout 2");
await execController("config set controller.tls_certificate ../../test/file/tls/cert.pem");
await execController("config set controller.tls_private_key ../../test/file/tls/key.pem");
console.log("Setting Controller Plugins");
await execCtlProcess("plugin add ../../test/file/test_plugin");
await execCtlProcess("plugin add ../../plugins/global_chat");
await execCtlProcess("plugin add ../../plugins/research_sync");
await execCtlProcess("plugin add ../../plugins/statistics_exporter");
await execCtlProcess("plugin add ../../plugins/subspace_storage");
await execCtlProcess("plugin add ../../plugins/player_sync");
await execCtlProcess("plugin add ../../plugins/player_auth");
console.log("Bootstrapping");
await execController("bootstrap create-admin test");
await execController("bootstrap create-ctl-config test");
controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
const factorioVersions = (await _listFactorioVersions(factorioDir)).versions;
latestFactorioVersion = [...factorioVersions.values()]
.sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0];
haveFactorioInstall = factorioVersions.size > 0;
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
const pairing = await pairTestHost("host");
hostId = pairing.hostId;
await writeHostConfig(hostConfigPath, pairing);
await execHost("config create");
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
hostProcess = await spawnNode("host:", "../../packages/host run", /Started host/);
const controlConnector = new TestControlConnector(url, 2);
controlConnector.token = controlToken;
control = new TestControl(controlConnector);
await controlConnector.connect();
const initArgs = await initializeCtl(`--plugin-list ${pluginListPath} config create`, undefined, true);
control.config = initArgs.ctlConfig;
control.hooks = initArgs.ctlHooks;
const testPack = lib.ModPack.fromJSON({});
testPack.id = 12;
testPack.name = "subspace_storage-pack";
testPack.factorioVersion = latestFactorioVersion;
const [major, minor] = lib.normaliseMajorMinorVersion(latestFactorioVersion).split(".");
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: `2.1.${major}${minor}` });
await control.sendTo("controller", new lib.ModPackCreateRequest(testPack));
await control.sendTo(
"controller",
new lib.ControllerConfigSetFieldRequest("controller.default_mod_pack_id", "12"),
);
});
after(async function() {
this.timeout(20000);
if (hostProcess) {
console.log("Shutting down host");
hostProcess.kill("SIGINT");
await events.once(hostProcess, "exit");
}
if (controllerProcess) {
console.log("Shutting down controller");
controllerProcess.kill("SIGINT");
await events.once(controllerProcess, "exit");
}
if (control) {
await control.connector.close();
}
if (haveFactorioInstall === false) {
console.warn("Many tests may have been skipped due to no factorio install being detected");
}
});
// Ensure the test processes are stopped.
process.on("exit", () => {
if (hostProcess) { hostProcess.kill(); }
if (controllerProcess) { controllerProcess.kill(); }
});