mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Refactor user and instance manager (#837)
* Refactor UserManager to use Datastore * Rename lib.User to UserDetails also adds to/fromUserDetails * Fix existing tests * Add user class tests * Add tests for finaliseUsers * Move HostDetails to own file with tests * Move InstanceDetails to its own file with tests * Rename HostInfo and add tests * Rename InstanceInfo and add tests - Also changes the names of the host properties related to instances - Also removes the need to specify the location of InstanceInfo * Make json round trip test validate schema * Add InstanceManager * Fix lint errors * Extra tests * Add final test coverage * Add one last test to InstanceManager * Response to comments * Use generator rather than return anonumous generator Co-authored-by: Hornwitser <github@hornwitser.no> * Fix lint issue with generator --------- Co-authored-by: Hornwitser <github@hornwitser.no>
This commit is contained in:
1 parent
5bea930d05
commit
2235294c59
60 files changed
+3371
-1227
No files matched your search
@@ -32,6 +32,7 @@ const { ConsoleTransport, levels, logger } = lib;
|
||||
|
||||
import Controller from "./src/Controller";
|
||||
import UserManager from "./src/UserManager";
|
||||
import UserRecord from "./src/UserRecord";
|
||||
import { version } from "./package.json";
|
||||
|
||||
// globals
|
||||
@@ -133,13 +134,22 @@ async function handleBootstrapCommand(
|
||||
let subCommand = args._[1];
|
||||
|
||||
const databaseDirectory = controllerConfig.get("controller.database_directory");
|
||||
await UserManager.attemptMigrateLegacyUsersFile(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
);
|
||||
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(controllerConfig, roles);
|
||||
await userManager.load(path.join(databaseDirectory, "users.json"));
|
||||
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
UserRecord.fromJSON.bind(UserRecord),
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(users, roles, controllerConfig);
|
||||
|
||||
if (subCommand === "create-admin") {
|
||||
await controllerConfigLock.acquire(); // Also needed to write to the database files
|
||||
@@ -150,14 +160,12 @@ async function handleBootstrapCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
let admin = userManager.getByName(args.name);
|
||||
if (!admin) {
|
||||
admin = userManager.createUser(args.name);
|
||||
}
|
||||
const user = userManager.getOrCreateUser(args.name);
|
||||
user.roleIds.add(lib.Role.DefaultAdminRoleId);
|
||||
user.isAdmin = true;
|
||||
user.saveRecord();
|
||||
|
||||
admin.isAdmin = true;
|
||||
admin.roleIds.add(lib.Role.DefaultAdminRoleId);
|
||||
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
||||
await userManager.records.save();
|
||||
await controllerConfigLock.release();
|
||||
|
||||
} else if (subCommand === "generate-user-token") {
|
||||
@@ -430,7 +438,7 @@ async function startup() {
|
||||
);
|
||||
|
||||
// Refuse to start if there are no users loaded
|
||||
if (args.checkUserCount && controller.userManager.users.size === 0) {
|
||||
if (args.checkUserCount && controller.users.records.size === 0) {
|
||||
logger.fatal(
|
||||
"Cannot start controller, no users loaded.\n" +
|
||||
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { bootstrap } from "./controller";
|
||||
export { default as Controller } from "./src/Controller";
|
||||
export { default as ControllerUser } from "./src/ControllerUser";
|
||||
export { default as ControllerRouter } from "./src/ControllerRouter";
|
||||
export { default as ControlConnection } from "./src/ControlConnection";
|
||||
export { default as HostConnection } from "./src/HostConnection";
|
||||
export { default as InstanceInfo } from "./src/InstanceInfo";
|
||||
export { default as HostInfo } from "./src/HostInfo";
|
||||
export { default as InstanceRecord } from "./src/InstanceRecord";
|
||||
export { default as InstanceManager } from "./src/InstanceManager";
|
||||
export { default as HostRecord } from "./src/HostRecord";
|
||||
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
|
||||
export { default as UserManager } from "./src/UserManager";
|
||||
export { default as UserRecord } from "./src/UserRecord";
|
||||
export { default as User } from "./src/User";
|
||||
export { default as WsServer } from "./src/WsServer";
|
||||
|
||||
if (module === require.main) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
|
||||
} from "@clusterio/lib";
|
||||
import type Controller from "./Controller";
|
||||
import type InstanceInfo from "./InstanceInfo";
|
||||
import type InstanceRecord from "./InstanceRecord";
|
||||
import type ControlConnection from "./ControlConnection";
|
||||
import type HostConnection from "./HostConnection";
|
||||
|
||||
@@ -91,7 +91,7 @@ export default class BaseControllerPlugin {
|
||||
* The instance that changed.
|
||||
* @param prev - the previous status of the instance.
|
||||
*/
|
||||
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
|
||||
async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { }
|
||||
|
||||
/**
|
||||
* Called when the value of a controller config field changed.
|
||||
@@ -117,7 +117,7 @@ export default class BaseControllerPlugin {
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
|
||||
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { }
|
||||
|
||||
/**
|
||||
* Called before collecting Prometheus metrics
|
||||
@@ -261,7 +261,7 @@ export default class BaseControllerPlugin {
|
||||
* The instance it occured on.
|
||||
* @param event - Information about the event.
|
||||
*/
|
||||
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
|
||||
async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
|
||||
|
||||
/**
|
||||
* Broadcast event to all connected hosts
|
||||
|
||||
@@ -6,7 +6,8 @@ import * as lib from "@clusterio/lib";
|
||||
const { logFilter, logger } = lib;
|
||||
|
||||
import BaseConnection from "./BaseConnection";
|
||||
import ControllerUser from "./ControllerUser";
|
||||
import User from "./User";
|
||||
import UserRecord from "./UserRecord";
|
||||
import * as routes from "./routes";
|
||||
import Controller from "./Controller";
|
||||
|
||||
@@ -40,7 +41,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
registerData: { version: string },
|
||||
connector: WsServerConnector,
|
||||
controller: Controller,
|
||||
public user: ControllerUser, // The user making this connection.
|
||||
public user: User, // The user making this connection.
|
||||
public id: number
|
||||
) {
|
||||
super(connector, controller);
|
||||
@@ -250,7 +251,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
|
||||
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
|
||||
return this._controller.instances.getForRequest(request.instanceId).toInstanceDetails();
|
||||
}
|
||||
|
||||
async handleInstanceDetailsListRequest() {
|
||||
@@ -268,20 +269,20 @@ export default class ControlConnection extends BaseConnection {
|
||||
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
|
||||
}
|
||||
instanceConfig.update(request.config, false, "control");
|
||||
await this._controller.instanceCreate(instanceConfig);
|
||||
await this._controller.instances.createInstance(instanceConfig);
|
||||
}
|
||||
|
||||
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
|
||||
await this._controller.instanceDelete(request.instanceId);
|
||||
await this._controller.instances.deleteInstance(request.instanceId);
|
||||
}
|
||||
|
||||
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
return instance.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
if (request.field === "instance.assigned_host") {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
@@ -296,14 +297,14 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
|
||||
let instance = this._controller.getRequestInstance(request.instanceId);
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
let { field, prop, value } = request;
|
||||
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
|
||||
await this._controller.instanceAssign(request.instanceId, request.hostId);
|
||||
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsListRequest() {
|
||||
@@ -337,8 +338,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (request.sourceInstanceId === request.targetInstanceId) {
|
||||
throw new lib.RequestError("Source and target instance may not be the same");
|
||||
}
|
||||
let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId);
|
||||
let targetInstance = this._controller.getRequestInstance(request.targetInstanceId);
|
||||
let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
|
||||
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
|
||||
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
|
||||
let targetHostId = targetInstance.config.get("instance.assigned_host");
|
||||
if (sourceHostId === null) {
|
||||
@@ -870,35 +871,33 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
this._controller.roles.delete(role);
|
||||
|
||||
this._controller.userManager.dirty = true;
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
if (user.roleIds.delete(id)) {
|
||||
for (const user of this._controller.users.values()) {
|
||||
if (user.removeRole(id)) {
|
||||
this._controller.userPermissionsUpdated(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
|
||||
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
|
||||
let name = request.name;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User ${name} does not exist`);
|
||||
}
|
||||
|
||||
return user;
|
||||
return user.toUserDetails();
|
||||
}
|
||||
|
||||
async handleUserListRequest(): Promise<lib.User[]> {
|
||||
return [...this._controller.userManager.users.values()];
|
||||
async handleUserListRequest(): Promise<lib.UserDetails[]> {
|
||||
return [...this._controller.users.records.values()].map(user => user.toUserDetails());
|
||||
}
|
||||
|
||||
async handleUserCreateRequest(request: lib.UserCreateRequest) {
|
||||
let user = this._controller.userManager.createUser(request.name);
|
||||
this._controller.usersUpdated([user]);
|
||||
this._controller.users.createUser(request.name);
|
||||
}
|
||||
|
||||
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
|
||||
let user = this._controller.userManager.getByName(request.name);
|
||||
let user = this._controller.users.getByName(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
@@ -912,11 +911,10 @@ export default class ControlConnection extends BaseConnection {
|
||||
controlConnection.connector.terminate();
|
||||
}
|
||||
}
|
||||
this._controller.usersUpdated([user]);
|
||||
}
|
||||
|
||||
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
|
||||
let user = this._controller.userManager.getByName(request.name);
|
||||
let user = this._controller.users.getByName(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
@@ -927,35 +925,33 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
user.roleIds = new Set(request.roles);
|
||||
user.set("roleIds", new Set(request.roles));
|
||||
this._controller.userPermissionsUpdated(user);
|
||||
this._controller.usersUpdated([user]);
|
||||
}
|
||||
|
||||
async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) {
|
||||
let { name, create, admin } = request;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
user.isAdmin = admin;
|
||||
this._controller.usersUpdated([user]);
|
||||
user.set("isAdmin", admin);
|
||||
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin));
|
||||
}
|
||||
|
||||
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
|
||||
let { name, create, banned, reason } = request;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByNameMutable(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
@@ -963,37 +959,34 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
user.isBanned = banned;
|
||||
user.banReason = reason;
|
||||
this._controller.usersUpdated([user]);
|
||||
user.saveRecord();
|
||||
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason));
|
||||
}
|
||||
|
||||
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
|
||||
let { name, create, whitelisted } = request;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
if (create) {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.userManager.createUser(name);
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
user.isWhitelisted = whitelisted;
|
||||
this._controller.usersUpdated([user]);
|
||||
user.set("isWhitelisted", whitelisted);
|
||||
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
|
||||
}
|
||||
|
||||
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
|
||||
let name = request.name;
|
||||
let user = this._controller.userManager.getByName(name);
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
}
|
||||
|
||||
user.isDeleted = true;
|
||||
this._controller.userManager.users.delete(name);
|
||||
this._controller.usersUpdated([user]);
|
||||
this._controller.users.deleteUser(user);
|
||||
|
||||
if (user.isAdmin) {
|
||||
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
|
||||
@@ -1006,14 +999,15 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, ControllerUser>) {
|
||||
/** This does not do a full restore, rather it demotes / unbanns / unwhitelistes users */
|
||||
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, UserRecord>) {
|
||||
if (request.importType === "users") {
|
||||
// Update all fields for all users
|
||||
const users = request.users as Extract<typeof request.users, { username: string }[]>;
|
||||
const admin = new Set(users.filter(u => u.is_admin).map(u => u.username.toLowerCase()));
|
||||
const banned = new Set(users.filter(u => u.is_banned).map(u => u.username.toLowerCase()));
|
||||
const whitelist = new Set(users.filter(u => u.is_whitelisted).map(u => u.username.toLowerCase()));
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.valuesMutable()) {
|
||||
if (user.isAdmin && !admin.has(user.name)) {
|
||||
user.isAdmin = false;
|
||||
updated.set(user.id, user);
|
||||
@@ -1034,7 +1028,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const expected = new Set(
|
||||
users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase()))
|
||||
);
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.valuesMutable()) {
|
||||
if (user.isBanned && !expected.has(user.name)) {
|
||||
user.isBanned = false;
|
||||
user.banReason = "";
|
||||
@@ -1046,7 +1040,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const users = request.users as string[];
|
||||
const expected = new Set(users.map(u => u.toLowerCase()));
|
||||
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.valuesMutable()) {
|
||||
if (user[prop] && !expected.has(user.name)) {
|
||||
user[prop] = false;
|
||||
updated.set(user.id, user);
|
||||
@@ -1055,11 +1049,9 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// A large number of small if statements are unavoidable when reducing the number of updates sent
|
||||
|
||||
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
|
||||
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
|
||||
const updated = new Map<ControllerUser["id"], ControllerUser>();
|
||||
const updated = new Map<User["id"], User>();
|
||||
if (request.restore) {
|
||||
// Unban / Demote / Unwhitelist players not on the list
|
||||
backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType));
|
||||
@@ -1067,11 +1059,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
// Will get a user or attempt to create one
|
||||
// UserManager.getOrCreateUser not used due to inclusion of checkPermission
|
||||
const getUserOrCreate = (username: string) => {
|
||||
const user = this._controller.userManager.getByName(username);
|
||||
if (user) { return user; }
|
||||
const user = this._controller.users.getByNameMutable(username);
|
||||
if (user) { return updated.get(user.id) ?? user; }
|
||||
this.user.checkPermission("core.user.create");
|
||||
return this._controller.userManager.createUser(username);
|
||||
return this._controller.users.createUser(username);
|
||||
};
|
||||
|
||||
// Merge the imported data with existing data, this is strictly additive
|
||||
@@ -1129,14 +1122,14 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
// Send the necessary update events
|
||||
const updatedUsers = [...updated.values()];
|
||||
this._controller.usersUpdated(updatedUsers);
|
||||
this._controller.users.records.setMany(updatedUsers);
|
||||
|
||||
// Resync the host user lists
|
||||
const adminlist: Set<string> = new Set();
|
||||
const banlist: Map<string, string> = new Map();
|
||||
const whitelist: Set<string> = new Set();
|
||||
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
for (let user of this._controller.users.values()) {
|
||||
if (user.isAdmin) {
|
||||
adminlist.add(user.id);
|
||||
}
|
||||
@@ -1160,7 +1153,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (request.exportType === "users") {
|
||||
// Send a full user export
|
||||
const usersToSend = [] as lib.ClusterioUserExport["users"];
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.values()) {
|
||||
let send = false;
|
||||
const userToSend = { username: user.name } as typeof usersToSend[0];
|
||||
if (user.isBanned) {
|
||||
@@ -1186,7 +1179,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
} else if (request.exportType === "bans") {
|
||||
// Bans have extra logic to handle ban reason
|
||||
const usersToSend = [] as Array<string | { username: string, reason: string }>;
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.values()) {
|
||||
if (user.isBanned) {
|
||||
if (user.banReason && user.banReason !== "") {
|
||||
usersToSend.push({ username: user.name, reason: user.banReason });
|
||||
@@ -1201,7 +1194,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
// This is a whitelist or admins export
|
||||
const usersToSend = [] as string[];
|
||||
const prop = request.exportType === "admins" ? "isAdmin" : "isWhitelisted";
|
||||
for (const user of this._controller.userManager.users.values()) {
|
||||
for (const user of this._controller.users.values()) {
|
||||
if (user[prop]) {
|
||||
usersToSend.push(user.name);
|
||||
}
|
||||
|
||||
@@ -18,16 +18,17 @@ import * as lib from "@clusterio/lib";
|
||||
const { logger, Summary, Gauge } = lib;
|
||||
|
||||
import HttpCloser from "./HttpCloser";
|
||||
import InstanceInfo from "./InstanceInfo";
|
||||
import InstanceRecord from "./InstanceRecord";
|
||||
import * as metrics from "./metrics";
|
||||
import * as routes from "./routes";
|
||||
import ControllerUser from "./ControllerUser";
|
||||
import User from "./User";
|
||||
import UserRecord from "./UserRecord";
|
||||
import UserManager from "./UserManager";
|
||||
import WsServer from "./WsServer";
|
||||
import HostConnection from "./HostConnection";
|
||||
import HostInfo from "./HostInfo";
|
||||
import HostRecord from "./HostRecord";
|
||||
import BaseControllerPlugin from "./BaseControllerPlugin";
|
||||
import ControllerRouter from "./ControllerRouter";
|
||||
import InstanceManager from "./InstanceManager";
|
||||
|
||||
const endpointDurationSummary = new Summary(
|
||||
"clusterio_controller_http_endpoint_duration_seconds",
|
||||
@@ -79,6 +80,10 @@ export default class Controller {
|
||||
|
||||
/** Event subscription controller */
|
||||
subscriptions = new lib.SubscriptionController();
|
||||
/** User manager for the cluster, also contains access to the user records */
|
||||
users: UserManager;
|
||||
/** Instance manager for the cluster, also contains access to the user records */
|
||||
instances: InstanceManager;
|
||||
|
||||
// Possible states are new, starting, running, stopping, stopped
|
||||
private _state: string = "new";
|
||||
@@ -113,6 +118,12 @@ export default class Controller {
|
||||
let databaseDirectory = config.get("controller.database_directory");
|
||||
await fs.ensureDir(databaseDirectory);
|
||||
|
||||
// Migrate combined user and role file from 2.0.0-alpha.22
|
||||
await UserManager.attemptMigrateLegacyUsersFile(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
);
|
||||
|
||||
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "systems.json"),
|
||||
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
|
||||
@@ -121,13 +132,13 @@ export default class Controller {
|
||||
|
||||
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "hosts.json"),
|
||||
HostInfo.fromJSON.bind(HostInfo),
|
||||
HostRecord.fromJSON.bind(HostRecord),
|
||||
this.migrateHosts, this.finaliseHosts,
|
||||
).bootstrap());
|
||||
|
||||
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "instances.json"),
|
||||
json => InstanceInfo.fromJSON(json, "controller"),
|
||||
InstanceRecord.fromJSON.bind(InstanceRecord),
|
||||
this.migrateInstances, this.finaliseInstances,
|
||||
).bootstrap());
|
||||
|
||||
@@ -148,8 +159,11 @@ export default class Controller {
|
||||
this.migrateRoles,
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(config, roles);
|
||||
await userManager.load(path.join(databaseDirectory, "users.json"));
|
||||
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
UserRecord.fromJSON.bind(UserRecord),
|
||||
undefined, this.finaliseUsers,
|
||||
).bootstrap());
|
||||
|
||||
let modsDirectory = config.get("controller.mods_directory");
|
||||
await fs.ensureDir(modsDirectory);
|
||||
@@ -172,7 +186,7 @@ export default class Controller {
|
||||
modPacks,
|
||||
modStore,
|
||||
roles,
|
||||
userManager,
|
||||
users,
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -192,10 +206,10 @@ export default class Controller {
|
||||
*/
|
||||
public recoveryMode: boolean = false,
|
||||
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
|
||||
/** Mapping of host id to host info */
|
||||
public hosts = new lib.SubscribableDatastore<HostInfo>(),
|
||||
/** Mapping of instance id to instance info */
|
||||
public instances = new lib.SubscribableDatastore<InstanceInfo>(),
|
||||
/** Mapping of host id to host record */
|
||||
public hosts = new lib.SubscribableDatastore<HostRecord>(),
|
||||
/** Mapping of instance id to instance record */
|
||||
instances = new lib.SubscribableDatastore<InstanceRecord>(),
|
||||
/** Mapping of save id to save details */
|
||||
public saves = new lib.SubscribableDatastore<lib.SaveDetails>(),
|
||||
/** Mapping of mod pack id to mod pack */
|
||||
@@ -204,8 +218,8 @@ export default class Controller {
|
||||
public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()),
|
||||
/** Mapping of mod pack id to mod pack */
|
||||
public roles = new lib.SubscribableDatastore<lib.Role>(),
|
||||
/** User and roles manager for the cluster */
|
||||
public userManager = new UserManager(config, roles),
|
||||
/** Mapping of user id to user record */
|
||||
users = new lib.SubscribableDatastore<UserRecord>(),
|
||||
) {
|
||||
this.clusterLogger = clusterLogger;
|
||||
this.pluginInfos = pluginInfos;
|
||||
@@ -218,6 +232,9 @@ export default class Controller {
|
||||
this.trustedProxies = this.parseTrustedProxies();
|
||||
this.wsServer = new WsServer(this);
|
||||
|
||||
this.users = new UserManager(users, roles, config);
|
||||
this.instances = new InstanceManager(instances, this);
|
||||
|
||||
this.modStore.on("change", mod => {
|
||||
this.modsUpdated([mod]);
|
||||
});
|
||||
@@ -237,10 +254,11 @@ export default class Controller {
|
||||
// Handle updates for datastores
|
||||
this.systems.on("update", this.systemsUpdated.bind(this));
|
||||
this.hosts.on("update", this.hostsUpdated.bind(this));
|
||||
this.instances.on("update", this.instanceDetailsUpdated.bind(this));
|
||||
this.instances.records.on("update", this.instanceUpdated.bind(this));
|
||||
this.saves.on("update", this.savesUpdated.bind(this));
|
||||
this.modPacks.on("update", this.modPacksUpdated.bind(this));
|
||||
this.roles.on("update", this.rolesUpdated.bind(this));
|
||||
this.users.records.on("update", this.usersUpdated.bind(this));
|
||||
}
|
||||
|
||||
async start(args: ControllerArgs) {
|
||||
@@ -305,9 +323,6 @@ export default class Controller {
|
||||
}
|
||||
lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev);
|
||||
});
|
||||
for (let instance of this.instances.values()) {
|
||||
this.addInstanceHooks(instance);
|
||||
}
|
||||
|
||||
// Make sure we're actually going to listen on a port
|
||||
let httpPort = this.config.get("controller.http_port");
|
||||
@@ -643,16 +658,13 @@ export default class Controller {
|
||||
this.config.save(),
|
||||
this.systems.save(),
|
||||
this.hosts.save(),
|
||||
this.instances.save(),
|
||||
this.instances.records.save(),
|
||||
this.saves.save(),
|
||||
this.modPacks.save(),
|
||||
this.roles.save(),
|
||||
this.users.records.save(),
|
||||
]);
|
||||
|
||||
if (this.userManager.dirty) {
|
||||
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
|
||||
}
|
||||
|
||||
await lib.invokeHook(this.plugins, "onSaveData");
|
||||
}
|
||||
|
||||
@@ -675,7 +687,7 @@ export default class Controller {
|
||||
});
|
||||
}
|
||||
|
||||
static migrateHosts(rawJson: unknown[]): Static<typeof HostInfo.jsonSchema>[] {
|
||||
static migrateHosts(rawJson: unknown[]): Static<typeof HostRecord.jsonSchema>[] {
|
||||
let serialized = rawJson as any;
|
||||
|
||||
// New format 2.0.0.alpha.19
|
||||
@@ -686,7 +698,7 @@ export default class Controller {
|
||||
return serialized;
|
||||
}
|
||||
|
||||
static finaliseHosts(host: HostInfo): HostInfo {
|
||||
static finaliseHosts(host: HostRecord): HostRecord {
|
||||
if (host.connected) {
|
||||
host.connected = false;
|
||||
host.updatedAtMs = Date.now();
|
||||
@@ -694,8 +706,8 @@ export default class Controller {
|
||||
return host;
|
||||
}
|
||||
|
||||
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceInfo.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof InstanceInfo.jsonSchema>[];
|
||||
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceRecord.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof InstanceRecord.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
if (!json.config) { // New format 2.0.0.alpha.14
|
||||
return { config: json as any, status: "running" }; // Use running to force updatedAtMs
|
||||
@@ -704,7 +716,7 @@ export default class Controller {
|
||||
});
|
||||
}
|
||||
|
||||
static finaliseInstances(instance: InstanceInfo): InstanceInfo {
|
||||
static finaliseInstances(instance: InstanceRecord): InstanceRecord {
|
||||
const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown";
|
||||
if (instance.status !== status) {
|
||||
instance.status = status;
|
||||
@@ -738,6 +750,20 @@ export default class Controller {
|
||||
});
|
||||
}
|
||||
|
||||
static finaliseUsers(user: UserRecord, users: Map<UserRecord["id"], UserRecord>): UserRecord {
|
||||
const existingUser = users.get(user.id);
|
||||
if (existingUser) {
|
||||
// All lowercase ids in 2.0.0.alpha.19
|
||||
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
|
||||
// We assume the user with the lower online time is the duplicate
|
||||
existingUser.merge(user);
|
||||
return existingUser;
|
||||
}
|
||||
user.merge(existingUser);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
|
||||
let manifest = {};
|
||||
try {
|
||||
@@ -856,7 +882,7 @@ export default class Controller {
|
||||
);
|
||||
}
|
||||
|
||||
hostsUpdated(hosts: HostInfo[]) {
|
||||
hostsUpdated(hosts: HostRecord[]) {
|
||||
let updates = hosts.map(host => host.toHostDetails());
|
||||
this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates));
|
||||
}
|
||||
@@ -868,75 +894,6 @@ export default class Controller {
|
||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instance by ID for a request
|
||||
*
|
||||
* @param instanceId - ID of instance to get.
|
||||
* @returns Info for the given instance if it exists
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
*/
|
||||
getRequestInstance(instanceId:number): InstanceInfo {
|
||||
let instance = this.instances.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance
|
||||
*
|
||||
* Adds common Factorio settings to the provided instance config then
|
||||
* creates an instance using that config in the cluster.
|
||||
*
|
||||
* @example
|
||||
* let instanceConfig = new lib.InstanceConfig("controller");
|
||||
* instanceConfig.set("instance.name", "My instance");
|
||||
* let instance = await controller.instanceAssign(instanceConfig);
|
||||
* await controller.instanceAssign(instance.id, hostId);
|
||||
*
|
||||
* @param instanceConfig -
|
||||
* Config to base newly created instance on.
|
||||
* @returns The created instance
|
||||
*/
|
||||
async instanceCreate(instanceConfig: lib.InstanceConfig): Promise<InstanceInfo> {
|
||||
let instanceId = instanceConfig.get("instance.id");
|
||||
if (this.instances.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
|
||||
}
|
||||
|
||||
// Add common settings for the Factorio server
|
||||
let settings = {
|
||||
"name": `${this.config.get("controller.name")} - ${instanceConfig.get("instance.name")}`,
|
||||
"description": `Clusterio instance for ${this.config.get("controller.name")}`,
|
||||
"tags": ["clusterio"],
|
||||
"max_players": 0,
|
||||
"visibility": { "public": true, "lan": true },
|
||||
"game_password": "",
|
||||
"require_user_verification": true,
|
||||
"max_upload_in_kilobytes_per_second": 0,
|
||||
"max_upload_slots": 5,
|
||||
"ignore_player_limit_for_returning_players": false,
|
||||
"allow_commands": "admins-only",
|
||||
"autosave_interval": 10,
|
||||
"autosave_slots": 5,
|
||||
"afk_autokick_interval": 0,
|
||||
"auto_pause": false,
|
||||
"only_admins_can_pause_the_game": true,
|
||||
"autosave_only_on_server": true,
|
||||
"non_blocking_saving": false,
|
||||
|
||||
...instanceConfig.get("factorio.settings"),
|
||||
};
|
||||
instanceConfig.set("factorio.settings", settings);
|
||||
|
||||
let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, undefined, Date.now());
|
||||
this.instances.set(instance);
|
||||
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance);
|
||||
this.addInstanceHooks(instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all saves currently stored for the given instance, if any
|
||||
* @param instanceId - Id of Instance to clear saves for.
|
||||
@@ -946,84 +903,6 @@ export default class Controller {
|
||||
this.saves.deleteMany([...this.saves.values()].filter(save => save.instanceId === instanceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Change assigned host of an instance
|
||||
*
|
||||
* Unassigns instance from existing host if already assigned to one and
|
||||
* then assigns it to the given host. This is the only supported way of
|
||||
* changing the instance.assigned_host config entry.
|
||||
*
|
||||
* Note: this will not transfer any files or saves stored on the host
|
||||
* the instance was previously assgined to the new one.
|
||||
*
|
||||
* @param instanceId - ID of Instance to assign.
|
||||
* @param hostId - ID of host to assign instance to.
|
||||
*/
|
||||
async instanceAssign(instanceId: number, hostId?: number) {
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
|
||||
// Check if target host is connected
|
||||
let newHostConnection: HostConnection | undefined;
|
||||
if (hostId !== undefined) {
|
||||
newHostConnection = this.wsServer.hostConnections.get(hostId);
|
||||
if (!newHostConnection) {
|
||||
// The case of the host not getting the assign instance message
|
||||
// still have to be handled, so it's not a requirement that the
|
||||
// target host be connected to the controller while doing the
|
||||
// assignment, but it is IMHO a better user experience if this
|
||||
// is the case.
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
}
|
||||
}
|
||||
|
||||
// Unassign from currently assigned host if it is connected.
|
||||
let currentAssignedHost = instance.config.get("instance.assigned_host");
|
||||
if (currentAssignedHost !== null && hostId !== currentAssignedHost) {
|
||||
let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost);
|
||||
if (oldHostConnection && !oldHostConnection.connector.closing) {
|
||||
await oldHostConnection.send(new lib.InstanceUnassignInternalRequest(instanceId));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove saves recorded from currently assigned host if any
|
||||
this.clearSavesOfInstance(instanceId);
|
||||
|
||||
// Assign to target
|
||||
instance.config.set("instance.assigned_host", hostId ?? null);
|
||||
if (hostId !== undefined && newHostConnection) {
|
||||
await newHostConnection.send(
|
||||
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host"))
|
||||
);
|
||||
} else {
|
||||
instance.status = "unassigned";
|
||||
}
|
||||
this.instances.set(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an instance
|
||||
*
|
||||
* Permanently deletes the instance from its assigned host (if assigned)
|
||||
* and the controller. This action cannot be undone.
|
||||
*
|
||||
* @param instanceId - ID of instance to delete.
|
||||
*/
|
||||
async instanceDelete(instanceId: number) {
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId !== null) {
|
||||
await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId));
|
||||
}
|
||||
|
||||
let prev = instance.status;
|
||||
this.instances.delete(instance);
|
||||
|
||||
this.clearSavesOfInstance(instanceId);
|
||||
this.userManager.clearStatsOfInstance(instanceId);
|
||||
|
||||
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the config of an instance updated
|
||||
*
|
||||
@@ -1033,7 +912,7 @@ export default class Controller {
|
||||
* @param instance -
|
||||
* Instance to nodify the config updated.
|
||||
*/
|
||||
async instanceConfigUpdated(instance: InstanceInfo) {
|
||||
async instanceConfigUpdated(instance: InstanceRecord) {
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId !== null) {
|
||||
let connection = this.wsServer.hostConnections.get(hostId);
|
||||
@@ -1045,16 +924,7 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
addInstanceHooks(instance: InstanceInfo) {
|
||||
instance.config.on("fieldChanged", (field: string, curr: any, prev: any) => {
|
||||
instance.updatedAtMs = Date.now();
|
||||
this.instances.set(instance); // Trigger update logic
|
||||
|
||||
lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, field, curr, prev);
|
||||
});
|
||||
}
|
||||
|
||||
instanceDetailsUpdated(instances: InstanceInfo[]) {
|
||||
instanceUpdated(instances: InstanceRecord[]) {
|
||||
for (const instance of instances) {
|
||||
if (instance.status === "stopped") {
|
||||
this.subscriptions.unsubscribeAddress(lib.Address.fromShorthand({instanceId: instance.id}));
|
||||
@@ -1106,17 +976,12 @@ export default class Controller {
|
||||
return mods.length ? new lib.ModUpdatesEvent(mods) : null;
|
||||
}
|
||||
|
||||
usersUpdated(users: ControllerUser[]) {
|
||||
const now = Date.now();
|
||||
for (const user of users) {
|
||||
user.updatedAtMs = now;
|
||||
}
|
||||
this.userManager.dirty = true;
|
||||
usersUpdated(users: UserRecord[]) {
|
||||
this.subscriptions.broadcast(new lib.UserUpdatesEvent(users));
|
||||
}
|
||||
|
||||
async handleUserSubscription(request: lib.SubscriptionRequest) {
|
||||
const users = [...this.userManager.users.values()].filter(
|
||||
const users = [...this.users.records.values()].filter(
|
||||
user => user.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return users.length ? new lib.UserUpdatesEvent(users) : null;
|
||||
@@ -1127,7 +992,7 @@ export default class Controller {
|
||||
* permissions for this user may have changed.
|
||||
* @param user - User permisions updated for.
|
||||
*/
|
||||
userPermissionsUpdated(user: ControllerUser) {
|
||||
userPermissionsUpdated(user: Readonly<User>) {
|
||||
for (let controlConnection of this.wsServer.controlConnections.values()) {
|
||||
if (controlConnection.user === user) {
|
||||
controlConnection.send(
|
||||
@@ -1386,7 +1251,7 @@ export default class Controller {
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.instance) {
|
||||
let instance = this.getRequestInstance(dst.id);
|
||||
const instance = this.instances.getForRequest(dst.id);
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null) {
|
||||
throw new lib.RequestError("Instance is not assigned to a host");
|
||||
@@ -1421,7 +1286,7 @@ export default class Controller {
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.instance) {
|
||||
let instance = this.instances.get(dst.id);
|
||||
const instance = this.instances.get(dst.id);
|
||||
if (!instance) {
|
||||
throw new Error(`Instance with ID ${dst.id} does not exist`);
|
||||
}
|
||||
@@ -1480,7 +1345,7 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async sendRequestToHostByInstanceId<Req extends InstanceId, Res>(request: Req & lib.Request<Req, Res>) {
|
||||
let instance = this.getRequestInstance(request.instanceId);
|
||||
let instance = this.instances.getForRequest(request.instanceId);
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null) {
|
||||
throw new lib.RequestError("Instance is not assigned to a host");
|
||||
@@ -1493,7 +1358,7 @@ export default class Controller {
|
||||
}
|
||||
|
||||
sendEventToHostByInstanceId<T extends { instanceId: number }>(event: T & lib.Event<T>) {
|
||||
let instance = this.getRequestInstance(event.instanceId);
|
||||
let instance = this.instances.getForRequest(event.instanceId);
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null) {
|
||||
return;
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
import {
|
||||
IControllerUser, PermissionError, PlayerStats,
|
||||
Role, SubscribableDatastore, User, permissions,
|
||||
} from "@clusterio/lib";
|
||||
|
||||
export default class ControllerUser extends User implements IControllerUser {
|
||||
constructor(
|
||||
private _controllerRoles: SubscribableDatastore<Role>,
|
||||
/** Unix time in seconds the user token must be issued after to be valid. */
|
||||
public tokenValidAfter = 0,
|
||||
...args: ConstructorParameters<typeof User>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
...User.jsonSchema.properties,
|
||||
token_valid_after: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, controllerRoles: SubscribableDatastore<Role>) {
|
||||
const roleIds = new Set<number>(json.roles ?? []);
|
||||
const instanceStats = new Map(
|
||||
(json.instance_stats ?? []).map(
|
||||
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
|
||||
)
|
||||
);
|
||||
const playerStats = User._calculatePlayerStats(instanceStats);
|
||||
return new this(
|
||||
controllerRoles,
|
||||
json.token_valid_after,
|
||||
json.name,
|
||||
roleIds,
|
||||
new Set(json.instances),
|
||||
json.is_admin,
|
||||
json.is_banned,
|
||||
json.is_whitelisted,
|
||||
json.ban_reason,
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
playerStats,
|
||||
instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
|
||||
if (!controller) {
|
||||
return super.toJSON();
|
||||
}
|
||||
|
||||
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
|
||||
if (this.tokenValidAfter) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
get roles(): ReadonlySet<Readonly<Role>> {
|
||||
return new Set([...this.roleIds].map(
|
||||
id => this._controllerRoles.get(id)
|
||||
).filter(
|
||||
(r): r is Role => Boolean(r)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate current tokens for the user
|
||||
*
|
||||
* Sets the tokenValidAfter property to the current time, which causes
|
||||
* all currently issued tokens for the user to become invalid.
|
||||
*/
|
||||
invalidateToken() {
|
||||
this.tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
checkPermission(permission: string) {
|
||||
if (!permissions.has(permission)) {
|
||||
throw new Error(`permission ${permission} does not exist`);
|
||||
}
|
||||
|
||||
for (let roleId of this.roleIds) {
|
||||
let role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PermissionError("Permission denied");
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
import BaseConnection from "./BaseConnection";
|
||||
import InstanceInfo from "./InstanceInfo";
|
||||
import HostInfo from "./HostInfo";
|
||||
import InstanceRecord from "./InstanceRecord";
|
||||
import HostRecord from "./HostRecord";
|
||||
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ import HostInfo from "./HostInfo";
|
||||
* @alias module:controller/src/HostConnection
|
||||
*/
|
||||
export default class HostConnection extends BaseConnection {
|
||||
private info: HostInfo;
|
||||
private info: HostRecord;
|
||||
plugins: Map<string, string>;
|
||||
|
||||
constructor(
|
||||
@@ -30,7 +30,7 @@ export default class HostConnection extends BaseConnection {
|
||||
this.plugins = new Map(Object.entries(registerData.plugins));
|
||||
|
||||
const previousHostInfo = this._controller.hosts.get(registerData.id);
|
||||
this.info = new HostInfo(
|
||||
this.info = new HostRecord(
|
||||
registerData.id,
|
||||
previousHostInfo?.name ?? "",
|
||||
registerData.version,
|
||||
@@ -57,7 +57,7 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
this.connector.on("close", () => {
|
||||
// Update status to unknown for instances on this host.
|
||||
const instances: InstanceInfo[] = [];
|
||||
const instances: InstanceRecord[] = [];
|
||||
for (let instance of this._controller.instances.valuesMutable()) {
|
||||
if (instance.config.get("instance.assigned_host") !== this.id) {
|
||||
continue;
|
||||
@@ -68,7 +68,7 @@ export default class HostConnection extends BaseConnection {
|
||||
instance.status = "unknown";
|
||||
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
this._controller.instances.setMany(instances);
|
||||
this._controller.instances.records.setMany(instances);
|
||||
|
||||
this.info.connected = false;
|
||||
this._controller.hosts.set(this.info);
|
||||
@@ -215,7 +215,7 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
instance.gamePort = request.gamePort;
|
||||
instance.factorioVersion = request.factorioVersion;
|
||||
this._controller.instances.set(instance);
|
||||
this._controller.instances.records.set(instance);
|
||||
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export default class HostConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
// Assign instances the host has but controller does not
|
||||
const instanceUpdates: InstanceInfo[] = [];
|
||||
const instanceUpdates: InstanceRecord[] = [];
|
||||
for (let instanceData of request.instances) {
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
instanceConfig.update(instanceData.config, false, "host");
|
||||
@@ -265,32 +265,30 @@ export default class HostConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
instanceConfig.set("instance.assigned_host", this.id);
|
||||
let newInstance = new InstanceInfo(
|
||||
instanceConfig,
|
||||
instanceData.status,
|
||||
instanceData.gamePort,
|
||||
instanceData.factorioVersion,
|
||||
0,
|
||||
Date.now(),
|
||||
);
|
||||
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
|
||||
newInstance.factorioVersion = instanceData.factorioVersion;
|
||||
newInstance.gamePort = instanceData.gamePort;
|
||||
newInstance.status = instanceData.status;
|
||||
instanceUpdates.push(newInstance);
|
||||
this._controller.addInstanceHooks(newInstance);
|
||||
await this.send(
|
||||
new lib.InstanceAssignInternalRequest(
|
||||
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
|
||||
)
|
||||
);
|
||||
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
|
||||
}
|
||||
|
||||
this._controller.instances.setMany(instanceUpdates);
|
||||
// We suppress the changes made by createInstance so we must manually make them here
|
||||
this._controller.instances.records.setMany(instanceUpdates);
|
||||
await Promise.all(instanceUpdates.flatMap(newInstance => [
|
||||
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance),
|
||||
this.send(
|
||||
new lib.InstanceAssignInternalRequest(
|
||||
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
|
||||
)
|
||||
),
|
||||
]));
|
||||
|
||||
// Push lists to make sure they are in sync.
|
||||
let adminlist: Set<string> = new Set();
|
||||
let banlist: Map<string, string> = new Map();
|
||||
let whitelist: Set<string> = new Set();
|
||||
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
for (let user of this._controller.users.values()) {
|
||||
if (user.isAdmin) {
|
||||
adminlist.add(user.id);
|
||||
}
|
||||
@@ -339,21 +337,18 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
|
||||
let instanceId = src.id;
|
||||
let user = this._controller.userManager.getByName(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
|
||||
if (event.type === "join") {
|
||||
this._controller.userManager.notifyJoin(user, instanceId);
|
||||
user.notifyJoin(instanceId);
|
||||
} else if (event.type === "leave") {
|
||||
this._controller.userManager.notifyLeave(user, instanceId);
|
||||
user.notifyLeave(instanceId);
|
||||
}
|
||||
|
||||
user.instanceStats.set(instanceId, event.stats);
|
||||
|
||||
user.recalculatePlayerStats();
|
||||
this._controller.usersUpdated([user]);
|
||||
user.saveRecord();
|
||||
|
||||
let instance = this._controller.instances.get(instanceId)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
@@ -373,13 +368,9 @@ export default class HostConnection extends BaseConnection {
|
||||
return;
|
||||
}
|
||||
|
||||
let user = this._controller.userManager.getByName(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.set("isAdmin", event.admin);
|
||||
|
||||
user.isAdmin = event.admin;
|
||||
this._controller.usersUpdated([user]);
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
|
||||
@@ -393,14 +384,11 @@ export default class HostConnection extends BaseConnection {
|
||||
return;
|
||||
}
|
||||
|
||||
let user = this._controller.userManager.getByName(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.isBanned = event.banned;
|
||||
user.banReason = event.reason;
|
||||
this._controller.usersUpdated([user]);
|
||||
user.saveRecord();
|
||||
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
|
||||
@@ -415,13 +403,9 @@ export default class HostConnection extends BaseConnection {
|
||||
return;
|
||||
}
|
||||
|
||||
let user = this._controller.userManager.getByName(event.name);
|
||||
if (!user) {
|
||||
user = this._controller.userManager.createUser(event.name);
|
||||
}
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.set("isWhitelisted", event.whitelisted);
|
||||
|
||||
user.isWhitelisted = event.whitelisted;
|
||||
this._controller.usersUpdated([user]);
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { HostDetails } from "@clusterio/lib";
|
||||
|
||||
/**
|
||||
* Info about a host known to the controller.
|
||||
*/
|
||||
export default class HostInfo {
|
||||
/** Underlying data class for hosts on the controller */
|
||||
export default class HostRecord {
|
||||
constructor(
|
||||
/** Id of this host */
|
||||
public id: number,
|
||||
@@ -33,12 +31,12 @@ export default class HostInfo {
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"plugins": Type.Record(Type.String(), Type.String()),
|
||||
"connected": Type.Boolean(),
|
||||
"remote_address": Type.String(),
|
||||
"public_address": Type.String(),
|
||||
"token_valid_after": Type.Number(),
|
||||
"updated_at_ms": Type.Number(),
|
||||
"is_deleted": Type.Boolean(),
|
||||
"connected": Type.Optional(Type.Boolean()),
|
||||
"remote_address": Type.Optional(Type.String()),
|
||||
"public_address": Type.Optional(Type.String()),
|
||||
"token_valid_after": Type.Optional(Type.Number()),
|
||||
"updated_at_ms": Type.Optional(Type.Number()),
|
||||
"is_deleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -56,19 +54,50 @@ export default class HostInfo {
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): Static<typeof HostInfo.jsonSchema> {
|
||||
return {
|
||||
toJSON() {
|
||||
const json = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
version: this.version,
|
||||
plugins: Object.fromEntries(this.plugins),
|
||||
connected: this.connected,
|
||||
remote_address: this.remoteAddress,
|
||||
public_address: this.publicAddress,
|
||||
token_valid_after: this.tokenValidAfter,
|
||||
updated_at_ms: this.updatedAtMs,
|
||||
is_deleted: this.isDeleted,
|
||||
};
|
||||
} as Static<typeof HostRecord.jsonSchema>;
|
||||
|
||||
if (this.connected !== false) {
|
||||
json.connected = this.connected;
|
||||
}
|
||||
if (this.remoteAddress !== "") {
|
||||
json.remote_address = this.remoteAddress;
|
||||
}
|
||||
if (this.publicAddress !== "") {
|
||||
json.public_address = this.publicAddress;
|
||||
}
|
||||
if (this.tokenValidAfter !== 0) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updated_at_ms = this.updatedAtMs;
|
||||
}
|
||||
if (this.isDeleted !== false) {
|
||||
json.is_deleted = this.isDeleted;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
static fromHostDetails(details: HostDetails, plugins: Map<string, string>) {
|
||||
return new HostRecord(
|
||||
details.id,
|
||||
details.name,
|
||||
details.version,
|
||||
plugins,
|
||||
details.connected,
|
||||
details.remoteAddress,
|
||||
details.publicAddress,
|
||||
details.tokenValidAfter,
|
||||
details.updatedAtMs,
|
||||
details.isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
toHostDetails() {
|
||||
@@ -85,4 +114,3 @@ export default class HostInfo {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller";
|
||||
import type HostConnection from "./HostConnection";
|
||||
import InstanceRecord from "./InstanceRecord";
|
||||
|
||||
export default class InstanceManager {
|
||||
private static readonly DefaultFactorioSettings = {
|
||||
tags: ["clusterio"],
|
||||
max_players: 0,
|
||||
visibility: { public: true, lan: true },
|
||||
game_password: "",
|
||||
require_user_verification: true,
|
||||
max_upload_in_kilobytes_per_second: 0,
|
||||
max_upload_slots: 5,
|
||||
ignore_player_limit_for_returning_players: false,
|
||||
allow_commands: "admins-only",
|
||||
autosave_interval: 10,
|
||||
autosave_slots: 5,
|
||||
afk_autokick_interval: 0,
|
||||
auto_pause: false,
|
||||
only_admins_can_pause_the_game: true,
|
||||
autosave_only_on_server: true,
|
||||
non_blocking_saving: false,
|
||||
} as const;
|
||||
|
||||
constructor(
|
||||
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
|
||||
private readonly _controller: Controller,
|
||||
) {
|
||||
for (const instance of records.values()) {
|
||||
this.addInstanceHooks(instance);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Basic Accessors */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
has(id: InstanceRecord["id"]): boolean {
|
||||
return this.records.has(id);
|
||||
}
|
||||
|
||||
getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined {
|
||||
return this.records.get(id);
|
||||
}
|
||||
|
||||
get(id: InstanceRecord["id"]): Readonly<InstanceRecord> | undefined {
|
||||
return this.getMutable(id);
|
||||
}
|
||||
|
||||
valuesMutable(): IterableIterator<InstanceRecord> {
|
||||
return this.records.values();
|
||||
}
|
||||
|
||||
values(): IterableIterator<Readonly<InstanceRecord>> {
|
||||
return this.valuesMutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instance by ID for a request.
|
||||
*
|
||||
* Convenience wrapper around {@link getMutable} that throws
|
||||
* if the instance does not exist.
|
||||
*
|
||||
* @param instanceId - ID of instance to get.
|
||||
* @returns The instance record.
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
*/
|
||||
getForRequest(instanceId: number): InstanceRecord {
|
||||
const instance = this.records.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Internal Helpers */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
private async _notifyStatusChanged(
|
||||
instance: InstanceRecord,
|
||||
prev?: lib.InstanceStatus,
|
||||
): Promise<void> {
|
||||
await lib.invokeHook(
|
||||
this._controller.plugins,
|
||||
"onInstanceStatusChanged",
|
||||
instance,
|
||||
prev,
|
||||
);
|
||||
}
|
||||
|
||||
private async _notifyConfigFieldChanged(
|
||||
instance: InstanceRecord,
|
||||
field: string,
|
||||
curr: unknown,
|
||||
prev: unknown,
|
||||
): Promise<void> {
|
||||
await lib.invokeHook(
|
||||
this._controller.plugins,
|
||||
"onInstanceConfigFieldChanged",
|
||||
instance,
|
||||
field,
|
||||
curr,
|
||||
prev,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach change listeners to an instance record.
|
||||
*
|
||||
* Hooks into configuration field changes and ensures the datastore
|
||||
* is updated and plugins are notified when a config field changes.
|
||||
*
|
||||
* @param instance - The instance record to attach hooks to.
|
||||
* @internal
|
||||
*/
|
||||
addInstanceHooks(instance: InstanceRecord): void {
|
||||
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
|
||||
this.records.set(instance);
|
||||
this._notifyConfigFieldChanged(instance, field, curr, prev);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @example
|
||||
* const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" });
|
||||
* const instance = await controller.instances.createInstance(config);
|
||||
*
|
||||
* @param instanceConfig - Config to base the newly created instance on.
|
||||
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
|
||||
* @returns The created instance record.
|
||||
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
|
||||
*/
|
||||
async createInstance(
|
||||
instanceConfig: lib.InstanceConfig,
|
||||
_suppressChanges?: boolean,
|
||||
): Promise<InstanceRecord> {
|
||||
const instanceId = instanceConfig.get("instance.id");
|
||||
if (this.records.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
|
||||
}
|
||||
|
||||
const controllerName = this._controller.config.get("controller.name");
|
||||
instanceConfig.set("factorio.settings", {
|
||||
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
|
||||
description: `Clusterio instance for ${controllerName}`,
|
||||
...InstanceManager.DefaultFactorioSettings,
|
||||
...instanceConfig.get("factorio.settings"),
|
||||
});
|
||||
|
||||
const instance = new InstanceRecord(instanceConfig, "unassigned");
|
||||
this.addInstanceHooks(instance);
|
||||
|
||||
if (!_suppressChanges) {
|
||||
this.records.set(instance);
|
||||
await this._notifyStatusChanged(instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the assigned host of an instance.
|
||||
*
|
||||
* Unassigns the instance from the currently assigned host (if connected)
|
||||
* and assigns it to the provided host.
|
||||
*
|
||||
* This is the only supported way of modifying the
|
||||
* `instance.assigned_host` configuration entry.
|
||||
*
|
||||
* Note:
|
||||
* - This does NOT transfer any files or saves between hosts.
|
||||
* - If the instance is already assigned to the specified host, no action is taken.
|
||||
*
|
||||
* @param instanceId - ID of instance to assign.
|
||||
* @param hostId - ID of host to assign the instance to. If undefined,
|
||||
* the instance will be unassigned.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.RequestError} if the target host is not connected.
|
||||
*/
|
||||
async assignInstance(
|
||||
instanceId: number,
|
||||
hostId: number | null,
|
||||
): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
const hostConnections = this._controller.wsServer.hostConnections;
|
||||
|
||||
const currentAssignedHost = instance.config.get("instance.assigned_host");
|
||||
if (currentAssignedHost === hostId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newHostConnection: HostConnection | undefined;
|
||||
if (hostId !== null) {
|
||||
newHostConnection = hostConnections.get(hostId);
|
||||
if (!newHostConnection) {
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
}
|
||||
}
|
||||
|
||||
if (currentAssignedHost !== null) {
|
||||
const oldHostConnection = hostConnections.get(currentAssignedHost);
|
||||
if (oldHostConnection && !oldHostConnection.connector.closing) {
|
||||
await oldHostConnection.send(
|
||||
new lib.InstanceUnassignInternalRequest(instanceId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.clearSavesOfInstance(instanceId);
|
||||
instance.config.set("instance.assigned_host", hostId);
|
||||
|
||||
if (newHostConnection) {
|
||||
await newHostConnection.send(
|
||||
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
|
||||
);
|
||||
} else {
|
||||
instance.status = "unassigned";
|
||||
}
|
||||
|
||||
this.records.set(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign an instance from its current host.
|
||||
*
|
||||
* Convenience wrapper around {@link assignInstance} that removes
|
||||
* the assigned host.
|
||||
*
|
||||
* @param instanceId - ID of instance to unassign.
|
||||
*/
|
||||
async unassignInstance(instanceId: number): Promise<void> {
|
||||
return this.assignInstance(instanceId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an instance permanently.
|
||||
*
|
||||
* Sends a delete request to the assigned host (if any),
|
||||
* removes the instance from the datastore,
|
||||
* clears associated saves and user statistics.
|
||||
*
|
||||
* @param instanceId - ID of instance to delete.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
*/
|
||||
async deleteInstance(instanceId: number): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
|
||||
if (hostId !== null) {
|
||||
await this._controller.sendTo(
|
||||
{ hostId },
|
||||
new lib.InstanceDeleteInternalRequest(instanceId),
|
||||
);
|
||||
}
|
||||
|
||||
const prevStatus = instance.status;
|
||||
this.records.delete(instance);
|
||||
this._controller.clearSavesOfInstance(instanceId);
|
||||
this._controller.users.clearStatsOfInstance(instanceId);
|
||||
|
||||
await this._notifyStatusChanged(instance, prevStatus);
|
||||
}
|
||||
}
|
||||
+50
-22
@@ -1,12 +1,8 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
|
||||
/**
|
||||
* Runtime status of an instance on the controller
|
||||
* @alias module:controller/src/InstanceInfo
|
||||
*/
|
||||
export default class InstanceInfo {
|
||||
/** Underlying data class for instances on the controller */
|
||||
export default class InstanceRecord {
|
||||
constructor(
|
||||
public config: lib.InstanceConfig,
|
||||
public status: lib.InstanceStatus,
|
||||
@@ -19,6 +15,21 @@ export default class InstanceInfo {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
return this.config.get("instance.id");
|
||||
}
|
||||
|
||||
get isDeleted() {
|
||||
return this.status === "deleted";
|
||||
}
|
||||
|
||||
set isDeleted(value: boolean) {
|
||||
if (value === false) {
|
||||
throw new Error("Setting isDeleted to false is not supported, use status instead.");
|
||||
}
|
||||
this.status = "deleted";
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"config": lib.InstanceConfig.jsonSchema,
|
||||
"status": lib.InstanceStatus,
|
||||
@@ -28,9 +39,9 @@ export default class InstanceInfo {
|
||||
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
lib.InstanceConfig.fromJSON(json.config, location),
|
||||
lib.InstanceConfig.fromJSON(json.config, "controller"),
|
||||
json.status,
|
||||
json.gamePort,
|
||||
json.factorioVersion,
|
||||
@@ -39,6 +50,28 @@ export default class InstanceInfo {
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): Static<typeof InstanceRecord.jsonSchema> {
|
||||
const json: any = {
|
||||
config: this.config.toJSON(),
|
||||
status: this.status,
|
||||
};
|
||||
|
||||
if (this.gamePort !== undefined) {
|
||||
json.gamePort = this.gamePort;
|
||||
}
|
||||
if (this.factorioVersion !== undefined) {
|
||||
json.factorioVersion = this.factorioVersion;
|
||||
}
|
||||
if (this.startedAtMs !== 0) {
|
||||
json.startedAtMs = this.startedAtMs;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updatedAtMs = this.updatedAtMs;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
toInstanceDetails() {
|
||||
return new lib.InstanceDetails(
|
||||
this.config.get("instance.name"),
|
||||
@@ -53,19 +86,14 @@ export default class InstanceInfo {
|
||||
);
|
||||
}
|
||||
|
||||
get isDeleted() {
|
||||
return this.status === "deleted";
|
||||
}
|
||||
|
||||
set isDeleted(value: boolean) {
|
||||
if (value === false) {
|
||||
throw new Error("Setting isDeleted to false is not supported, use status instead.");
|
||||
}
|
||||
this.status = "deleted";
|
||||
}
|
||||
|
||||
/** Shorthand for `instance.config.get("instance.id")` */
|
||||
get id():number {
|
||||
return this.config.get("instance.id");
|
||||
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
|
||||
return new InstanceRecord(
|
||||
config,
|
||||
details.status,
|
||||
details.gamePort,
|
||||
details.factorioVersion,
|
||||
details.startedAtMs,
|
||||
details.updatedAtMs,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
|
||||
import {
|
||||
IUser, Role, SubscribableDatastore,
|
||||
PermissionError, permissions,
|
||||
} from "@clusterio/lib";
|
||||
|
||||
import UserRecord from "./UserRecord";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* Represents a user as viewed from the controller.
|
||||
*
|
||||
* All methods are safe to use when readonly.
|
||||
* Only use mutable when needing to change multiple values.
|
||||
*/
|
||||
export default class User extends UserRecord implements IUser {
|
||||
constructor(
|
||||
private _controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
private _controllerRoles: SubscribableDatastore<Role>,
|
||||
...args: ConstructorParameters<typeof UserRecord>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static fromJSON(
|
||||
json: Static<typeof this.jsonSchema>,
|
||||
_controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
_controllerRoles: SubscribableDatastore<Role>,
|
||||
) {
|
||||
return this.fromUserRecord(
|
||||
UserRecord.fromJSON(json),
|
||||
_controllerUsers,
|
||||
_controllerRoles,
|
||||
);
|
||||
}
|
||||
|
||||
static fromUserRecord(
|
||||
userRecord: UserRecord,
|
||||
_controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
_controllerRoles: SubscribableDatastore<Role>,
|
||||
): User {
|
||||
return new this(
|
||||
_controllerUsers,
|
||||
_controllerRoles,
|
||||
userRecord.tokenValidAfter,
|
||||
userRecord.name,
|
||||
userRecord.roleIds,
|
||||
userRecord.instances,
|
||||
userRecord.isAdmin,
|
||||
userRecord.isBanned,
|
||||
userRecord.isWhitelisted,
|
||||
userRecord.banReason,
|
||||
userRecord.updatedAtMs,
|
||||
userRecord.isDeleted,
|
||||
userRecord.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
/** Roles this user has */
|
||||
get roles(): ReadonlySet<Readonly<Role>> {
|
||||
return new Set([...this.roleIds].map(
|
||||
id => this._controllerRoles.get(id)
|
||||
).filter(
|
||||
(r): r is Role => Boolean(r)
|
||||
));
|
||||
}
|
||||
|
||||
/** Save the underlying user record */
|
||||
saveRecord() {
|
||||
this._controllerUsers.set(this);
|
||||
}
|
||||
|
||||
/** Set a property on the underlying record, then save it. */
|
||||
set<K extends keyof this>(key: K, value: this[K]) {
|
||||
this[key] = value;
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate current tokens for the user
|
||||
*
|
||||
* Sets the tokenValidAfter property to the current time, which causes
|
||||
* all currently issued tokens for the user to become invalid.
|
||||
*/
|
||||
invalidateToken() {
|
||||
this.tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Add a role to this user */
|
||||
addRole(roleId: number) {
|
||||
const role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`role ${roleId} does not exist`);
|
||||
}
|
||||
this.roleIds.add(roleId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Remove a role from user */
|
||||
removeRole(roleId: number) {
|
||||
if (!this.roleIds.delete(roleId)) {
|
||||
return false;
|
||||
}
|
||||
this.saveRecord();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Set a user as being online on a specific instance */
|
||||
notifyJoin(instanceId: number) {
|
||||
this.instances.add(instanceId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Set a user as being offline on a specific instance */
|
||||
notifyLeave(instanceId: number) {
|
||||
this.instances.delete(instanceId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Clear the stats of a player on a specific instance */
|
||||
clearInstanceStats(instanceId: number) {
|
||||
this.instances.delete(instanceId);
|
||||
this.instanceStats.delete(instanceId);
|
||||
this.recalculatePlayerStats();
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
checkPermission(permission: string) {
|
||||
if (!permissions.has(permission)) {
|
||||
throw new Error(`permission ${permission} does not exist`);
|
||||
}
|
||||
|
||||
for (const roleId of this.roleIds) {
|
||||
const role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PermissionError("Permission denied");
|
||||
}
|
||||
}
|
||||
@@ -3,65 +3,45 @@ import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControllerUser from "./ControllerUser";
|
||||
import User from "./User";
|
||||
import UserRecord from "./UserRecord";
|
||||
|
||||
/**
|
||||
* Manages users and roles
|
||||
* Access layer for users on the controller.
|
||||
*
|
||||
* In performance critical paths the raw records can be accessed directly.
|
||||
* Otherwise this class will wrap data in a view class with a set and save method.
|
||||
*
|
||||
* @alias module:controller/src/UserManager
|
||||
*/
|
||||
export default class UserManager {
|
||||
users: Map<ControllerUser["id"], ControllerUser> = new Map();
|
||||
dirty = false;
|
||||
|
||||
/**
|
||||
* Set of users currently online in the cluster.
|
||||
*/
|
||||
onlineUsers = new Set<ControllerUser>();
|
||||
|
||||
constructor(
|
||||
private _config: lib.ControllerConfig,
|
||||
private _roles: lib.SubscribableDatastore<lib.Role>,
|
||||
public records: lib.SubscribableDatastore<UserRecord>,
|
||||
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
|
||||
private _controllerConfig: lib.ControllerConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
getByName(name: string) {
|
||||
return this.users.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
async load(filePath: string): Promise<void> {
|
||||
/**
|
||||
* Prior to 2.0.0-alpha.22 users and roles were stored in a single file.
|
||||
* This migration separates them so they can be loaded by datastore.
|
||||
*
|
||||
* @param usersFilePath The src and dst file path for the users json file
|
||||
* @param rolesFilePath The dst for the roles json file
|
||||
*/
|
||||
static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) {
|
||||
try {
|
||||
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
|
||||
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
|
||||
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
|
||||
await this._roles.save(); // Force a save to prevent data loss
|
||||
content = content.users;
|
||||
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
|
||||
if (!("roles" in combinedJson)) {
|
||||
return; // Files are already split, no action required
|
||||
}
|
||||
|
||||
let duplicates = 0;
|
||||
for (const serializedUser of content) {
|
||||
const user = ControllerUser.fromJSON(serializedUser, this._roles);
|
||||
const existingUser = this.users.get(user.id);
|
||||
if (existingUser) {
|
||||
// Required migration to all lowercase ids in alpha 19
|
||||
duplicates += 1;
|
||||
// We assume the user with the lower online time is the duplicate
|
||||
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
|
||||
existingUser.merge(user);
|
||||
continue; // Skip users.set
|
||||
}
|
||||
user.merge(existingUser);
|
||||
}
|
||||
this.users.set(user.id, user);
|
||||
}
|
||||
|
||||
if (duplicates) {
|
||||
const backupPath = `${filePath}.${Date.now()}.bak`;
|
||||
lib.logger.warn(
|
||||
`A total of ${duplicates} users were merged, a backup was written to: ${backupPath}`
|
||||
);
|
||||
await lib.safeOutputFile(backupPath, JSON.stringify(content, null, "\t"));
|
||||
if (!("users" in combinedJson)) {
|
||||
throw new Error("Legacy users json does not contain users property");
|
||||
}
|
||||
|
||||
// We intentionally write the roles first because if it fails then the old user file will still contain them
|
||||
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
|
||||
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
@@ -69,66 +49,116 @@ export default class UserManager {
|
||||
}
|
||||
}
|
||||
|
||||
async save(filePath: string): Promise<void> {
|
||||
this.dirty = false;
|
||||
await lib.safeOutputFile(filePath,
|
||||
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Basic Accessors */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
has(id: UserRecord["id"]) {
|
||||
return this.records.has(id);
|
||||
}
|
||||
|
||||
getMutable(id: UserRecord["id"]) {
|
||||
const userRecord = this.records.getMutable(id);
|
||||
if (!userRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return User.fromUserRecord(
|
||||
userRecord,
|
||||
this.records,
|
||||
this._controllerRoles,
|
||||
);
|
||||
}
|
||||
|
||||
get(id: UserRecord["id"]): Readonly<User> | undefined {
|
||||
return this.getMutable(id);
|
||||
}
|
||||
|
||||
hasName(name: UserRecord["name"]) {
|
||||
return this.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
getByNameMutable(name: UserRecord["name"]) {
|
||||
return this.getMutable(name.toLowerCase());
|
||||
}
|
||||
|
||||
getByName(name: UserRecord["name"]) {
|
||||
return this.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
* valuesMutable(): IterableIterator<User> {
|
||||
const roles = this._controllerRoles;
|
||||
const users = this.records;
|
||||
|
||||
for (const record of users.values()) {
|
||||
yield User.fromUserRecord(record, users, roles);
|
||||
}
|
||||
}
|
||||
|
||||
values(): IterableIterator<Readonly<User>> {
|
||||
return this.valuesMutable();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates a new user and add it to the user database.
|
||||
* @param name - Name of the user to create.
|
||||
* @returns The created user.
|
||||
*/
|
||||
createUser(name: string): ControllerUser {
|
||||
createUser(name: string): User {
|
||||
if (this.getByName(name)) {
|
||||
throw new Error(`User '${name}' already exists`);
|
||||
}
|
||||
|
||||
let roles = new Set<number>();
|
||||
let defaultRoleId = this._config.get("controller.default_role_id");
|
||||
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
|
||||
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
|
||||
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
|
||||
roles.add(defaultRoleId);
|
||||
}
|
||||
|
||||
let user = new ControllerUser(this._roles, 0, name, roles);
|
||||
this.users.set(user.id, user);
|
||||
this.dirty = true;
|
||||
const user = new User(this.records, this._controllerRoles, 0, name, roles);
|
||||
this.records.set(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Get or create a user with a given name. */
|
||||
getOrCreateUser(name: string): User {
|
||||
return this.getByNameMutable(name) ?? this.createUser(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing user
|
||||
*
|
||||
* @param user - user to delete
|
||||
*/
|
||||
deleteUser(user: UserRecord) {
|
||||
return this.records.delete(user);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sign access token for the given user
|
||||
*
|
||||
* @param user - user to sign token for
|
||||
* @returns JWT access token for the user.
|
||||
*/
|
||||
signUserToken(user: ControllerUser): string {
|
||||
signUserToken(user: UserRecord): string {
|
||||
return jwt.sign(
|
||||
{ aud: "user", user: user.id },
|
||||
Buffer.from(this._config.get("controller.auth_secret"), "base64")
|
||||
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
|
||||
);
|
||||
}
|
||||
|
||||
notifyJoin(user: ControllerUser, instance_id: number) {
|
||||
user.instances.add(instance_id);
|
||||
this.onlineUsers.add(user);
|
||||
}
|
||||
|
||||
notifyLeave(user: ControllerUser, instance_id: number) {
|
||||
user.instances.delete(instance_id);
|
||||
if (!user.instances.size) {
|
||||
this.onlineUsers.delete(user);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all player stats for specific instance */
|
||||
clearStatsOfInstance(instanceId: number) {
|
||||
for (const user of this.users.values()) {
|
||||
this.notifyLeave(user, instanceId);
|
||||
user.instanceStats.delete(instanceId);
|
||||
user.recalculatePlayerStats();
|
||||
for (const user of this.values()) {
|
||||
user.clearInstanceStats(instanceId);
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { UserDetails } from "@clusterio/lib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
/** Underlying data class for users on the controller */
|
||||
export default class UserRecord extends UserDetails {
|
||||
constructor(
|
||||
/** Unix time in seconds the user token must be issued after to be valid. */
|
||||
public tokenValidAfter = 0,
|
||||
...args: ConstructorParameters<typeof UserDetails>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
token_valid_after: Type.Optional(Type.Number()),
|
||||
...UserDetails.jsonSchema.properties,
|
||||
});
|
||||
|
||||
// _a and _b are a ts workaround for allowing User to have extra arguments
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, _a?: any, _b?: any) {
|
||||
return this.fromUserDetails(
|
||||
UserDetails.fromJSON(json),
|
||||
json.token_valid_after,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json = super.toJSON() as Static<typeof UserRecord.jsonSchema>;
|
||||
|
||||
if (this.tokenValidAfter > 0) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
static fromUserDetails(
|
||||
userDetails: UserDetails,
|
||||
tokenValidAfter = 0,
|
||||
) {
|
||||
return new this(
|
||||
tokenValidAfter,
|
||||
userDetails.name,
|
||||
userDetails.roleIds,
|
||||
userDetails.instances,
|
||||
userDetails.isAdmin,
|
||||
userDetails.isBanned,
|
||||
userDetails.isWhitelisted,
|
||||
userDetails.banReason,
|
||||
userDetails.updatedAtMs,
|
||||
userDetails.isDeleted,
|
||||
userDetails.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
toUserDetails() {
|
||||
return new UserDetails(
|
||||
this.name,
|
||||
this.roleIds,
|
||||
this.instances,
|
||||
this.isAdmin,
|
||||
this.isBanned,
|
||||
this.isWhitelisted,
|
||||
this.banReason,
|
||||
this.updatedAtMs,
|
||||
this.isDeleted,
|
||||
this.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge another user into this one, must have matching ids, user is not deleted
|
||||
* @param otherUser - User who's details are merged from
|
||||
*/
|
||||
merge(otherUser: UserDetails) {
|
||||
if (this.id !== otherUser.id) {
|
||||
throw new Error("Cannot merge users with different ids");
|
||||
}
|
||||
|
||||
// Merge properties
|
||||
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
|
||||
this.instances = new Set([...this.instances, ...otherUser.instances]);
|
||||
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
|
||||
this.isBanned = this.isBanned || otherUser.isBanned;
|
||||
this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted;
|
||||
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
|
||||
this.updatedAtMs = Date.now();
|
||||
|
||||
// Merge instance stats
|
||||
const thisInstanceStats = this.instanceStats;
|
||||
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
|
||||
if (thisInstanceStats.has(instanceId)) {
|
||||
thisInstanceStats.get(instanceId)!.merge(instanceStats);
|
||||
} else {
|
||||
thisInstanceStats.set(instanceId, instanceStats);
|
||||
}
|
||||
}
|
||||
|
||||
// Player stats must be recalculated after modifying instance stats
|
||||
this.recalculatePlayerStats();
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ ${err.stack}`
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
user = this.controller.userManager.getByName(tokenPayload.user);
|
||||
user = this.controller.users.getByNameMutable(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
let user = req.app.locals.controller.userManager.getByName(tokenPayload.user);
|
||||
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
||||
import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}
|
||||
//%if instance
|
||||
|
||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
|
||||
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
//%endif
|
||||
@@ -50,7 +50,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
this.logger.info("controller::onShutdown");
|
||||
}
|
||||
|
||||
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
|
||||
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
+92
-81
@@ -32,7 +32,7 @@ function checkRequestSaveName(name: string) {
|
||||
*
|
||||
* Looks through all sub-dirs of the provided directory for valid
|
||||
* instance definitions and return a mapping of instance id to
|
||||
* instanceInfo objects.
|
||||
* instance configs and the root instance paths.
|
||||
*
|
||||
* @param instancesDir - Directory containing instances
|
||||
* @returns
|
||||
@@ -40,7 +40,7 @@ function checkRequestSaveName(name: string) {
|
||||
* @internal
|
||||
*/
|
||||
async function discoverInstances(instancesDir: string) {
|
||||
let instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||
const instances = new Map<number, UnloadedInstance>();
|
||||
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
|
||||
@@ -60,19 +60,19 @@ async function discoverInstances(instancesDir: string) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
|
||||
if (instances.has(instanceConfig.get("instance.id"))) {
|
||||
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
|
||||
instanceInfos.set(instanceConfig.get("instance.id"), {
|
||||
instances.set(instanceConfig.get("instance.id"), {
|
||||
path: instancePath,
|
||||
config: instanceConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
return instanceInfos;
|
||||
return instances;
|
||||
}
|
||||
|
||||
const instanceStartingMessages = new Set([
|
||||
@@ -114,10 +114,10 @@ export class HostRouter {
|
||||
dst.type === lib.Address.controller
|
||||
|| dst.type === lib.Address.control
|
||||
|| dst.type === lib.Address.host && dst.id !== this.host.config.get("host.id")
|
||||
|| dst.type === lib.Address.instance && !this.host.instanceInfos.has(dst.id)
|
||||
|| dst.type === lib.Address.instance && !this.host.assignedInstances.has(dst.id)
|
||||
) {
|
||||
nextHop = this.host;
|
||||
} else if (dst.type === lib.Address.instance && this.host.instanceInfos.has(dst.id)) {
|
||||
} else if (dst.type === lib.Address.instance && this.host.assignedInstances.has(dst.id)) {
|
||||
nextHop = this.host.instanceConnections.get(dst.id)!;
|
||||
}
|
||||
|
||||
@@ -244,6 +244,12 @@ export class HostRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// Instance information required to location and load an instance
|
||||
type UnloadedInstance = {
|
||||
path: string,
|
||||
config: lib.InstanceConfig,
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles running the host
|
||||
*
|
||||
@@ -263,9 +269,12 @@ export default class Host extends lib.Link {
|
||||
/** Mapping of plugin name to loaded plugin */
|
||||
plugins: Map<string, BaseHostPlugin> = new Map();
|
||||
|
||||
/** A map from instance id to instance connection. Only present when instance is running. */
|
||||
instanceConnections = new Map<number, InstanceConnection>();
|
||||
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||
/** A map of instances that exist in the instance directory of the host */
|
||||
discoveredInstances = new Map<number, UnloadedInstance>();
|
||||
/** A map of instances that are assigned to this host */
|
||||
assignedInstances = new Map<number, UnloadedInstance>();
|
||||
|
||||
adminlist = new Set<string>();
|
||||
banlist = new Map<string, string>();
|
||||
@@ -652,16 +661,16 @@ export default class Host extends lib.Link {
|
||||
|
||||
async handleInstanceAssignInternalRequest(request: lib.InstanceAssignInternalRequest) {
|
||||
let { instanceId, config } = request;
|
||||
let instanceInfo = this.instanceInfos.get(instanceId);
|
||||
if (instanceInfo) {
|
||||
instanceInfo.config.update(config, true, "controller");
|
||||
logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo));
|
||||
let instance = this.assignedInstances.get(instanceId);
|
||||
if (instance) {
|
||||
instance.config.update(config, true, "controller");
|
||||
logger.verbose(`Updated config for ${instance.path}`, this.instanceLogMeta(instanceId, instance));
|
||||
|
||||
} else {
|
||||
instanceInfo = this.discoveredInstanceInfos.get(instanceId);
|
||||
if (instanceInfo) {
|
||||
await Instance.populate_folders(instanceInfo.path);
|
||||
instanceInfo.config.update(config, true, "controller");
|
||||
instance = this.discoveredInstances.get(instanceId);
|
||||
if (instance) {
|
||||
await Instance.populate_folders(instance.path);
|
||||
instance.config.update(config, true, "controller");
|
||||
|
||||
} else {
|
||||
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
|
||||
@@ -672,16 +681,16 @@ export default class Host extends lib.Link {
|
||||
|
||||
logger.info(`Creating ${instanceDir}`);
|
||||
await Instance.populate_folders(instanceDir);
|
||||
instanceInfo = {
|
||||
instance = {
|
||||
path: instanceDir,
|
||||
config: instanceConfig,
|
||||
};
|
||||
|
||||
this.discoveredInstanceInfos.set(instanceId, instanceInfo);
|
||||
this.discoveredInstances.set(instanceId, instance);
|
||||
}
|
||||
|
||||
this.instanceInfos.set(instanceId, instanceInfo);
|
||||
logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`);
|
||||
this.assignedInstances.set(instanceId, instance);
|
||||
logger.verbose(`assigned instance ${instance.config.get("instance.name")}`);
|
||||
}
|
||||
|
||||
// Somewhat hacky, but in the event of a lost session the status is
|
||||
@@ -696,45 +705,45 @@ export default class Host extends lib.Link {
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.version
|
||||
// TODO: factorio.version is not validated
|
||||
: instanceInfo.config.get("factorio.version") as lib.TargetVersion,
|
||||
: instance.config.get("factorio.version") as lib.TargetVersion,
|
||||
)
|
||||
);
|
||||
|
||||
// Send the new list of saves for this assigned instance to the controller.
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
|
||||
|
||||
// save a copy of the instance config
|
||||
await instanceInfo.config.save();
|
||||
await instance.config.save();
|
||||
}
|
||||
|
||||
async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) {
|
||||
let instanceId = request.instanceId;
|
||||
let instanceInfo = this.instanceInfos.get(instanceId);
|
||||
if (instanceInfo) {
|
||||
let instance = this.assignedInstances.get(instanceId);
|
||||
if (instance) {
|
||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||
if (instanceConnection && ["starting", "running"].includes(instanceConnection.instance.status)) {
|
||||
await instanceConnection.send(new lib.InstanceStopRequest());
|
||||
}
|
||||
|
||||
this.instanceInfos.delete(instanceId);
|
||||
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
|
||||
this.assignedInstances.delete(instanceId);
|
||||
logger.verbose(`unassigned instance ${instance.config.get("instance.name")}`);
|
||||
}
|
||||
}
|
||||
|
||||
instanceLogMeta(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) {
|
||||
instanceInfo = instanceInfo || this.instanceInfos.get(instanceId);
|
||||
if (!instanceInfo) {
|
||||
instanceLogMeta(instanceId: number, instance?: { config: lib.InstanceConfig }) {
|
||||
instance = instance || this.assignedInstances.get(instanceId);
|
||||
if (!instance) {
|
||||
return { instance_id: instanceId, instance_name: String(instanceId) };
|
||||
}
|
||||
return { instance_id: instanceId, instance_name: instanceInfo.config.get("instance.name") };
|
||||
return { instance_id: instanceId, instance_name: instance.config.get("instance.name") };
|
||||
}
|
||||
|
||||
getRequestInstanceInfo(instanceId: number) {
|
||||
let instanceInfo = this.instanceInfos.get(instanceId);
|
||||
if (!instanceInfo) {
|
||||
getRequestInstance(instanceId: number) {
|
||||
let instance = this.assignedInstances.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instanceInfo;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,7 +752,7 @@ export default class Host extends lib.Link {
|
||||
* @returns Assigned game port or undefined if it does not exist.
|
||||
*/
|
||||
gamePort(instanceId: number) {
|
||||
const instance = this.discoveredInstanceInfos.get(instanceId);
|
||||
const instance = this.discoveredInstances.get(instanceId);
|
||||
if (!instance) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -756,7 +765,7 @@ export default class Host extends lib.Link {
|
||||
|
||||
assignGamePort(instanceId: number) {
|
||||
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
||||
for (const [id, instance] of this.instanceInfos) {
|
||||
for (const [id, instance] of this.assignedInstances) {
|
||||
if (id === instanceId) {
|
||||
continue;
|
||||
}
|
||||
@@ -768,7 +777,7 @@ export default class Host extends lib.Link {
|
||||
availablePorts.delete(port);
|
||||
}
|
||||
}
|
||||
const instanceToAssign = this.instanceInfos.get(instanceId)!;
|
||||
const instanceToAssign = this.assignedInstances.get(instanceId)!;
|
||||
const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port");
|
||||
if (assignedPort !== null && availablePorts.has(assignedPort)) {
|
||||
return assignedPort;
|
||||
@@ -788,7 +797,7 @@ export default class Host extends lib.Link {
|
||||
* @returns connection to instance.
|
||||
*/
|
||||
async _connectInstance(instanceId: number) {
|
||||
let instanceInfo = this.getRequestInstanceInfo(instanceId);
|
||||
let unloadedInstance = this.getRequestInstance(instanceId);
|
||||
if (this.instanceConnections.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
|
||||
}
|
||||
@@ -797,7 +806,9 @@ export default class Host extends lib.Link {
|
||||
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
|
||||
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
|
||||
let instance = new Instance(
|
||||
this, connectionClient, instanceInfo.path, this.config.get("host.factorio_directory"), instanceInfo.config
|
||||
this, connectionClient,
|
||||
unloadedInstance.path, this.config.get("host.factorio_directory"),
|
||||
unloadedInstance.config
|
||||
);
|
||||
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
|
||||
|
||||
@@ -896,19 +907,19 @@ export default class Host extends lib.Link {
|
||||
src: lib.Address,
|
||||
dst: lib.Address
|
||||
) {
|
||||
let instanceInfo = this.getRequestInstanceInfo(dst.id);
|
||||
return await Instance.listSaves(dst.id, path.join(instanceInfo.path, "saves"), null);
|
||||
let instance = this.getRequestInstance(dst.id);
|
||||
return await Instance.listSaves(dst.id, path.join(instance.path, "saves"), null);
|
||||
}
|
||||
|
||||
async handleInstanceRenameSaveRequest(request: lib.InstanceRenameSaveRequest) {
|
||||
let { instanceId, oldName, newName } = request;
|
||||
checkRequestSaveName(oldName);
|
||||
checkRequestSaveName(newName);
|
||||
let instanceInfo = this.getRequestInstanceInfo(instanceId);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
try {
|
||||
await fs.move(
|
||||
path.join(instanceInfo.path, "saves", oldName),
|
||||
path.join(instanceInfo.path, "saves", newName),
|
||||
path.join(instance.path, "saves", oldName),
|
||||
path.join(instance.path, "saves", newName),
|
||||
{ overwrite: false },
|
||||
);
|
||||
} catch (err: any) {
|
||||
@@ -917,18 +928,18 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
|
||||
}
|
||||
|
||||
async handleInstanceCopySaveRequest(request: lib.InstanceCopySaveRequest) {
|
||||
let { instanceId, source, destination } = request;
|
||||
checkRequestSaveName(source);
|
||||
checkRequestSaveName(destination);
|
||||
let instanceInfo = this.getRequestInstanceInfo(instanceId);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
try {
|
||||
await fs.copy(
|
||||
path.join(instanceInfo.path, "saves", source),
|
||||
path.join(instanceInfo.path, "saves", destination),
|
||||
path.join(instance.path, "saves", source),
|
||||
path.join(instance.path, "saves", destination),
|
||||
{ overwrite: false, errorOnExist: true },
|
||||
);
|
||||
} catch (err: any) {
|
||||
@@ -937,33 +948,33 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
|
||||
}
|
||||
|
||||
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
||||
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
|
||||
checkRequestSaveName(sourceName);
|
||||
checkRequestSaveName(targetName);
|
||||
let sourceInstanceInfo = this.getRequestInstanceInfo(sourceInstanceId);
|
||||
let targetInstanceInfo = this.getRequestInstanceInfo(targetInstanceId);
|
||||
let sourceInstance = this.getRequestInstance(sourceInstanceId);
|
||||
let targetInstance = this.getRequestInstance(targetInstanceId);
|
||||
|
||||
// For consistency with remote transfer initiated through pullSave the
|
||||
// target is renamed if it already exists.
|
||||
targetName = await lib.findUnusedName(
|
||||
path.join(targetInstanceInfo.path, "saves"), targetName, ".zip"
|
||||
path.join(targetInstance.path, "saves"), targetName, ".zip"
|
||||
);
|
||||
|
||||
try {
|
||||
if (copy) {
|
||||
await fs.copy(
|
||||
path.join(sourceInstanceInfo.path, "saves", sourceName),
|
||||
path.join(targetInstanceInfo.path, "saves", targetName),
|
||||
path.join(sourceInstance.path, "saves", sourceName),
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
{ overwrite: true },
|
||||
);
|
||||
} else {
|
||||
await fs.move(
|
||||
path.join(sourceInstanceInfo.path, "saves", sourceName),
|
||||
path.join(targetInstanceInfo.path, "saves", targetName),
|
||||
path.join(sourceInstance.path, "saves", sourceName),
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
{ overwrite: true },
|
||||
);
|
||||
}
|
||||
@@ -973,8 +984,8 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstanceInfo.path, "saves"));
|
||||
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstanceInfo.path, "saves"));
|
||||
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
|
||||
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.path, "saves"));
|
||||
|
||||
return targetName;
|
||||
}
|
||||
@@ -994,23 +1005,23 @@ export default class Host extends lib.Link {
|
||||
async handleInstanceDeleteSaveRequest(request: lib.InstanceDeleteSaveRequest) {
|
||||
let { instanceId, name } = request;
|
||||
checkRequestSaveName(name);
|
||||
let instanceInfo = this.getRequestInstanceInfo(instanceId);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
|
||||
try {
|
||||
await fs.unlink(path.join(instanceInfo.path, "saves", name));
|
||||
await fs.unlink(path.join(instance.path, "saves", name));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${name} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
|
||||
}
|
||||
|
||||
async handleInstancePullSaveRequest(request: lib.InstancePullSaveRequest) {
|
||||
let { instanceId, streamId, name } = request;
|
||||
checkRequestSaveName(name);
|
||||
let instanceInfo = this.getRequestInstanceInfo(instanceId);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
|
||||
let url = new URL(this.config.get("host.controller_url"));
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
@@ -1025,7 +1036,7 @@ export default class Host extends lib.Link {
|
||||
throw new lib.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`);
|
||||
}
|
||||
|
||||
let savesDir = path.join(instanceInfo.path, "saves");
|
||||
let savesDir = path.join(instance.path, "saves");
|
||||
let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
let writeStream: NodeJS.WritableStream;
|
||||
while (true) {
|
||||
@@ -1054,12 +1065,12 @@ export default class Host extends lib.Link {
|
||||
async handleInstancePushSaveRequest(request: lib.InstancePushSaveRequest) {
|
||||
let { instanceId, streamId, name } = request;
|
||||
checkRequestSaveName(name);
|
||||
let instanceInfo = this.getRequestInstanceInfo(instanceId);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
|
||||
let content: Buffer;
|
||||
try {
|
||||
// phin doesn't support streaming requests :(
|
||||
content = await fs.readFile(path.join(instanceInfo.path, "saves", name));
|
||||
content = await fs.readFile(path.join(instance.path, "saves", name));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${name} does not exist`);
|
||||
@@ -1084,14 +1095,14 @@ export default class Host extends lib.Link {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
|
||||
}
|
||||
|
||||
let instanceInfo = this.discoveredInstanceInfos.get(instanceId);
|
||||
if (!instanceInfo) {
|
||||
let instance = this.discoveredInstances.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
|
||||
this.discoveredInstanceInfos.delete(instanceId);
|
||||
this.instanceInfos.delete(instanceId);
|
||||
await fs.remove(instanceInfo.path);
|
||||
this.discoveredInstances.delete(instanceId);
|
||||
this.assignedInstances.delete(instanceId);
|
||||
await fs.remove(instance.path);
|
||||
}
|
||||
|
||||
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
|
||||
@@ -1141,12 +1152,12 @@ export default class Host extends lib.Link {
|
||||
* the host and controller with the new list of instances.
|
||||
*/
|
||||
async updateInstances() {
|
||||
this.discoveredInstanceInfos = await discoverInstances(this.config.get("host.instances_directory"));
|
||||
this.discoveredInstances = await discoverInstances(this.config.get("host.instances_directory"));
|
||||
let list = [];
|
||||
for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) {
|
||||
for (let [instanceId, instance] of this.discoveredInstances) {
|
||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||
list.push(new lib.HostInstanceUpdate(
|
||||
instanceInfo.config.toRemote("controller"),
|
||||
instance.config.toRemote("controller"),
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||
@@ -1158,10 +1169,10 @@ export default class Host extends lib.Link {
|
||||
if (this._startup) {
|
||||
this._startup = false;
|
||||
|
||||
for (let [instanceId, instanceInfo] of this.instanceInfos) {
|
||||
if (instanceInfo.config.get("instance.auto_start")) {
|
||||
for (let [instanceId, instance] of this.assignedInstances) {
|
||||
if (instance.config.get("instance.auto_start")) {
|
||||
if (this.recoveryMode) {
|
||||
logger.warn(`Recovery | skipping auto startup for ${instanceInfo.config.get("instance.name")}`);
|
||||
logger.warn(`Recovery | skipping auto startup for ${instance.config.get("instance.name")}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -1169,8 +1180,8 @@ export default class Host extends lib.Link {
|
||||
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
|
||||
} catch (err: any) {
|
||||
logger.error(
|
||||
`Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`,
|
||||
this.instanceLogMeta(instanceId, instanceInfo)
|
||||
`Error during auto startup for ${instance.config.get("instance.name")}:\n${err.stack}`,
|
||||
this.instanceLogMeta(instanceId, instance)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1218,7 +1229,7 @@ export default class Host extends lib.Link {
|
||||
await this.config.save();
|
||||
// Save host side in case host_assigned_game_port changed in the config.
|
||||
await Promise.all(
|
||||
[...this.instanceInfos.values()].map(instanceInfo => instanceInfo.config.save())
|
||||
[...this.assignedInstances.values()].map(instance => instance.config.save())
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
export default class HostDetails {
|
||||
constructor(
|
||||
/** Version of the host software. The controller may warn if there is a mismatch. */
|
||||
public version: string,
|
||||
/** Human-readable display name of the host. */
|
||||
public name: string,
|
||||
/** Unique numeric identifier for this host. */
|
||||
public id: number,
|
||||
/** Whether the host is currently connected to the controller. */
|
||||
public connected: boolean,
|
||||
/** Address from which the host has connected to the controller. */
|
||||
public remoteAddress = "",
|
||||
/** Public-facing IP address of the host, if available. */
|
||||
public publicAddress: string = "",
|
||||
/** Unix timestamp (sec) after which issued tokens are considered valid. */
|
||||
public tokenValidAfter: number = 0,
|
||||
/** Unix timestamp (ms) indicating when this record was last updated. */
|
||||
public updatedAtMs = 0,
|
||||
/** Flag indicating the host record has been removed on the controller. */
|
||||
public isDeleted = false
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"version": Type.String(),
|
||||
"name": Type.String(),
|
||||
"id": Type.Integer(),
|
||||
"connected": Type.Boolean(),
|
||||
"remoteAddress": Type.Optional(Type.String()),
|
||||
"publicAddress": Type.Optional(Type.String()),
|
||||
"tokenValidAfter": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"isDeleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.version,
|
||||
json.name,
|
||||
json.id,
|
||||
json.connected,
|
||||
json.remoteAddress,
|
||||
json.publicAddress,
|
||||
json.tokenValidAfter,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json = {
|
||||
version: this.version,
|
||||
name: this.name,
|
||||
id: this.id,
|
||||
connected: this.connected,
|
||||
} as Static<typeof HostDetails.jsonSchema>;
|
||||
|
||||
if (this.remoteAddress !== "") {
|
||||
json.remoteAddress = this.remoteAddress;
|
||||
}
|
||||
if (this.publicAddress !== "") {
|
||||
json.publicAddress = this.publicAddress;
|
||||
}
|
||||
if (this.tokenValidAfter !== 0) {
|
||||
json.tokenValidAfter = this.tokenValidAfter;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updatedAtMs = this.updatedAtMs;
|
||||
}
|
||||
if (this.isDeleted !== false) {
|
||||
json.isDeleted = this.isDeleted;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "./composites";
|
||||
import { TargetVersion, TargetVersionSchema } from "./version";
|
||||
|
||||
export const InstanceStatus = StringEnum([
|
||||
"unknown",
|
||||
"unassigned",
|
||||
"stopped",
|
||||
"starting",
|
||||
"running",
|
||||
"stopping",
|
||||
"creating_save",
|
||||
"exporting_data",
|
||||
"deleted",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Current status of the instance. One of:
|
||||
* - `unknown`: Instance is assigned to a host but this host is currently
|
||||
* not connected to the contreller.
|
||||
* - `unassigned`: Instance is not assigned to a a host and exists only on
|
||||
* the controller.
|
||||
* - `stopped`: Instance is stopped.
|
||||
* - `starting`: Instance is in the process of starting up.
|
||||
* - `running`: Instance is running normally.
|
||||
* - `stopping`: Instance is in the process of stopping.
|
||||
* - `creating_save`: Instance is in the process of creating a save.
|
||||
* - `exporting_data`: Instance is in the process of exporting game data.
|
||||
* - `deleted`: Instance has been deleted.
|
||||
*/
|
||||
export type InstanceStatus = Static<typeof InstanceStatus>;
|
||||
|
||||
export default class InstanceDetails {
|
||||
constructor(
|
||||
public name: string,
|
||||
public id: number,
|
||||
public assignedHost: number | undefined,
|
||||
public gamePort: number | undefined,
|
||||
public status: InstanceStatus,
|
||||
public factorioVersion: TargetVersion | undefined,
|
||||
public startedAtMs = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs = 0,
|
||||
public excludeFromStartAll = false
|
||||
) {}
|
||||
|
||||
get isDeleted() {
|
||||
return this.status === "deleted";
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"id": Type.Integer(),
|
||||
"assignedHost": Type.Optional(Type.Integer()),
|
||||
"gamePort": Type.Optional(Type.Integer()),
|
||||
"status": StringEnum([
|
||||
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
|
||||
"creating_save", "exporting_data", "deleted",
|
||||
]),
|
||||
"factorioVersion": Type.Optional(TargetVersionSchema),
|
||||
"startedAtMs": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"excludeFromStartAll": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.name,
|
||||
json.id,
|
||||
json.assignedHost,
|
||||
json.gamePort,
|
||||
json.status,
|
||||
json.factorioVersion,
|
||||
json.startedAtMs,
|
||||
json.updatedAtMs,
|
||||
json.excludeFromStartAll
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json = {
|
||||
name: this.name,
|
||||
id: this.id,
|
||||
status: this.status,
|
||||
} as Static<typeof InstanceDetails.jsonSchema>;
|
||||
|
||||
if (this.assignedHost !== undefined) {
|
||||
json.assignedHost = this.assignedHost;
|
||||
}
|
||||
if (this.gamePort !== undefined) {
|
||||
json.gamePort = this.gamePort;
|
||||
}
|
||||
if (this.factorioVersion !== undefined) {
|
||||
json.factorioVersion = this.factorioVersion;
|
||||
}
|
||||
if (this.startedAtMs !== 0) {
|
||||
json.startedAtMs = this.startedAtMs;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updatedAtMs = this.updatedAtMs;
|
||||
}
|
||||
if (this.excludeFromStartAll !== false) {
|
||||
json.excludeFromStartAll = this.excludeFromStartAll;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,10 @@ import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
import PlayerStats from "./PlayerStats";
|
||||
|
||||
/**
|
||||
* Represeents a user in the cluster
|
||||
*
|
||||
* Holds data about a Factorio user in the cluster.
|
||||
*/
|
||||
export default class User {
|
||||
/** Represents a user in the cluster */
|
||||
export default class UserDetails {
|
||||
public playerStats: PlayerStats;
|
||||
|
||||
constructor(
|
||||
/** Factorio user name. */
|
||||
public name: string,
|
||||
@@ -27,11 +25,15 @@ export default class User {
|
||||
public updatedAtMs = 0,
|
||||
/** True if this user object has been removed from the cluster. */
|
||||
public isDeleted = false,
|
||||
/** Combined statistics for the player this user account is tied to. */
|
||||
public playerStats = new PlayerStats(),
|
||||
/** Per instance statistics for the player this user account is tied to. */
|
||||
public instanceStats = new Map<number, PlayerStats>(),
|
||||
) {
|
||||
this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats);
|
||||
}
|
||||
|
||||
/** Name of the user, used by event subscriptions and to ensure case insensitivity */
|
||||
get id() {
|
||||
return this.name.toLowerCase();
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -51,17 +53,13 @@ export default class User {
|
||||
),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, _hack?: any) {
|
||||
const roleIds = new Set<number>(json.roles ?? []);
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
const instanceStats = new Map(
|
||||
(json.instance_stats ?? []).map(
|
||||
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
|
||||
)
|
||||
(json.instance_stats ?? []).map(([id, stats]) => [id, PlayerStats.fromJSON(stats)])
|
||||
);
|
||||
const playerStats = User._calculatePlayerStats(instanceStats);
|
||||
return new this(
|
||||
json.name,
|
||||
roleIds,
|
||||
new Set<number>(json.roles),
|
||||
new Set(json.instances),
|
||||
json.is_admin,
|
||||
json.is_banned,
|
||||
@@ -69,13 +67,12 @@ export default class User {
|
||||
json.ban_reason,
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
playerStats,
|
||||
instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
let json: Static<typeof User.jsonSchema> = {
|
||||
let json: Static<typeof UserDetails.jsonSchema> = {
|
||||
name: this.name,
|
||||
};
|
||||
|
||||
@@ -119,7 +116,7 @@ export default class User {
|
||||
}
|
||||
|
||||
recalculatePlayerStats() {
|
||||
this.playerStats = User._calculatePlayerStats(this.instanceStats);
|
||||
this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats);
|
||||
}
|
||||
|
||||
static _calculatePlayerStats(instanceStatsMap: Map<number, PlayerStats>) {
|
||||
@@ -149,44 +146,13 @@ export default class User {
|
||||
}
|
||||
return playerStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge another user into this one, must have matching ids, user is not deleted
|
||||
* @param otherUser - User who's details are merged from
|
||||
*/
|
||||
merge(otherUser: User) {
|
||||
if (this.id !== otherUser.id) {
|
||||
throw new Error("Cannot merge users with different ids");
|
||||
}
|
||||
|
||||
// Merge properties
|
||||
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
|
||||
this.instances = new Set([...this.instances, ...otherUser.instances]);
|
||||
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
|
||||
this.isBanned = this.isBanned || otherUser.isBanned;
|
||||
this.isBanned = this.isWhitelisted || otherUser.isWhitelisted;
|
||||
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
|
||||
this.updatedAtMs = Date.now();
|
||||
|
||||
// Merge instance stats
|
||||
const thisInstanceStats = this.instanceStats;
|
||||
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
|
||||
if (thisInstanceStats.has(instanceId)) {
|
||||
thisInstanceStats.get(instanceId)!.merge(instanceStats);
|
||||
} else {
|
||||
thisInstanceStats.set(instanceId, instanceStats);
|
||||
}
|
||||
}
|
||||
this.recalculatePlayerStats();
|
||||
}
|
||||
|
||||
/** Name of the user, used by event subscriptions and to ensure case insensitivity */
|
||||
get id() {
|
||||
return this.name.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
export interface IControllerUser extends User {
|
||||
/**
|
||||
* This interface is implemented by User on the controller.
|
||||
* It is exposed here to allow message definitions to create permission methods.
|
||||
*/
|
||||
export interface IUser extends UserDetails {
|
||||
/**
|
||||
* Check if a given permission is granted
|
||||
*
|
||||
@@ -12,8 +12,10 @@ export { default as ModuleInfo } from "./ModuleInfo";
|
||||
export { default as Permission } from "./Permission";
|
||||
export { default as PlayerStats } from "./PlayerStats";
|
||||
export { default as Role } from "./Role";
|
||||
export { default as User } from "./User";
|
||||
export type { IControllerUser } from "./User";
|
||||
export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails";
|
||||
export { default as HostDetails } from "./HostDetails";
|
||||
export { default as UserDetails } from "./UserDetails";
|
||||
export type { IUser } from "./UserDetails";
|
||||
export * from "./composites";
|
||||
export * from "./messages_core";
|
||||
export * from "./messages_controller";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import { jsonArray, plainJson, StringEnum } from "./composites";
|
||||
import { CollectorResultSerialized } from "../prometheus";
|
||||
import { HostConfig } from "../config/definitions";
|
||||
import HostDetails from "./HostDetails";
|
||||
|
||||
export class HostStopRequest {
|
||||
declare ["constructor"]: typeof HostStopRequest;
|
||||
@@ -82,47 +83,6 @@ export class HostConfigSetPropRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class HostDetails {
|
||||
constructor(
|
||||
public version: string,
|
||||
public name: string,
|
||||
public id: number,
|
||||
public connected: boolean,
|
||||
public remoteAddress = "",
|
||||
public publicAddress: string = "",
|
||||
public tokenValidAfter: number = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs = 0,
|
||||
public isDeleted = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"version": Type.String(),
|
||||
"name": Type.String(),
|
||||
"id": Type.Integer(),
|
||||
"connected": Type.Boolean(),
|
||||
"remoteAddress": Type.Optional(Type.String()),
|
||||
"publicAddress": Type.Optional(Type.String()),
|
||||
"tokenValidAfter": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"isDeleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.version,
|
||||
json.name,
|
||||
json.id,
|
||||
json.connected,
|
||||
json.remoteAddress,
|
||||
json.publicAddress,
|
||||
json.tokenValidAfter,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class HostListRequest {
|
||||
declare ["constructor"]: typeof HostListRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -2,87 +2,11 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import PlayerStats from "./PlayerStats";
|
||||
import { JsonString, StringEnum, jsonArray, plainJson } from "./composites";
|
||||
import { InstanceConfig } from "../config";
|
||||
import type { IControllerUser } from "./User";
|
||||
import type { IUser } from "./UserDetails";
|
||||
import type { MessageRequest } from "./messages_core";
|
||||
import { CollectorResultSerialized } from "../prometheus";
|
||||
import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version";
|
||||
|
||||
|
||||
export const InstanceStatus = StringEnum([
|
||||
"unknown",
|
||||
"unassigned",
|
||||
"stopped",
|
||||
"starting",
|
||||
"running",
|
||||
"stopping",
|
||||
"creating_save",
|
||||
"exporting_data",
|
||||
"deleted",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Current status of the instance. One of:
|
||||
* - `unknown`: Instance is assigned to a host but this host is currently
|
||||
* not connected to the contreller.
|
||||
* - `unassigned`: Instance is not assigned to a a host and exists only on
|
||||
* the controller.
|
||||
* - `stopped`: Instance is stopped.
|
||||
* - `starting`: Instance is in the process of starting up.
|
||||
* - `running`: Instance is running normally.
|
||||
* - `stopping`: Instance is in the process of stopping.
|
||||
* - `creating_save`: Instance is in the process of creating a save.
|
||||
* - `exporting_data`: Instance is in the process of exporting game data.
|
||||
* - `deleted`: Instance has been deleted.
|
||||
*/
|
||||
export type InstanceStatus = Static<typeof InstanceStatus>;
|
||||
|
||||
export class InstanceDetails {
|
||||
constructor(
|
||||
public name: string,
|
||||
public id: number,
|
||||
public assignedHost: number | undefined,
|
||||
public gamePort: number | undefined,
|
||||
public status: InstanceStatus,
|
||||
public factorioVersion: TargetVersion | undefined,
|
||||
public startedAtMs = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs = 0,
|
||||
public excludeFromStartAll = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"id": Type.Integer(),
|
||||
"assignedHost": Type.Optional(Type.Integer()),
|
||||
"gamePort": Type.Optional(Type.Integer()),
|
||||
"status": StringEnum([
|
||||
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
|
||||
"creating_save", "exporting_data", "deleted",
|
||||
]),
|
||||
"factorioVersion": Type.Optional(TargetVersionSchema),
|
||||
"startedAtMs": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"excludeFromStartAll": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.name,
|
||||
json.id,
|
||||
json.assignedHost,
|
||||
json.gamePort,
|
||||
json.status,
|
||||
json.factorioVersion,
|
||||
json.startedAtMs,
|
||||
json.updatedAtMs,
|
||||
json.excludeFromStartAll,
|
||||
);
|
||||
}
|
||||
|
||||
get isDeleted() {
|
||||
return this.status === "deleted";
|
||||
}
|
||||
}
|
||||
import InstanceDetails, { InstanceStatus } from "./InstanceDetails";
|
||||
|
||||
export class InstanceDetailsGetRequest {
|
||||
declare ["constructor"]: typeof InstanceDetailsGetRequest;
|
||||
@@ -539,7 +463,7 @@ export class InstanceTransferSaveRequest {
|
||||
static type = "request" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = ["controller", "host"] as const;
|
||||
static permission(user: IControllerUser, message: MessageRequest) {
|
||||
static permission(user: IUser, message: MessageRequest) {
|
||||
user.checkPermission("core.instance.save.transfer");
|
||||
if (typeof message.data === "object" && message.data !== null) {
|
||||
const data = message.data as { copy: boolean, sourceName: string, targetName: string };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import User, { IControllerUser } from "./User";
|
||||
import UserDetails, { IUser } from "./UserDetails";
|
||||
import { StringEnum, jsonArray, plainJson } from "./composites";
|
||||
import { MessageRequest } from "./messages_core";
|
||||
|
||||
@@ -22,7 +22,7 @@ export class UserGetRequest {
|
||||
return new this(json.name);
|
||||
}
|
||||
|
||||
static Response = User;
|
||||
static Response = UserDetails;
|
||||
}
|
||||
|
||||
export class UserListRequest {
|
||||
@@ -31,7 +31,7 @@ export class UserListRequest {
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.user.list" as const;
|
||||
static Response = jsonArray(User);
|
||||
static Response = jsonArray(UserDetails);
|
||||
}
|
||||
|
||||
export class UserCreateRequest {
|
||||
@@ -198,15 +198,15 @@ export class UserUpdatesEvent {
|
||||
static permission = "core.user.subscribe" as const;
|
||||
|
||||
constructor(
|
||||
public updates: User[],
|
||||
public updates: UserDetails[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"updates": Type.Array(User.jsonSchema),
|
||||
"updates": Type.Array(UserDetails.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.updates.map(update => User.fromJSON(update)));
|
||||
return new this(json.updates.map(update => UserDetails.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ export class UserBulkImportRequest {
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission(user: IControllerUser, message: MessageRequest) {
|
||||
static permission(user: IUser, message: MessageRequest) {
|
||||
if (typeof message.data === "object" && message.data !== null) {
|
||||
const data = message.data as Static<typeof UserBulkImportRequest.jsonSchema>;
|
||||
// Check if this is a restore or import request
|
||||
|
||||
@@ -53,7 +53,7 @@ export class JsonDatastoreProvider<
|
||||
private filePath: string,
|
||||
private fromJson: (json: J) => V = v => v as any as V,
|
||||
private migrations: (rawJson: Record<string, unknown>) => Record<string, J> = v => v as Record<string, J>,
|
||||
private finalise: (obj: V) => V = v => v,
|
||||
private finalise: (obj: V, map: Map<string, V>) => V | undefined = v => v,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -82,7 +82,24 @@ export class JsonDatastoreProvider<
|
||||
const serialized = this.migrations(rawJson);
|
||||
|
||||
// Convert to data class objects
|
||||
return new Map(Object.entries(serialized).map(([k, v]) => [k, this.finalise(this.fromJson(v))]));
|
||||
let serializedCount = 0;
|
||||
const result = new Map<string, V>();
|
||||
for (const [key, raw] of Object.entries(serialized)) {
|
||||
const final = this.finalise(this.fromJson(raw), result);
|
||||
if (final !== undefined) { result.set(key, final); }
|
||||
serializedCount += 1;
|
||||
}
|
||||
|
||||
// Save a backup if the map does not contain all values (happens where there are duplicates)
|
||||
if (result.size < serializedCount) {
|
||||
const backupPath = `${this.filePath}.${Date.now()}.bak`;
|
||||
logger.warn(
|
||||
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
|
||||
);
|
||||
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +112,7 @@ export class JsonIdDatastoreProvider<
|
||||
private filePath: string,
|
||||
private fromJson: (json: J) => V = v => v as any as V,
|
||||
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as Array<J>,
|
||||
private finalise: (obj: V) => V = v => v,
|
||||
private finalise: (obj: V, map: Map<V["id"], V>) => V | undefined = v => v,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -124,10 +141,24 @@ export class JsonIdDatastoreProvider<
|
||||
const serialized = this.migrations(rawJson);
|
||||
|
||||
// Convert to data class objects
|
||||
return new Map(serialized.map((e) => {
|
||||
const v = this.finalise(this.fromJson(e));
|
||||
return [v.id, v];
|
||||
}));
|
||||
let serializedCount = 0;
|
||||
const result = new Map<V["id"], V>();
|
||||
for (const raw of serialized) {
|
||||
const final = this.finalise(this.fromJson(raw), result);
|
||||
if (final !== undefined) { result.set(final.id, final); }
|
||||
serializedCount += 1;
|
||||
}
|
||||
|
||||
// Save a backup if the map does not contain all values (happens where there are duplicates)
|
||||
if (result.size < serializedCount) {
|
||||
const backupPath = `${this.filePath}.${Date.now()}.bak`;
|
||||
logger.warn(
|
||||
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
|
||||
);
|
||||
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BaseConnector, WebSocketBaseConnector } from "./connectors";
|
||||
import { strict as assert } from "assert";
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
|
||||
import type {
|
||||
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IControllerUser,
|
||||
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser,
|
||||
} from "../data";
|
||||
|
||||
export interface Request<Req, Res> {
|
||||
@@ -18,7 +18,7 @@ export interface Request<Req, Res> {
|
||||
type: "request";
|
||||
src: AddressType | readonly AddressType[];
|
||||
dst: AddressType | readonly AddressType[];
|
||||
permission?: null | string | ((user: IControllerUser, message: MessageRequest) => void);
|
||||
permission?: null | string | ((user: IUser, message: MessageRequest) => void);
|
||||
plugin?: string;
|
||||
Response?: JSONDeserialisable<Res>,
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export interface Event<T> {
|
||||
type: "event";
|
||||
src: AddressType | readonly AddressType[];
|
||||
dst: AddressType | readonly AddressType[];
|
||||
permission?: null | string | ((user: IControllerUser, message: MessageEvent) => void);
|
||||
permission?: null | string | ((user: IUser, message: MessageEvent) => void);
|
||||
plugin?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
|
||||
import { Address, MessageRequest, IControllerUser, plainJson, JsonBoolean } from "./data";
|
||||
import { Address, MessageRequest, IUser, plainJson, JsonBoolean } from "./data";
|
||||
import isDeepStrictEqual from "./is_deep_strict_equal";
|
||||
import { RequestError, SessionLost } from "./errors";
|
||||
import { logger } from "./logging";
|
||||
@@ -18,7 +18,7 @@ export class SubscriptionRequest {
|
||||
static type = "request" as const;
|
||||
static src = ["control", "host", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission(user: IControllerUser, message: MessageRequest) {
|
||||
static permission(user: IUser, message: MessageRequest) {
|
||||
if (typeof message.data === "object" && message.data !== null) {
|
||||
const data = message.data as Static<typeof SubscriptionRequest.jsonSchema>;
|
||||
const entry = Link._eventsByName.get(data[0]);
|
||||
|
||||
@@ -9,7 +9,7 @@ import BaseWebPlugin from "../BaseWebPlugin";
|
||||
type PluginExtraProps = {
|
||||
component: string;
|
||||
instance?: lib.InstanceDetails;
|
||||
user?: lib.User;
|
||||
user?: lib.UserDetails;
|
||||
host?: lib.HostDetails;
|
||||
role?: lib.Role;
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
const data = [...users.values()];
|
||||
|
||||
// Determine online predicate based on instanceId
|
||||
const isUserOnline = (user: lib.User) => {
|
||||
const isUserOnline = (user: lib.UserDetails) => {
|
||||
if (instanceId !== undefined) {
|
||||
return user.instances && user.instances.has(instanceId);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_: any, user: lib.User) => (
|
||||
render: (_: any, user: lib.UserDetails) => (
|
||||
<Space>
|
||||
{user.name}
|
||||
<span>
|
||||
@@ -69,11 +69,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
</Space>
|
||||
),
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
|
||||
filterIcon: (filtered: boolean) => (
|
||||
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
|
||||
),
|
||||
onFilter: (value: string | number | boolean, record: lib.User) => record.name
|
||||
onFilter: (value: string | number | boolean, record: lib.UserDetails) => record.name
|
||||
.toLowerCase()
|
||||
.includes((value as string).toLowerCase()),
|
||||
filterDropdownProps: {
|
||||
@@ -106,8 +106,10 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
key: "roles",
|
||||
filters: roleFilters,
|
||||
filterMultiple: true,
|
||||
onFilter: (value: string | number | boolean, record: lib.User) => record.roleIds.has(value as number),
|
||||
render: (_: any, user: lib.User) => (
|
||||
onFilter: (value: string | number | boolean, record: lib.UserDetails) => (
|
||||
record.roleIds.has(value as number)
|
||||
),
|
||||
render: (_: any, user: lib.UserDetails) => (
|
||||
[...user.roleIds].map((id) => (
|
||||
<Link key={id} to={`/roles/${id}/view`} onClick={(e) => e.stopPropagation()}>
|
||||
<Tag>{(roles.get(id) || { name: id }).name}</Tag>
|
||||
@@ -119,7 +121,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
|
||||
|
||||
// Helper to get last seen timestamp
|
||||
function getLastSeenTimestamp(user: lib.User): number | undefined {
|
||||
function getLastSeenTimestamp(user: lib.UserDetails): number | undefined {
|
||||
const stats = instanceId !== undefined ? user.instanceStats.get(instanceId) : user.playerStats;
|
||||
if (!stats) { return undefined; }
|
||||
if (stats.lastLeaveAt && stats.lastLeaveAt.getTime() > (stats.lastJoinAt?.getTime() ?? 0)) {
|
||||
@@ -136,11 +138,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
{
|
||||
title: "Play Time",
|
||||
key: "playTime",
|
||||
render: (_: any, user: lib.User) => {
|
||||
render: (_: any, user: lib.UserDetails) => {
|
||||
const instanceStats = user.instanceStats.get(instanceId);
|
||||
return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-";
|
||||
},
|
||||
sorter: (a: lib.User, b: lib.User) => {
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
|
||||
const statsA = a.instanceStats.get(instanceId);
|
||||
const statsB = b.instanceStats.get(instanceId);
|
||||
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
|
||||
@@ -149,11 +151,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
{
|
||||
title: "Join Count",
|
||||
key: "joinCount",
|
||||
render: (_: any, user: lib.User) => {
|
||||
render: (_: any, user: lib.UserDetails) => {
|
||||
const instanceStats = user.instanceStats.get(instanceId);
|
||||
return instanceStats?.joinCount ?? 0;
|
||||
},
|
||||
sorter: (a: lib.User, b: lib.User) => {
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
|
||||
const statsA = a.instanceStats.get(instanceId);
|
||||
const statsB = b.instanceStats.get(instanceId);
|
||||
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
|
||||
@@ -162,8 +164,8 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
{
|
||||
title: "First Seen",
|
||||
key: "firstSeen",
|
||||
render: (_: any, user: lib.User) => formatFirstSeen(user, instanceId),
|
||||
sorter: (a: lib.User, b: lib.User) => {
|
||||
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
|
||||
const statsA = a.instanceStats.get(instanceId);
|
||||
const statsB = b.instanceStats.get(instanceId);
|
||||
const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0;
|
||||
@@ -177,18 +179,18 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
{
|
||||
title: "Online time",
|
||||
key: "onlineTime",
|
||||
render: (_: any, user: lib.User) => (user.playerStats?.onlineTimeMs
|
||||
render: (_: any, user: lib.UserDetails) => (user.playerStats?.onlineTimeMs
|
||||
? formatDuration(user.playerStats.onlineTimeMs)
|
||||
: null),
|
||||
// eslint-disable-next-line max-len
|
||||
sorter: (a: lib.User, b: lib.User) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "First seen",
|
||||
key: "firstSeen",
|
||||
render: (_: any, user: lib.User) => formatFirstSeen(user),
|
||||
sorter: (a: lib.User, b: lib.User) => sortFirstSeen(a, b),
|
||||
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -204,7 +206,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
{ text: "7d", value: "7d" },
|
||||
{ text: "30d", value: "30d" },
|
||||
],
|
||||
onFilter: (value: string | number | boolean, record: lib.User) => {
|
||||
onFilter: (value: string | number | boolean, record: lib.UserDetails) => {
|
||||
const online = isUserOnline(record);
|
||||
if (value === "online") {
|
||||
return online;
|
||||
@@ -227,8 +229,8 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
return false;
|
||||
}
|
||||
},
|
||||
sorter: (a: lib.User, b: lib.User) => sortLastSeen(a, b, instanceId, instanceId),
|
||||
render: (_: any, user: lib.User) => formatLastSeen(user, instanceId),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId),
|
||||
render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import ControlContext from "../components/ControlContext";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export function useRolesOfUser(user?: lib.User) {
|
||||
export function useRolesOfUser(user?: lib.UserDetails) {
|
||||
const [roles, synced] = useRoles();
|
||||
return [
|
||||
user !== undefined ? new Map([...user.roleIds].map(roleId => [roleId, roles.get(roleId)])) : new Map(),
|
||||
|
||||
@@ -4,19 +4,19 @@ import ControlContext from "../components/ControlContext";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
function getInstanceStats(user: lib.User, instanceId?: number) {
|
||||
function getInstanceStats(user: lib.UserDetails, instanceId?: number) {
|
||||
if (instanceId === undefined) {
|
||||
return user.playerStats;
|
||||
}
|
||||
return user.instanceStats.get(instanceId);
|
||||
}
|
||||
|
||||
function calculateFirstSeen(user: lib.User, instanceId?: number) {
|
||||
function calculateFirstSeen(user: lib.UserDetails, instanceId?: number) {
|
||||
const stats = getInstanceStats(user, instanceId);
|
||||
return stats?.firstJoinAt?.getTime();
|
||||
}
|
||||
|
||||
function calculateLastSeen(user: lib.User, instanceId?: number) {
|
||||
function calculateLastSeen(user: lib.UserDetails, instanceId?: number) {
|
||||
const stats = getInstanceStats(user, instanceId);
|
||||
if (!stats) {
|
||||
return undefined;
|
||||
@@ -30,7 +30,7 @@ function calculateLastSeen(user: lib.User, instanceId?: number) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatFirstSeen(user: lib.User, instanceId?: number) {
|
||||
export function formatFirstSeen(user: lib.UserDetails, instanceId?: number) {
|
||||
const firstSeen = calculateFirstSeen(user, instanceId);
|
||||
if (firstSeen === undefined) {
|
||||
return undefined;
|
||||
@@ -38,7 +38,7 @@ export function formatFirstSeen(user: lib.User, instanceId?: number) {
|
||||
return new Date(firstSeen).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatLastSeen(user: lib.User, instanceId?: number) {
|
||||
export function formatLastSeen(user: lib.UserDetails, instanceId?: number) {
|
||||
if (user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId)) {
|
||||
return <Tag color="green">Online</Tag>;
|
||||
}
|
||||
@@ -49,14 +49,24 @@ export function formatLastSeen(user: lib.User, instanceId?: number) {
|
||||
return new Date(lastSeen).toLocaleString();
|
||||
}
|
||||
|
||||
export function sortFirstSeen(userA: lib.User, userB: lib.User, instanceIdA?: number, instanceIdB?: number) {
|
||||
export function sortFirstSeen(
|
||||
userA: lib.UserDetails,
|
||||
userB: lib.UserDetails,
|
||||
instanceIdA?: number,
|
||||
instanceIdB?: number
|
||||
) {
|
||||
const firstSeenA = calculateFirstSeen(userA, instanceIdA) || 0;
|
||||
const firstSeenB = calculateFirstSeen(userB, instanceIdB) || 0;
|
||||
return firstSeenA - firstSeenB;
|
||||
}
|
||||
|
||||
export function sortLastSeen(userA: lib.User, userB: lib.User, instanceIdA?: number, instanceIdB?: number) {
|
||||
function epoch(user: lib.User, instanceId?: number) {
|
||||
export function sortLastSeen(
|
||||
userA: lib.UserDetails,
|
||||
userB: lib.UserDetails,
|
||||
instanceIdA?: number,
|
||||
instanceIdB?: number
|
||||
) {
|
||||
function epoch(user: lib.UserDetails, instanceId?: number) {
|
||||
return user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ type instanceHandler = (instanceDetails: lib.InstanceDetails) => void;
|
||||
type saveListHandler = (saveListEvent: lib.SaveDetails) => void;
|
||||
type modPackHandler = (modPack: lib.ModPack) => void;
|
||||
type modInfoHandler = (modInfo: lib.ModInfo) => void;
|
||||
type userHandler = (rawUser: lib.User) => void;
|
||||
type userHandler = (rawUser: lib.UserDetails) => void;
|
||||
type logHandler = (info: { level: keyof typeof lib.levels, message: string }) => void;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseControllerPlugin, type InstanceInfo } from "@clusterio/controller";
|
||||
import { BaseControllerPlugin, type InstanceRecord } from "@clusterio/controller";
|
||||
import type { IpcPlayerData } from "./messages";
|
||||
|
||||
import fs from "fs-extra";
|
||||
@@ -51,7 +51,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
this.controller.handle(msg.DatabaseStatsRequest, this.handleDatabaseStatsRequest.bind(this));
|
||||
}
|
||||
|
||||
async onInstanceStatusChanged(instance: InstanceInfo) {
|
||||
async onInstanceStatusChanged(instance: InstanceRecord) {
|
||||
let instanceId = instance.id;
|
||||
if (["unassigned", "deleted"].includes(instance.status)) {
|
||||
for (let [playerName, acquisitionRecord] of this.acquiredPlayers) {
|
||||
|
||||
@@ -152,13 +152,13 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
for (let [player, entry] of this.players) {
|
||||
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
|
||||
if (entry.verifyCode === verifyCode) {
|
||||
let user = this.controller.userManager.getByName(player);
|
||||
let user = this.controller.users.getByName(player);
|
||||
if (!user) {
|
||||
res.send({ error: true, message: "invalid user" });
|
||||
return;
|
||||
}
|
||||
|
||||
let token = this.controller.userManager.signUserToken(user);
|
||||
let token = this.controller.users.signUserToken(user);
|
||||
res.send({ verified: true, token });
|
||||
return;
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("player_auth", function() {
|
||||
describe("/api/player_auth/servers", function() {
|
||||
it("should return a list of running servers with player_auth loaded", async function() {
|
||||
function addInstance(id, status, load, name) {
|
||||
controllerPlugin.controller.instances.set(id, {
|
||||
controllerPlugin.controller.instances.records.set(id, {
|
||||
config: {
|
||||
get(field) {
|
||||
if (field === "player_auth.load_plugin") {
|
||||
@@ -85,7 +85,7 @@ describe("player_auth", function() {
|
||||
const result = await fetch(`${controllerUrl}/api/player_auth/servers`);
|
||||
assert.deepEqual(await result.json(), ["running loaded", "unnamed server"]);
|
||||
for (let id of [1, 2, 3, 4, 5]) {
|
||||
controllerPlugin.controller.instances.delete(id);
|
||||
controllerPlugin.controller.instances.records.delete(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -286,8 +286,8 @@ describe("player_auth", function() {
|
||||
new SetVerifyCodeRequest("test", verify_code)
|
||||
);
|
||||
|
||||
const user = controllerPlugin.controller.userManager.getByName("test");
|
||||
const token = controllerPlugin.controller.userManager.signUserToken(user);
|
||||
const user = controllerPlugin.controller.users.getByName("test");
|
||||
const token = controllerPlugin.controller.users.signUserToken(user);
|
||||
const verifyResult = await postJSON(
|
||||
`${controllerUrl}/api/player_auth/verify`,
|
||||
{ player_code: playerCode, verify_code, verify_token }
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { compile } = require("@clusterio/lib");
|
||||
|
||||
/**
|
||||
* Generate a flat array of tests from a matrix of inputs.
|
||||
@@ -21,11 +22,16 @@ function testMatrix(...arrays) {
|
||||
* @param {T[]} tests - The tests inputs to pass to the class constructor.
|
||||
*/
|
||||
function testRoundTripJsonSerialisable(Class, tests) {
|
||||
const validate = compile(Class.jsonSchema);
|
||||
for (const test of tests) {
|
||||
const original = new Class(...test);
|
||||
const serialised = JSON.stringify(original);
|
||||
const reconstructed = Class.fromJSON(JSON.parse(serialised));
|
||||
const jsonObject = JSON.parse(serialised);
|
||||
const reconstructed = Class.fromJSON(jsonObject);
|
||||
assert.deepEqual(reconstructed, original, JSON.stringify(test));
|
||||
if (!validate(jsonObject)) {
|
||||
throw validate.errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+125
-60
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
const path = require("node:path");
|
||||
const assert = require("assert").strict;
|
||||
const { Controller, HostInfo, InstanceInfo } = require("@clusterio/controller");
|
||||
const { Controller, HostRecord, InstanceRecord, UserRecord } = require("@clusterio/controller");
|
||||
const { EventEmitter } = require("stream");
|
||||
|
||||
const {
|
||||
@@ -39,7 +39,7 @@ describe("controller/src/Controller", function() {
|
||||
mockInstanceConfig = new MockInstanceConfig(new Map([
|
||||
["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []],
|
||||
]));
|
||||
await controller.instanceCreate(mockInstanceConfig);
|
||||
await controller.instances.createInstance(mockInstanceConfig);
|
||||
});
|
||||
function check(list, ip, present = true) {
|
||||
if (present === false) {
|
||||
@@ -191,53 +191,9 @@ describe("controller/src/Controller", function() {
|
||||
|
||||
describe(".instances", function() {
|
||||
it("should set the dirty flag if the config is updated", function() {
|
||||
controller.instances.dirty = false;
|
||||
controller.instances.records.dirty = false;
|
||||
mockInstanceConfig.set("instance.assigned_host", null);
|
||||
assert(controller.instances.dirty === true, "dirty flag was not set");
|
||||
});
|
||||
});
|
||||
describe(".finaliseHosts()", function() {
|
||||
it("resets the connected state to false", function() {
|
||||
const hostInfo = new HostInfo(1, "", "", new Map(), true);
|
||||
Controller.finaliseHosts(hostInfo);
|
||||
assert.equal(hostInfo.connected, false);
|
||||
assert(hostInfo.updatedAtMs > 0, "updatedAtMs not incremented");
|
||||
});
|
||||
it("does not increment updatedAtMs when there are no changes", function() {
|
||||
const hostInfo = new HostInfo(1, "", "", new Map(), false);
|
||||
Controller.finaliseHosts(hostInfo);
|
||||
assert.equal(hostInfo.connected, false);
|
||||
assert(hostInfo.updatedAtMs === 0, "updatedAtMs incremented");
|
||||
});
|
||||
});
|
||||
describe(".finaliseInstances()", function() {
|
||||
it("resets the stats for assigned instances", function() {
|
||||
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 });
|
||||
const instanceInfo = new InstanceInfo(instanceConfig, "running");
|
||||
Controller.finaliseInstances(instanceInfo);
|
||||
assert.equal(instanceInfo.status, "unknown");
|
||||
assert(instanceInfo.updatedAtMs > 0, "updatedAtMs not incremented");
|
||||
});
|
||||
it("resets the status for unassigned instances", function() {
|
||||
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null });
|
||||
const instanceInfo = new InstanceInfo(instanceConfig, "running");
|
||||
Controller.finaliseInstances(instanceInfo);
|
||||
assert.equal(instanceInfo.status, "unassigned");
|
||||
assert(instanceInfo.updatedAtMs > 0, "updatedAtMs not incremented");
|
||||
});
|
||||
it("does not update updatedAtMs when there are no changes (assigned)", function() {
|
||||
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 });
|
||||
const instanceInfo = new InstanceInfo(instanceConfig, "unknown");
|
||||
Controller.finaliseInstances(instanceInfo);
|
||||
assert.equal(instanceInfo.status, "unknown");
|
||||
assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented");
|
||||
});
|
||||
it("does not update updatedAtMs when there are no changes (unassigned)", function() {
|
||||
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null });
|
||||
const instanceInfo = new InstanceInfo(instanceConfig, "unassigned");
|
||||
Controller.finaliseInstances(instanceInfo);
|
||||
assert.equal(instanceInfo.status, "unassigned");
|
||||
assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented");
|
||||
assert(controller.instances.records.dirty === true, "dirty flag was not set");
|
||||
});
|
||||
});
|
||||
describe(".checkRestartRequired()", function() {
|
||||
@@ -297,8 +253,117 @@ describe("controller/src/Controller", function() {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("finalise", function() {
|
||||
describe(".finaliseHosts()", function() {
|
||||
it("resets the connected state to false", function() {
|
||||
const hostInfo = new HostRecord(1, "", "", new Map(), true);
|
||||
Controller.finaliseHosts(hostInfo);
|
||||
assert.equal(hostInfo.connected, false);
|
||||
assert(hostInfo.updatedAtMs > 0, "updatedAtMs not incremented");
|
||||
});
|
||||
it("does not increment updatedAtMs when there are no changes", function() {
|
||||
const hostInfo = new HostRecord(1, "", "", new Map(), false);
|
||||
Controller.finaliseHosts(hostInfo);
|
||||
assert.equal(hostInfo.connected, false);
|
||||
assert(hostInfo.updatedAtMs === 0, "updatedAtMs incremented");
|
||||
});
|
||||
});
|
||||
describe(".finaliseInstances()", function() {
|
||||
it("resets the stats for assigned instances", function() {
|
||||
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 });
|
||||
const instanceRecord = new InstanceRecord(instanceConfig, "running");
|
||||
Controller.finaliseInstances(instanceRecord);
|
||||
assert.equal(instanceRecord.status, "unknown");
|
||||
assert(instanceRecord.updatedAtMs > 0, "updatedAtMs not incremented");
|
||||
});
|
||||
it("resets the status for unassigned instances", function() {
|
||||
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null });
|
||||
const instanceRecord = new InstanceRecord(instanceConfig, "running");
|
||||
Controller.finaliseInstances(instanceRecord);
|
||||
assert.equal(instanceRecord.status, "unassigned");
|
||||
assert(instanceRecord.updatedAtMs > 0, "updatedAtMs not incremented");
|
||||
});
|
||||
it("does not update updatedAtMs when there are no changes (assigned)", function() {
|
||||
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 });
|
||||
const instanceRecord = new InstanceRecord(instanceConfig, "unknown");
|
||||
Controller.finaliseInstances(instanceRecord);
|
||||
assert.equal(instanceRecord.status, "unknown");
|
||||
assert(instanceRecord.updatedAtMs === 0, "updatedAtMs incremented");
|
||||
});
|
||||
it("does not update updatedAtMs when there are no changes (unassigned)", function() {
|
||||
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null });
|
||||
const instanceRecord = new InstanceRecord(instanceConfig, "unassigned");
|
||||
Controller.finaliseInstances(instanceRecord);
|
||||
assert.equal(instanceRecord.status, "unassigned");
|
||||
assert(instanceRecord.updatedAtMs === 0, "updatedAtMs incremented");
|
||||
});
|
||||
});
|
||||
describe(".finaliseUsers()", function() {
|
||||
it("returns the user when there is no existing user", function() {
|
||||
const user = new UserRecord(0, "TestUser", new Set([1]));
|
||||
user.playerStats.onlineTimeMs = 100;
|
||||
|
||||
const users = new Map();
|
||||
const result = Controller.finaliseUsers(user, users);
|
||||
assert.equal(users.size, 0);
|
||||
assert.equal(result, user);
|
||||
});
|
||||
it("does not merge users with different ids", function() {
|
||||
const existingUser = new UserRecord(0, "UserOne", new Set([1]));
|
||||
existingUser.playerStats.onlineTimeMs = 100;
|
||||
|
||||
const otherUser = new UserRecord(0, "UserTwo", new Set([2]));
|
||||
otherUser.playerStats.onlineTimeMs = 50;
|
||||
|
||||
const users = new Map([[existingUser.id, existingUser]]);
|
||||
const result = Controller.finaliseUsers(otherUser, users);
|
||||
assert.equal(users.size, 1);
|
||||
assert.equal(result, otherUser);
|
||||
assert(!result.roleIds.has(1), "users were merged");
|
||||
});
|
||||
it("merges into existing user when onlineTimeMs is lower", function() {
|
||||
const existingUser = new UserRecord(0, "TestUser", new Set([1]));
|
||||
existingUser.playerStats.onlineTimeMs = 100;
|
||||
|
||||
const user = new UserRecord(0, "TestUser", new Set([2]));
|
||||
user.playerStats.onlineTimeMs = 50;
|
||||
|
||||
const users = new Map([[existingUser.id, existingUser]]);
|
||||
const result = Controller.finaliseUsers(user, users);
|
||||
assert.equal(users.size, 1);
|
||||
assert.equal(result, existingUser);
|
||||
assert(result.roleIds.has(2), "other user not merged");
|
||||
});
|
||||
it("merges into existing user when onlineTimeMs is equal", function() {
|
||||
const existingUser = new UserRecord(0, "TestUser", new Set([1]));
|
||||
existingUser.playerStats.onlineTimeMs = 100;
|
||||
|
||||
const user = new UserRecord(0, "TestUser", new Set([2]));
|
||||
user.playerStats.onlineTimeMs = 100;
|
||||
|
||||
const users = new Map([[existingUser.id, existingUser]]);
|
||||
const result = Controller.finaliseUsers(user, users);
|
||||
assert.equal(users.size, 1);
|
||||
assert.equal(result, existingUser);
|
||||
assert(result.roleIds.has(2), "other user not merged");
|
||||
});
|
||||
it("merges existing user into new user when onlineTimeMs is higher", function() {
|
||||
const existingUser = new UserRecord(0, "TestUser", new Set([1]));
|
||||
existingUser.playerStats.onlineTimeMs = 50;
|
||||
|
||||
const user = new UserRecord(0, "TestUser", new Set([2]));
|
||||
user.playerStats.onlineTimeMs = 100;
|
||||
|
||||
const users = new Map([[existingUser.id, existingUser]]);
|
||||
const result = Controller.finaliseUsers(user, users);
|
||||
assert.equal(users.size, 1);
|
||||
assert.equal(result, user);
|
||||
assert(result.roleIds.has(1), "existing user not merged");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("migrations", function() {
|
||||
describe("SystemInfo", function() {
|
||||
describe(".migrateSystems()", function() {
|
||||
it("migrates 'canRestart' from undefined to false", function() { // Alpha 17
|
||||
const result = Controller.migrateSystems([
|
||||
{}, { canRestart: true }, { canRestart: false },
|
||||
@@ -350,7 +415,7 @@ describe("controller/src/Controller", function() {
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe("HostInfo", function() {
|
||||
describe(".migrateHosts()", function() {
|
||||
it("migrates to the new format", function() { // Alpha 19
|
||||
const result = Controller.migrateHosts([
|
||||
["", "foo"], ["", "bar"], ["", "baz"],
|
||||
@@ -360,7 +425,7 @@ describe("controller/src/Controller", function() {
|
||||
]);
|
||||
});
|
||||
it("does nothing for upto date data", function() {
|
||||
const hostInfo = new HostInfo(1, "", "", new Map());
|
||||
const hostInfo = new HostRecord(1, "", "", new Map());
|
||||
const jsonString = JSON.stringify(hostInfo);
|
||||
const hostInfoJson = JSON.parse(jsonString);
|
||||
const hostInfoJsonCopy = JSON.parse(jsonString);
|
||||
@@ -372,7 +437,7 @@ describe("controller/src/Controller", function() {
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe("InstanceInfo", function() {
|
||||
describe(".migrateInstances()", function() {
|
||||
it("migrates to the new format", function() { // Alpha 14
|
||||
const result = Controller.migrateInstances([
|
||||
{ name: "foo" }, { name: "bar" }, { name: "baz" },
|
||||
@@ -385,19 +450,19 @@ describe("controller/src/Controller", function() {
|
||||
});
|
||||
it("does nothing for upto date data", function() {
|
||||
const instanceConfig = new InstanceConfig("controller");
|
||||
const instanceInfo = new InstanceInfo(instanceConfig, "unknown");
|
||||
const jsonString = JSON.stringify(instanceInfo);
|
||||
const instanceInfoJson = JSON.parse(jsonString);
|
||||
const instanceInfoJsonCopy = JSON.parse(jsonString);
|
||||
const instanceRecord = new InstanceRecord(instanceConfig, "unknown");
|
||||
const jsonString = JSON.stringify(instanceRecord);
|
||||
const instanceRecordJson = JSON.parse(jsonString);
|
||||
const instanceRecordJsonCopy = JSON.parse(jsonString);
|
||||
const result = Controller.migrateInstances([
|
||||
instanceInfoJson, instanceInfoJson, instanceInfoJson,
|
||||
instanceRecordJson, instanceRecordJson, instanceRecordJson,
|
||||
]);
|
||||
assert.deepEqual(result, [
|
||||
instanceInfoJsonCopy, instanceInfoJsonCopy, instanceInfoJsonCopy,
|
||||
instanceRecordJsonCopy, instanceRecordJsonCopy, instanceRecordJsonCopy,
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe("ModPack", function() {
|
||||
describe(".migrateModPacks()", function() {
|
||||
it("migrates builtin mod versions to X.Y.Z", function() {
|
||||
const modPacks = Controller.migrateModPacks([{
|
||||
id: 0,
|
||||
@@ -439,7 +504,7 @@ describe("controller/src/Controller", function() {
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe("Role", function() {
|
||||
describe(".migrateRoles()", function() {
|
||||
it("migrates renamed permissions", function() { // Alpha 17
|
||||
const result = Controller.migrateRoles([
|
||||
{ permissions: ["foo", "bar", "core.instance.save.list.subscribe"] },
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { ControllerUser } = require("@clusterio/controller");
|
||||
const lib = require("@clusterio/lib");
|
||||
const { User } = require("@clusterio/controller");
|
||||
|
||||
describe("controller/src/ControllerUser", function() {
|
||||
describe("class ControllerUser", function() {
|
||||
describe("controller/src/User", function() {
|
||||
describe("class User", function() {
|
||||
const roles = new Map([
|
||||
[1, new lib.Role(1, "a", "a role", new Set(["core.admin"]))],
|
||||
[2, new lib.Role(2, "b", "b role", new Set(["user-test"]))],
|
||||
]);
|
||||
|
||||
it("should round trip serialize", function() {
|
||||
function test_roundtrip(serialized) {
|
||||
let user = ControllerUser.fromJSON(serialized, roles);
|
||||
let user_serialized = user.toJSON(true);
|
||||
function testRoundTrip(serialized) {
|
||||
let user = User.fromJSON(serialized, {}, roles);
|
||||
let user_serialized = user.toJSON();
|
||||
assert.deepEqual(user_serialized, serialized);
|
||||
let user_deserialized = ControllerUser.fromJSON(user_serialized, roles);
|
||||
let user_deserialized = User.fromJSON(user_serialized, {}, roles);
|
||||
assert.deepEqual(user_deserialized, user);
|
||||
}
|
||||
|
||||
test_roundtrip({ name: "admin", roles: [1] });
|
||||
test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 });
|
||||
test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true });
|
||||
test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" });
|
||||
test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]});
|
||||
testRoundTrip({ name: "admin", roles: [1] });
|
||||
testRoundTrip({ name: "user", roles: [2], token_valid_after: 12345 });
|
||||
testRoundTrip({ name: "user", is_admin: true, is_whitelisted: true });
|
||||
testRoundTrip({ name: "user", is_banned: true, ban_reason: "Bad user" });
|
||||
testRoundTrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]});
|
||||
});
|
||||
describe(".checkPermission()", function() {
|
||||
it("should correctly resolve permissions", function() {
|
||||
lib.definePermission({ name: "user-test", title: "Test", description: "User Test" });
|
||||
let a = ControllerUser.fromJSON({ name: "admin", roles: [1] }, roles);
|
||||
let b = ControllerUser.fromJSON({ name: "user", roles: [2] }, roles);
|
||||
let c = ControllerUser.fromJSON({ name: "null", roles: [] }, roles);
|
||||
let a = User.fromJSON({ name: "admin", roles: [1] }, {}, roles);
|
||||
let b = User.fromJSON({ name: "user", roles: [2] }, {}, roles);
|
||||
let c = User.fromJSON({ name: "null", roles: [] }, {}, roles);
|
||||
|
||||
a.checkPermission("core.control.connect");
|
||||
assert.throws(() => b.checkPermission("core.control.connect"), new Error("Permission denied"));
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { HostDetails } = require("@clusterio/lib");
|
||||
const { HostRecord } = require("@clusterio/controller");
|
||||
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
|
||||
|
||||
describe("controller/HostRecord", function () {
|
||||
const samplePlugins = new Map([
|
||||
["pluginA", "1.0.0"],
|
||||
["pluginB", "2.3.4"],
|
||||
]);
|
||||
|
||||
it("should be round trip serialisable", function () {
|
||||
testRoundTripJsonSerialisable(HostRecord, testMatrix(
|
||||
[1], // id
|
||||
["TestHost"], // name
|
||||
["1.0.0"], // version
|
||||
[samplePlugins, new Map()], // plugins
|
||||
[true, false], // connected
|
||||
[undefined, "10.0.0.1"], // remoteAddress
|
||||
[undefined, "203.0.113.1"], // publicAddress
|
||||
[undefined, 123456], // tokenValidAfter
|
||||
[undefined, 999999], // updatedAtMs
|
||||
[undefined, true], // isDeleted
|
||||
));
|
||||
});
|
||||
|
||||
describe("constructor", function () {
|
||||
it("should set defaults correctly", function () {
|
||||
const record = new HostRecord(1, "TestHost", "1.0.0", new Map());
|
||||
|
||||
assert.equal(record.id, 1);
|
||||
assert.equal(record.name, "TestHost");
|
||||
assert.equal(record.version, "1.0.0");
|
||||
assert.deepEqual(record.plugins, new Map());
|
||||
assert.equal(record.connected, false);
|
||||
assert.equal(record.remoteAddress, "");
|
||||
assert.equal(record.publicAddress, "");
|
||||
assert.equal(record.tokenValidAfter, 0);
|
||||
assert.equal(record.updatedAtMs, 0);
|
||||
assert.equal(record.isDeleted, false);
|
||||
});
|
||||
|
||||
it("should accept all constructor parameters", function () {
|
||||
const record = new HostRecord(
|
||||
42, "FullHost", "2.0.0", samplePlugins,
|
||||
true, "10.1.1.1", "198.51.100.5",
|
||||
123, 999, true
|
||||
);
|
||||
|
||||
assert.equal(record.id, 42);
|
||||
assert.equal(record.name, "FullHost");
|
||||
assert.equal(record.version, "2.0.0");
|
||||
assert.deepEqual(record.plugins, samplePlugins);
|
||||
assert.equal(record.connected, true);
|
||||
assert.equal(record.remoteAddress, "10.1.1.1");
|
||||
assert.equal(record.publicAddress, "198.51.100.5");
|
||||
assert.equal(record.tokenValidAfter, 123);
|
||||
assert.equal(record.updatedAtMs, 999);
|
||||
assert.equal(record.isDeleted, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toJSON()", function () {
|
||||
it("should only include required fields when optional fields are not given", function () {
|
||||
const record = new HostRecord(1, "TestHost", "1.0.0", samplePlugins);
|
||||
|
||||
assert.deepEqual(record.toJSON(), {
|
||||
id: 1,
|
||||
name: "TestHost",
|
||||
version: "1.0.0",
|
||||
plugins: Object.fromEntries(samplePlugins),
|
||||
});
|
||||
});
|
||||
|
||||
it("should include all optional fields when set", function () {
|
||||
const record = new HostRecord(
|
||||
1, "TestHost", "1.0.0", samplePlugins,
|
||||
true, "10.0.0.1", "203.0.113.1",
|
||||
100, 500, true
|
||||
);
|
||||
|
||||
assert.deepEqual(record.toJSON(), {
|
||||
id: 1,
|
||||
name: "TestHost",
|
||||
version: "1.0.0",
|
||||
plugins: Object.fromEntries(samplePlugins),
|
||||
connected: true,
|
||||
remote_address: "10.0.0.1",
|
||||
public_address: "203.0.113.1",
|
||||
token_valid_after: 100,
|
||||
updated_at_ms: 500,
|
||||
is_deleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit optional fields when equal to defaults", function () {
|
||||
const record = new HostRecord(
|
||||
1, "TestHost", "1.0.0", samplePlugins,
|
||||
false, "", "",
|
||||
0, 0, false
|
||||
);
|
||||
|
||||
assert.deepEqual(record.toJSON(), {
|
||||
id: 1,
|
||||
name: "TestHost",
|
||||
version: "1.0.0",
|
||||
plugins: Object.fromEntries(samplePlugins),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromJSON()", function () {
|
||||
it("should construct minimal object", function () {
|
||||
const record = HostRecord.fromJSON({
|
||||
id: 7,
|
||||
name: "JSONHost",
|
||||
version: "1.1.0",
|
||||
plugins: { pluginX: "0.1.0" },
|
||||
});
|
||||
|
||||
assert.equal(record.id, 7);
|
||||
assert.equal(record.name, "JSONHost");
|
||||
assert.equal(record.version, "1.1.0");
|
||||
assert.deepEqual(record.plugins, new Map([["pluginX", "0.1.0"]]));
|
||||
assert.equal(record.connected, false);
|
||||
assert.equal(record.remoteAddress, "");
|
||||
assert.equal(record.publicAddress, "");
|
||||
assert.equal(record.tokenValidAfter, 0);
|
||||
assert.equal(record.updatedAtMs, 0);
|
||||
assert.equal(record.isDeleted, false);
|
||||
});
|
||||
|
||||
it("should preserve optional fields exactly when provided", function () {
|
||||
const record = HostRecord.fromJSON({
|
||||
id: 7,
|
||||
name: "JSONHost",
|
||||
version: "1.1.0",
|
||||
plugins: { pluginX: "0.1.0" },
|
||||
connected: true,
|
||||
remote_address: "10.1.1.1",
|
||||
public_address: "198.51.100.10",
|
||||
token_valid_after: 200,
|
||||
updated_at_ms: 1000,
|
||||
is_deleted: true,
|
||||
});
|
||||
|
||||
assert.equal(record.id, 7);
|
||||
assert.equal(record.name, "JSONHost");
|
||||
assert.equal(record.version, "1.1.0");
|
||||
assert.deepEqual(record.plugins, new Map([["pluginX", "0.1.0"]]));
|
||||
assert.equal(record.connected, true);
|
||||
assert.equal(record.remoteAddress, "10.1.1.1");
|
||||
assert.equal(record.publicAddress, "198.51.100.10");
|
||||
assert.equal(record.tokenValidAfter, 200);
|
||||
assert.equal(record.updatedAtMs, 1000);
|
||||
assert.equal(record.isDeleted, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toHostDetails()", function () {
|
||||
it("should convert HostRecord to HostDetails correctly", function () {
|
||||
const record = new HostRecord(
|
||||
1, "TestHost", "1.0.0", samplePlugins,
|
||||
true, "10.0.0.1", "203.0.113.1",
|
||||
123, 456, true
|
||||
);
|
||||
|
||||
const details = record.toHostDetails();
|
||||
|
||||
assert(details instanceof HostDetails);
|
||||
assert.equal(details.version, record.version);
|
||||
assert.equal(details.name, record.name);
|
||||
assert.equal(details.id, record.id);
|
||||
assert.equal(details.connected, record.connected);
|
||||
assert.equal(details.remoteAddress, record.remoteAddress);
|
||||
assert.equal(details.publicAddress, record.publicAddress);
|
||||
assert.equal(details.tokenValidAfter, record.tokenValidAfter);
|
||||
assert.equal(details.updatedAtMs, record.updatedAtMs);
|
||||
assert.equal(details.isDeleted, record.isDeleted);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromHostDetails()", function () {
|
||||
it("should create HostRecord from HostDetails", function () {
|
||||
const details = new HostDetails("1.0.0", "TestHost", 1, true);
|
||||
|
||||
const record = HostRecord.fromHostDetails(details, samplePlugins);
|
||||
|
||||
assert(record instanceof HostRecord);
|
||||
assert.equal(record.id, details.id);
|
||||
assert.equal(record.name, details.name);
|
||||
assert.equal(record.version, details.version);
|
||||
assert.deepEqual(record.plugins, samplePlugins);
|
||||
assert.equal(record.connected, details.connected);
|
||||
assert.equal(record.remoteAddress, details.remoteAddress);
|
||||
assert.equal(record.publicAddress, details.publicAddress);
|
||||
assert.equal(record.tokenValidAfter, details.tokenValidAfter);
|
||||
assert.equal(record.updatedAtMs, details.updatedAtMs);
|
||||
assert.equal(record.isDeleted, details.isDeleted);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
|
||||
const { InstanceInfo } = require("@clusterio/controller");
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
|
||||
describe("controller/InstanceInfo", function() {
|
||||
describe("class InstanceInfo", function() {
|
||||
it("should round trip serialize", function() {
|
||||
const validate = lib.compile(InstanceInfo.jsonSchema);
|
||||
function check(info) {
|
||||
const json = JSON.parse(JSON.stringify(info));
|
||||
if (!validate(json)) {
|
||||
throw validate.errors;
|
||||
}
|
||||
assert.deepEqual(InstanceInfo.fromJSON(json, "controller"), info);
|
||||
}
|
||||
|
||||
const config = new lib.InstanceConfig("controller").toJSON();
|
||||
|
||||
check(InstanceInfo.fromJSON({ config, status: "stopped" }, "controller"));
|
||||
check(InstanceInfo.fromJSON({ config, status: "running" }, "controller"));
|
||||
check(InstanceInfo.fromJSON({ config, status: "running", gamePort: 34197 }, "controller"));
|
||||
check(InstanceInfo.fromJSON({ config, status: "running", updatedAtMs: Date.now() }, "controller"));
|
||||
|
||||
// All at once
|
||||
check(InstanceInfo.fromJSON({
|
||||
config,
|
||||
status: "unknown",
|
||||
gamePort: 10000,
|
||||
updateAt: 20000,
|
||||
}, "controller"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
const { Controller, InstanceRecord, InstanceManager } = require("@clusterio/controller");
|
||||
|
||||
describe("controller/InstanceManager", function () {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {InstanceManager} */
|
||||
let instances;
|
||||
/** @type {InstanceRecord} */
|
||||
let instance;
|
||||
|
||||
beforeEach(function () {
|
||||
const controllerConfig = new lib.ControllerConfig("controller", {
|
||||
"controller.name": "TestController",
|
||||
});
|
||||
const instanceConfig = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 1,
|
||||
"instance.name": "Test",
|
||||
});
|
||||
|
||||
const datastoreInstances = new lib.SubscribableDatastore();
|
||||
datastoreInstances.set(new InstanceRecord(instanceConfig, "unassigned"));
|
||||
|
||||
controller = new Controller(lib.logger, [], controllerConfig,
|
||||
undefined, undefined, undefined, undefined, datastoreInstances);
|
||||
instances = controller.instances;
|
||||
instance = instances.getMutable(1);
|
||||
});
|
||||
|
||||
describe("constructor()", function () {
|
||||
it("should call _notifyConfigFieldChanged when config changes", async function () {
|
||||
let called = false;
|
||||
|
||||
instances._notifyConfigFieldChanged = async (
|
||||
inst, field, curr, prev
|
||||
) => {
|
||||
called = true;
|
||||
assert.equal(inst.id, 1);
|
||||
assert.equal(field, "instance.name");
|
||||
assert.equal(prev, "Test");
|
||||
assert.equal(curr, "Updated");
|
||||
};
|
||||
|
||||
instance.config.set("instance.name", "Updated");
|
||||
|
||||
assert.equal(called, true);
|
||||
});
|
||||
|
||||
it("should update datastore when config changes", function () {
|
||||
let setCalled = false;
|
||||
|
||||
const originalSet = instances.records.set.bind(instances.records);
|
||||
instances.records.set = function (value) {
|
||||
setCalled = true;
|
||||
return originalSet(value);
|
||||
};
|
||||
|
||||
instance.config.set("instance.name", "Changed");
|
||||
|
||||
assert.equal(setCalled, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".has() / .get() / .getMutable()", function () {
|
||||
it("should return instance when exists", async function () {
|
||||
assert.equal(instances.has(instance.id), true);
|
||||
assert.equal(instances.get(instance.id), instance);
|
||||
assert.equal(instances.getMutable(instance.id), instance);
|
||||
});
|
||||
|
||||
it("should return undefined when missing", function () {
|
||||
assert.equal(instances.has(999), false);
|
||||
assert.equal(instances.get(999), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".values() / .valuesMutable()", function () {
|
||||
it("should iterate all instances", async function () {
|
||||
const config1 = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 2,
|
||||
"instance.name": "A",
|
||||
});
|
||||
const config2 = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 3,
|
||||
"instance.name": "B",
|
||||
});
|
||||
|
||||
const i1 = await instances.createInstance(config1);
|
||||
const i2 = await instances.createInstance(config2);
|
||||
|
||||
assert.deepEqual(Array.from(instances.values()), [instance, i1, i2]);
|
||||
assert.deepEqual(Array.from(instances.valuesMutable()), [instance, i1, i2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".getForRequest()", function () {
|
||||
it("should return instance when exists", async function () {
|
||||
assert.equal(instances.getForRequest(1), instance);
|
||||
});
|
||||
|
||||
it("should throw when missing", function () {
|
||||
assert.throws(() => instances.getForRequest(999), lib.RequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".createInstance()", function () {
|
||||
it("should create new instance with id 2", async function () {
|
||||
let notified = false;
|
||||
instances._notifyStatusChanged = async () => { notified = true; };
|
||||
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 2,
|
||||
"instance.name": "Second",
|
||||
});
|
||||
|
||||
const created = await instances.createInstance(config);
|
||||
|
||||
assert.equal(created.id, 2);
|
||||
assert(instances.get(2), created);
|
||||
assert.equal(notified, true);
|
||||
});
|
||||
|
||||
it("should call notify on config change", async function () {
|
||||
let notified = false;
|
||||
instances._notifyConfigFieldChanged = async () => { notified = true; };
|
||||
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 2,
|
||||
"instance.name": "Second",
|
||||
});
|
||||
|
||||
const created = await instances.createInstance(config);
|
||||
|
||||
created.config.set("instance.name", "Updated");
|
||||
|
||||
assert.equal(created.id, 2);
|
||||
assert.equal(instances.get(2), created);
|
||||
assert.equal(notified, true);
|
||||
});
|
||||
|
||||
it("should throw if id already exists", async function () {
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 1,
|
||||
"instance.name": "Duplicate",
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => instances.createInstance(config),
|
||||
lib.RequestError
|
||||
);
|
||||
});
|
||||
|
||||
it("should suppress changes when flag set", async function () {
|
||||
let notified = false;
|
||||
instances._notifyStatusChanged = async () => { notified = true; };
|
||||
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 2,
|
||||
"instance.name": "Suppressed",
|
||||
});
|
||||
|
||||
const created = await instances.createInstance(config, true);
|
||||
|
||||
assert.equal(instances.get(2), undefined);
|
||||
assert.equal(notified, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".assignInstance()", function () {
|
||||
it("should throw if host not connected", async function () {
|
||||
await assert.rejects(
|
||||
() => instances.assignInstance(1, 5),
|
||||
lib.RequestError
|
||||
);
|
||||
});
|
||||
|
||||
it("should do nothing if the host would not change", async function () {
|
||||
let assignRequest;
|
||||
controller.wsServer.hostConnections.set(5, {
|
||||
send: async (request) => {
|
||||
assignRequest = request;
|
||||
},
|
||||
connector: { closing: false },
|
||||
});
|
||||
|
||||
instance.config.set("instance.assigned_host", 5);
|
||||
|
||||
await instances.assignInstance(1, 5);
|
||||
assert.equal(assignRequest, undefined);
|
||||
});
|
||||
|
||||
it("should not unassign if new host is not connected", async function () {
|
||||
let unassignRequest;
|
||||
controller.wsServer.hostConnections.set(5, {
|
||||
send: async (request) => {
|
||||
unassignRequest = request;
|
||||
},
|
||||
connector: { closing: false },
|
||||
});
|
||||
|
||||
instance.config.set("instance.assigned_host", 5);
|
||||
|
||||
await assert.rejects(
|
||||
() => instances.assignInstance(1, 6),
|
||||
lib.RequestError
|
||||
);
|
||||
|
||||
assert.equal(unassignRequest, undefined);
|
||||
});
|
||||
|
||||
it("should assign to connected host", async function () {
|
||||
let assignRequest;
|
||||
controller.wsServer.hostConnections.set(5, {
|
||||
send: async (request) => {
|
||||
assignRequest = request;
|
||||
},
|
||||
connector: { closing: false },
|
||||
});
|
||||
|
||||
await instances.assignInstance(1, 5);
|
||||
|
||||
assert.equal(instance.config.get("instance.assigned_host"), 5);
|
||||
assert(assignRequest instanceof lib.InstanceAssignInternalRequest);
|
||||
assert.equal(assignRequest.instanceId, 1);
|
||||
});
|
||||
|
||||
it("should not fail when host has id of 0", async function () {
|
||||
let lastEvent;
|
||||
controller.wsServer.hostConnections.set(0, {
|
||||
send: async (request) => {
|
||||
lastEvent = request;
|
||||
},
|
||||
connector: { closing: false },
|
||||
});
|
||||
|
||||
await instances.assignInstance(1, 0);
|
||||
|
||||
assert.equal(instance.config.get("instance.assigned_host"), 0);
|
||||
assert(lastEvent instanceof lib.InstanceAssignInternalRequest);
|
||||
assert.equal(lastEvent.instanceId, 1);
|
||||
|
||||
await instances.assignInstance(1, null);
|
||||
|
||||
assert.equal(instance.config.get("instance.assigned_host"), null);
|
||||
assert(lastEvent instanceof lib.InstanceUnassignInternalRequest);
|
||||
assert.equal(lastEvent.instanceId, 1);
|
||||
});
|
||||
|
||||
it("should unassign old host if connected", async function () {
|
||||
let unassignRequest;
|
||||
controller.wsServer.hostConnections.set(5, {
|
||||
send: async (request) => {
|
||||
unassignRequest = request;
|
||||
},
|
||||
connector: { closing: false },
|
||||
});
|
||||
|
||||
let assignRequest;
|
||||
controller.wsServer.hostConnections.set(6, {
|
||||
send: async (request) => {
|
||||
assignRequest = request;
|
||||
},
|
||||
connector: { closing: false },
|
||||
});
|
||||
|
||||
instance.config.set("instance.assigned_host", 5);
|
||||
await instances.assignInstance(1, 6);
|
||||
|
||||
assert(unassignRequest instanceof lib.InstanceUnassignInternalRequest);
|
||||
assert.equal(unassignRequest.instanceId, 1);
|
||||
assert(assignRequest instanceof lib.InstanceAssignInternalRequest);
|
||||
assert.equal(assignRequest.instanceId, 1);
|
||||
});
|
||||
|
||||
it("should not unassign if old host closing", async function () {
|
||||
let unassignRequest;
|
||||
controller.wsServer.hostConnections.set(5, {
|
||||
send: async (request) => {
|
||||
unassignRequest = request;
|
||||
},
|
||||
connector: { closing: true },
|
||||
});
|
||||
|
||||
let assignRequest;
|
||||
controller.wsServer.hostConnections.set(6, {
|
||||
send: async (request) => {
|
||||
assignRequest = request;
|
||||
},
|
||||
connector: { closing: false },
|
||||
});
|
||||
|
||||
instance.config.set("instance.assigned_host", 5);
|
||||
await instances.assignInstance(1, 6);
|
||||
|
||||
assert.equal(unassignRequest, undefined);
|
||||
assert(assignRequest instanceof lib.InstanceAssignInternalRequest);
|
||||
assert.equal(assignRequest.instanceId, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".unassignInstance()", function () {
|
||||
it("should unassign an instance", async function () {
|
||||
controller.wsServer.hostConnections.set(5, {
|
||||
send: async () => {},
|
||||
connector: { closing: true },
|
||||
});
|
||||
|
||||
await instances.assignInstance(instance.id, 5);
|
||||
await instances.unassignInstance(instance.id);
|
||||
assert.equal(instance.config.get("instance.assigned_host"), null);
|
||||
assert.equal(instance.status, "unassigned");
|
||||
});
|
||||
it("should do nothing if the host would not change", async function () {
|
||||
instance.config.set("instance.assigned_host", null);
|
||||
|
||||
let fieldChanged;
|
||||
instance.config.on("fieldChanged", name => {
|
||||
fieldChanged = name;
|
||||
});
|
||||
|
||||
await instances.unassignInstance(1);
|
||||
assert.equal(fieldChanged, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".deleteInstance()", function () {
|
||||
it("should delete and notify", async function () {
|
||||
let notified = false;
|
||||
instances._notifyStatusChanged = async () => { notified = true; };
|
||||
|
||||
await instances.deleteInstance(1);
|
||||
|
||||
assert.equal(instances.get(1), undefined);
|
||||
assert.equal(notified, true);
|
||||
});
|
||||
|
||||
it("should send delete to host if assigned", async function () {
|
||||
let sentRequest;
|
||||
controller.sendTo = async (_target, request) => {
|
||||
sentRequest = request;
|
||||
};
|
||||
|
||||
controller.wsServer.hostConnections.set(5, {
|
||||
send: async () => {},
|
||||
connector: { closing: false },
|
||||
});
|
||||
|
||||
await instances.assignInstance(1, 5);
|
||||
await instances.deleteInstance(1);
|
||||
|
||||
assert(sentRequest instanceof lib.InstanceDeleteInternalRequest);
|
||||
assert.equal(sentRequest.instanceId, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const lib = require("@clusterio/lib");
|
||||
const { InstanceRecord } = require("@clusterio/controller");
|
||||
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
|
||||
|
||||
describe("controller/InstanceRecord", function () {
|
||||
/** @type {lib.InstanceConfig} */
|
||||
let defaultConfig;
|
||||
|
||||
before(function() {
|
||||
// This is needed to pick up any plugins that are loaded
|
||||
defaultConfig = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 1,
|
||||
"instance.name": "TestInstance",
|
||||
"instance.assigned_host": 1,
|
||||
"instance.exclude_from_start_all": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should be round trip serialisable", function () {
|
||||
testRoundTripJsonSerialisable(InstanceRecord, testMatrix(
|
||||
[defaultConfig], // config
|
||||
["running", "stopped"], // status
|
||||
[undefined, 34197], // gamePort
|
||||
[undefined, "1.0.0"], // factorioVersion
|
||||
[undefined, 1234], // startedAtMs
|
||||
[undefined, 5678], // updatedAtMs
|
||||
));
|
||||
});
|
||||
|
||||
describe("constructor", function () {
|
||||
it("should set defaults correctly", function () {
|
||||
const record = new InstanceRecord(defaultConfig, "running");
|
||||
|
||||
assert.equal(record.config, defaultConfig);
|
||||
assert.equal(record.status, "running");
|
||||
assert.equal(record.gamePort, undefined);
|
||||
assert.equal(record.factorioVersion, undefined);
|
||||
assert.equal(record.startedAtMs, 0);
|
||||
assert.equal(record.updatedAtMs, 0);
|
||||
});
|
||||
|
||||
it("should accept all constructor parameters", function () {
|
||||
const record = new InstanceRecord(
|
||||
defaultConfig, "starting",
|
||||
34197, "1.0.0",
|
||||
100, 200
|
||||
);
|
||||
|
||||
assert.equal(record.status, "starting");
|
||||
assert.equal(record.gamePort, 34197);
|
||||
assert.equal(record.factorioVersion, "1.0.0");
|
||||
assert.equal(record.startedAtMs, 100);
|
||||
assert.equal(record.updatedAtMs, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toJSON()", function () {
|
||||
it("should include required fields when optional fields are not given", function () {
|
||||
const record = new InstanceRecord(defaultConfig, "running");
|
||||
|
||||
assert.deepEqual(record.toJSON(), {
|
||||
config: defaultConfig.toJSON(),
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("should include optional fields when non-default", function () {
|
||||
const record = new InstanceRecord(
|
||||
defaultConfig, "running",
|
||||
34197, "1.0.0",
|
||||
100, 200
|
||||
);
|
||||
|
||||
assert.deepEqual(record.toJSON(), {
|
||||
config: defaultConfig.toJSON(),
|
||||
status: "running",
|
||||
gamePort: 34197,
|
||||
factorioVersion: "1.0.0",
|
||||
startedAtMs: 100,
|
||||
updatedAtMs: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit optional fields when equal to defaults", function () {
|
||||
const record = new InstanceRecord(
|
||||
defaultConfig, "running",
|
||||
undefined, undefined,
|
||||
undefined, undefined
|
||||
);
|
||||
|
||||
assert.deepEqual(record.toJSON(), {
|
||||
config: defaultConfig.toJSON(),
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromJSON()", function () {
|
||||
it("should construct minimal object", function () {
|
||||
const json = {
|
||||
config: defaultConfig.toJSON(),
|
||||
status: "stopped",
|
||||
};
|
||||
|
||||
const record = InstanceRecord.fromJSON(json);
|
||||
|
||||
assert.equal(record.status, "stopped");
|
||||
assert.equal(record.gamePort, undefined);
|
||||
assert.equal(record.factorioVersion, undefined);
|
||||
assert.equal(record.startedAtMs, 0);
|
||||
assert.equal(record.updatedAtMs, 0);
|
||||
});
|
||||
|
||||
it("should preserve optional fields exactly when provided", function () {
|
||||
const json = {
|
||||
config: defaultConfig.toJSON(),
|
||||
status: "stopped",
|
||||
gamePort: 34197,
|
||||
factorioVersion: "1.0.0",
|
||||
startedAtMs: 100,
|
||||
updatedAtMs: 200,
|
||||
};
|
||||
|
||||
const record = InstanceRecord.fromJSON(json);
|
||||
|
||||
assert.equal(record.status, "stopped");
|
||||
assert.equal(record.gamePort, 34197);
|
||||
assert.equal(record.factorioVersion, "1.0.0");
|
||||
assert.equal(record.startedAtMs, 100);
|
||||
assert.equal(record.updatedAtMs, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toInstanceDetails()", function () {
|
||||
it("should convert InstanceRecord to lib.InstanceDetails correctly", function () {
|
||||
const record = new InstanceRecord(
|
||||
defaultConfig, "running",
|
||||
34197, "1.0.0",
|
||||
100, 200
|
||||
);
|
||||
|
||||
const details = record.toInstanceDetails();
|
||||
|
||||
assert(details instanceof lib.InstanceDetails);
|
||||
assert.equal(details.name, record.config.get("instance.name"));
|
||||
assert.equal(details.id, record.id);
|
||||
assert.equal(details.assignedHost, 1);
|
||||
assert.equal(details.gamePort, 34197);
|
||||
assert.equal(details.status, "running");
|
||||
assert.equal(details.factorioVersion, "1.0.0");
|
||||
assert.equal(details.startedAtMs, 100);
|
||||
assert.equal(details.updatedAtMs, 200);
|
||||
assert.equal(details.excludeFromStartAll, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromInstanceDetails()", function () {
|
||||
it("should create InstanceRecord from lib.InstanceDetails", function () {
|
||||
const details = new lib.InstanceDetails(
|
||||
"TestInstance", 1, 1, 34197,
|
||||
"running", "1.0.0",
|
||||
100, 200, false
|
||||
);
|
||||
|
||||
const record = InstanceRecord.fromInstanceDetails(details, defaultConfig);
|
||||
|
||||
assert(record instanceof InstanceRecord);
|
||||
assert.equal(record.status, "running");
|
||||
assert.equal(record.gamePort, 34197);
|
||||
assert.equal(record.factorioVersion, "1.0.0");
|
||||
assert.equal(record.startedAtMs, 100);
|
||||
assert.equal(record.updatedAtMs, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get/set .isDeleted", function () {
|
||||
it("should return true if status is deleted", function () {
|
||||
const record = new InstanceRecord(defaultConfig, "deleted");
|
||||
assert.equal(record.isDeleted, true);
|
||||
});
|
||||
|
||||
it("should throw if trying to set isDeleted to false", function () {
|
||||
const record = new InstanceRecord(defaultConfig, "running");
|
||||
assert.throws(() => { record.isDeleted = false; }, /Setting isDeleted to false is not supported/);
|
||||
});
|
||||
|
||||
it("should set status to deleted when isDeleted = true", function () {
|
||||
const record = new InstanceRecord(defaultConfig, "running");
|
||||
record.isDeleted = true;
|
||||
assert.equal(record.status, "deleted");
|
||||
assert.equal(record.isDeleted, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { Role, SubscribableDatastore } = require("@clusterio/lib");
|
||||
const { User } = require("@clusterio/controller");
|
||||
|
||||
describe("controller/User", function () {
|
||||
/** @type {SubscribableDatastore<UserRecord>} */
|
||||
let users;
|
||||
/** @type {SubscribableDatastore<Role>} */
|
||||
let roles;
|
||||
/** @type {User} */
|
||||
let user;
|
||||
|
||||
beforeEach(function () {
|
||||
users = new SubscribableDatastore();
|
||||
roles = new SubscribableDatastore();
|
||||
|
||||
// Create a role and user used in most tests
|
||||
roles.set(Role.fromJSON({ id: 1, name: "connectRole", permissions: ["core.control.connect"] }));
|
||||
user = User.fromJSON({ name: "Alice", roles: [1] }, users, roles);
|
||||
|
||||
users.set(user);
|
||||
user.updatedAtMs = 1000; // Known low value
|
||||
});
|
||||
|
||||
describe("get roles", function () {
|
||||
it("should return roles from _controllerRoles", function () {
|
||||
const roleSet = user.roles;
|
||||
assert.equal(roleSet.size, 1);
|
||||
assert.equal([...roleSet][0].id, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".saveRecord() / set", function () {
|
||||
it("should update updatedAtMs", function () {
|
||||
const prev = user.updatedAtMs;
|
||||
user.saveRecord();
|
||||
assert(user.updatedAtMs > prev);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".set()", function () {
|
||||
it("should update updatedAtMs", function () {
|
||||
const prev = user.updatedAtMs;
|
||||
user.set("isAdmin", true);
|
||||
assert.equal(user.isAdmin, true);
|
||||
assert(user.updatedAtMs > prev);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".invalidateToken()", function () {
|
||||
it("should update tokenValidAfter to current time", function () {
|
||||
const prevValid = user.tokenValidAfter;
|
||||
const prevUpdate = user.updatedAtMs;
|
||||
user.invalidateToken();
|
||||
assert(user.tokenValidAfter > prevValid);
|
||||
assert(user.updatedAtMs > prevUpdate);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".addRole()", function () {
|
||||
it("should add role if exists", function () {
|
||||
roles.set(Role.fromJSON({ id: 2, name: "admin", permissions: ["core.admin"] }));
|
||||
const prev = user.updatedAtMs;
|
||||
|
||||
user.addRole(2);
|
||||
assert(user.roleIds.has(2));
|
||||
assert(user.updatedAtMs > prev);
|
||||
});
|
||||
|
||||
it("should throw if role does not exist", function () {
|
||||
assert.throws(() => user.addRole(999), /role 999 does not exist/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".removeRole()", function () {
|
||||
it("should remove existing role", function () {
|
||||
const prev = user.updatedAtMs;
|
||||
const result = user.removeRole(1);
|
||||
assert(result);
|
||||
assert(!user.roleIds.has(1));
|
||||
assert(user.updatedAtMs > prev);
|
||||
});
|
||||
|
||||
it("should return false if role was not assigned", function () {
|
||||
const prev = user.updatedAtMs;
|
||||
const result = user.removeRole(999);
|
||||
assert.equal(result, false);
|
||||
assert(user.updatedAtMs === prev); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe(".notifyJoin() / .notifyLeave()", function () {
|
||||
it("should add instance on notifyJoin", function () {
|
||||
const prev = user.updatedAtMs;
|
||||
user.notifyJoin(10);
|
||||
assert(user.instances.has(10));
|
||||
assert(user.updatedAtMs > prev);
|
||||
});
|
||||
|
||||
it("should remove instance on notifyLeave", function () {
|
||||
user.notifyJoin(10);
|
||||
user.updatedAtMs = 1000; // Known low value
|
||||
user.notifyLeave(10);
|
||||
assert(!user.instances.has(10));
|
||||
assert(user.updatedAtMs > 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".clearInstanceStats()", function () {
|
||||
it("should remove instanceStats and recalculate playerStats", function () {
|
||||
const instanceId = 5;
|
||||
const userWithStats = User.fromJSON({
|
||||
name: "Bob",
|
||||
instanceStats: [[instanceId, { join_count: 1 }]],
|
||||
}, users, roles);
|
||||
|
||||
users.set(userWithStats);
|
||||
userWithStats.updatedAtMs = 1000; // Known low value
|
||||
|
||||
userWithStats.clearInstanceStats(instanceId);
|
||||
assert(!userWithStats.instanceStats.has(instanceId));
|
||||
assert(userWithStats.updatedAtMs > 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".checkPermission()", function () {
|
||||
it("should allow if role has core.control.connect", function () {
|
||||
assert.doesNotThrow(() => user.checkPermission("core.control.connect"));
|
||||
});
|
||||
|
||||
it("should allow if role has core.admin", function () {
|
||||
roles.set(Role.fromJSON({ id: 2, name: "admin", permissions: ["core.admin"] }));
|
||||
const adminUser = User.fromJSON({ name: "Charlie", roles: [2] }, users, roles);
|
||||
assert.doesNotThrow(() => adminUser.checkPermission("core.admin"));
|
||||
});
|
||||
|
||||
it("should not error if user has invalid role", function () {
|
||||
user.roleIds = {
|
||||
[Symbol.iterator]: function* () {
|
||||
// We need the invalid id (99) to be before the valid id (1)
|
||||
for (const roleId of [99, 1]) { yield roleId; }
|
||||
},
|
||||
};
|
||||
assert.doesNotThrow(() => user.checkPermission("core.control.connect"));
|
||||
});
|
||||
|
||||
it("should throw if permission not granted", function () {
|
||||
assert.throws(() => user.checkPermission("core.admin"), /Permission denied/);
|
||||
});
|
||||
|
||||
it("should throw if permission does not exist", function () {
|
||||
assert.throws(() => user.checkPermission("nonexistent.permission"), /does not exist/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const assert = require("assert").strict;
|
||||
const { PlayerStats } = require("@clusterio/lib");
|
||||
const { ControllerUser, UserManager } = require("@clusterio/controller");
|
||||
|
||||
describe("controller/src/UserManager", function() {
|
||||
describe("class ControllerUser", function() {
|
||||
const userManager = new UserManager({});
|
||||
it("should track online users", function() {
|
||||
let user = ControllerUser.fromJSON({ name: "admin", roles: [1] });
|
||||
userManager.users.set(user.id, user);
|
||||
assert(!userManager.onlineUsers.has(user));
|
||||
assert.deepEqual(user.instances, new Set());
|
||||
|
||||
userManager.notifyJoin(user, 12);
|
||||
assert(userManager.onlineUsers.has(user));
|
||||
assert.deepEqual(user.instances, new Set([12]));
|
||||
|
||||
userManager.notifyJoin(user, 8);
|
||||
assert(userManager.onlineUsers.has(user));
|
||||
assert.deepEqual(user.instances, new Set([12, 8]));
|
||||
|
||||
userManager.notifyLeave(user, 11);
|
||||
assert(userManager.onlineUsers.has(user));
|
||||
assert.deepEqual(user.instances, new Set([12, 8]));
|
||||
|
||||
userManager.notifyLeave(user, 12);
|
||||
assert(userManager.onlineUsers.has(user));
|
||||
assert.deepEqual(user.instances, new Set([8]));
|
||||
|
||||
userManager.notifyLeave(user, 8);
|
||||
assert(!userManager.onlineUsers.has(user));
|
||||
assert.deepEqual(user.instances, new Set());
|
||||
});
|
||||
describe(".clearStatsOfInstance()", function() {
|
||||
it("should remove the instance stats from existing users", function() {
|
||||
let user1 = ControllerUser.fromJSON({ name: "admin", roles: [1] });
|
||||
let user2 = ControllerUser.fromJSON({ name: "player", roles: [1] });
|
||||
userManager.users.set(user1.id, user1);
|
||||
userManager.users.set(user2.id, user2);
|
||||
user1.instanceStats.set(10, new PlayerStats({ join_count: 1 }));
|
||||
user1.instanceStats.set(11, new PlayerStats({ join_count: 6 }));
|
||||
user1.recalculatePlayerStats();
|
||||
userManager.notifyJoin(user1, 11);
|
||||
user2.instanceStats.set(10, new PlayerStats({ join_count: 3 }));
|
||||
user2.recalculatePlayerStats();
|
||||
assert.equal(user1.playerStats.joinCount, 7);
|
||||
assert.equal(user2.playerStats.joinCount, 3);
|
||||
assert(userManager.onlineUsers.has(user1));
|
||||
|
||||
userManager.clearStatsOfInstance(11);
|
||||
|
||||
assert.equal(user1.playerStats.joinCount, 1);
|
||||
assert(!userManager.onlineUsers.has(user1));
|
||||
assert.equal(user2.playerStats.joinCount, 3);
|
||||
});
|
||||
});
|
||||
describe(".load()", function() {
|
||||
it("should merge users on load", async function() {
|
||||
// Create a user db file containing duplicate user ids
|
||||
const names = ["user1", "User1", "user3"];
|
||||
names.forEach(name => {
|
||||
const user = ControllerUser.fromJSON({ name: name, roles: [1] });
|
||||
userManager.users.set(user.name, user);
|
||||
});
|
||||
|
||||
// Setup the player stats
|
||||
const user1Before = userManager.users.get("user1");
|
||||
user1Before.instanceStats.set(9, new PlayerStats({ join_count: 3 }));
|
||||
user1Before.instanceStats.set(10, new PlayerStats({ join_count: 5 }));
|
||||
user1Before.banReason = "ban reason here";
|
||||
user1Before.isWhitelisted = true;
|
||||
user1Before.isBanned = true;
|
||||
user1Before.isAdmin = true;
|
||||
user1Before.updatedAtMs = 100;
|
||||
|
||||
const user2Before = userManager.users.get("User1");
|
||||
user2Before.instanceStats.set(10, new PlayerStats({ join_count: 7 }));
|
||||
user2Before.instanceStats.set(11, new PlayerStats({ join_count: 9 }));
|
||||
user2Before.isWhitelisted = false;
|
||||
user2Before.isBanned = false;
|
||||
user2Before.isAdmin = false;
|
||||
user1Before.updatedAtMs = 200;
|
||||
|
||||
const user3Before = userManager.users.get("user3");
|
||||
user3Before.instanceStats.set(10, new PlayerStats({ join_count: 11 }));
|
||||
user3Before.instanceStats.set(11, new PlayerStats({ join_count: 13 }));
|
||||
user3Before.isWhitelisted = false;
|
||||
user3Before.isAdmin = true;
|
||||
|
||||
// Save and reload the user data
|
||||
await fs.emptyDir("./temp/test/user_manager");
|
||||
await userManager.save("./temp/test/user_manager/load.json");
|
||||
names.map(name => userManager.users.delete(name));
|
||||
await userManager.load("./temp/test/user_manager/load.json");
|
||||
|
||||
// Check it was loaded correctly
|
||||
const user1After = userManager.users.get("user1");
|
||||
const user2After = userManager.users.get("User1");
|
||||
const user3After = userManager.users.get("user3");
|
||||
assert.notEqual(user1After, undefined, "'user1' was not loaded");
|
||||
assert.equal(user2After, undefined, "'User1' was loaded");
|
||||
assert.notEqual(user3After, undefined, "'user3' was not loaded");
|
||||
|
||||
assert.equal(user1After.isAdmin, false, "User 1 is admin");
|
||||
assert.equal(user1After.isBanned, true, "User 1 is not banned");
|
||||
assert.equal(user1After.isWhitelisted, true, "User 1 is not whitelisted");
|
||||
assert.equal(user1After.banReason, "ban reason here", "User 1 ban reason is wrong");
|
||||
assert.equal(user1After.instanceStats.get(9)?.joinCount, 3, "User 1 instance 9 stats are wrong");
|
||||
assert.equal(user1After.instanceStats.get(10)?.joinCount, 12, "User 1 instance 10 stats are wrong");
|
||||
assert.equal(user1After.instanceStats.get(11)?.joinCount, 9, "User 1 instance 11 stats are wrong");
|
||||
|
||||
assert.equal(user3After.isAdmin, true, "User 3 is not admin");
|
||||
assert.equal(user3After.isWhitelisted, false, "User 3 is whitelisted");
|
||||
assert.equal(user3After.instanceStats.get(10)?.joinCount, 11, "User 3 instance 10 stats are wrong");
|
||||
assert.equal(user3After.instanceStats.get(11)?.joinCount, 13, "User 3 instance 11 stats are wrong");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
const assert = require("assert").strict;
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { Role, SubscribableDatastore, ControllerConfig } = require("@clusterio/lib");
|
||||
const { UserRecord, UserManager } = require("@clusterio/controller");
|
||||
|
||||
const TEMP_DIR = path.join("temp", "test", "UserManager");
|
||||
|
||||
describe("controller/UserManager", function () {
|
||||
/** @type {SubscribableDatastore<UserRecord>} */
|
||||
let records;
|
||||
/** @type {SubscribableDatastore<Role>} */
|
||||
let roles;
|
||||
/** @type {ControllerConfig} */
|
||||
let config;
|
||||
/** @type {UserManager} */
|
||||
let userManager;
|
||||
|
||||
beforeEach(async function () {
|
||||
records = new SubscribableDatastore();
|
||||
roles = new SubscribableDatastore();
|
||||
config = new ControllerConfig("controller", {
|
||||
"controller.default_role_id": null,
|
||||
"controller.auth_secret": Buffer.from("mysecret").toString("base64"),
|
||||
});
|
||||
userManager = new UserManager(records, roles, config);
|
||||
await fs.emptyDir(TEMP_DIR);
|
||||
});
|
||||
|
||||
describe(".has() / .get() / .getMutable() / .hasName() / .getByName() / .getByNameMutable()", function () {
|
||||
it("should return user when exists", function () {
|
||||
const user = userManager.createUser("Alice");
|
||||
assert.deepEqual(userManager.has(user.id), true);
|
||||
assert.deepEqual(userManager.get(user.id), user);
|
||||
assert.deepEqual(userManager.getMutable(user.id), user);
|
||||
assert.deepEqual(userManager.hasName("Alice"), true);
|
||||
assert.deepEqual(userManager.getByName("Alice"), user);
|
||||
assert.deepEqual(userManager.getByNameMutable("Alice"), user);
|
||||
});
|
||||
|
||||
it("should return undefined when user does not exist", function () {
|
||||
assert.equal(userManager.has("nonexistent"), false);
|
||||
assert.equal(userManager.get("nonexistent"), undefined);
|
||||
assert.equal(userManager.hasName("nonexistent"), false);
|
||||
assert.equal(userManager.getByName("nonexistent"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".values() / .valuesMutable()", function () {
|
||||
it("should iterate all users", function () {
|
||||
const u1 = userManager.createUser("Alice");
|
||||
const u2 = userManager.createUser("Bob");
|
||||
|
||||
const values = Array.from(userManager.values());
|
||||
const valuesMutable = Array.from(userManager.valuesMutable());
|
||||
|
||||
assert.deepEqual(values, [u1, u2]);
|
||||
assert.deepEqual(valuesMutable, [u1, u2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".createUser()", function () {
|
||||
it("should create and store a new user", function () {
|
||||
const user = userManager.createUser("Charlie");
|
||||
assert.deepEqual(userManager.getByName("Charlie"), user);
|
||||
assert.deepEqual(records.getMutable(user.id).toJSON(), user.toJSON());
|
||||
});
|
||||
|
||||
it("should throw if user already exists", function () {
|
||||
userManager.createUser("Dave");
|
||||
assert.throws(() => userManager.createUser("Dave"), /already exists/);
|
||||
});
|
||||
|
||||
it("should assign default role if configured and exists", function () {
|
||||
roles.set(new Role(1, "admin"));
|
||||
config.set("controller.default_role_id", 1);
|
||||
const user = userManager.createUser("Eve");
|
||||
assert(user.roleIds.has(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe(".getOrCreateUser()", function () {
|
||||
it("should return existing user", function () {
|
||||
const existing = userManager.createUser("Frank");
|
||||
const found = userManager.getOrCreateUser("Frank");
|
||||
assert.deepEqual(found, existing);
|
||||
});
|
||||
|
||||
it("should create user if missing", function () {
|
||||
const found = userManager.getOrCreateUser("Grace");
|
||||
assert(userManager.getByName("Grace"));
|
||||
assert.deepEqual(found, userManager.getByName("Grace"));
|
||||
});
|
||||
});
|
||||
|
||||
describe(".deleteUser()", function () {
|
||||
it("should delete user from records", function () {
|
||||
const user = userManager.createUser("Heidi");
|
||||
assert(userManager.getByName("Heidi"));
|
||||
userManager.deleteUser(user);
|
||||
assert.equal(userManager.getByName("Heidi"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".signUserToken()", function () {
|
||||
it("should return a valid JWT for user", function () {
|
||||
const user = userManager.createUser("Ivan");
|
||||
const token = userManager.signUserToken(user);
|
||||
const payload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(config.get("controller.auth_secret"), "base64")
|
||||
);
|
||||
const { iat, ...rest } = payload;
|
||||
assert.equal(typeof iat, "number");
|
||||
assert.deepEqual(rest, { aud: "user", user: user.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe(".clearStatsOfInstance()", function () {
|
||||
it("should clear instance stats for all users", function () {
|
||||
const instanceId = 42;
|
||||
|
||||
records.set(UserRecord.fromJSON({ name: "Jack", instance_stats: [[instanceId, { join_count: 1 }]] }));
|
||||
records.set(UserRecord.fromJSON({ name: "Jill", instance_stats: [[instanceId, { join_count: 1 }]] }));
|
||||
|
||||
userManager.clearStatsOfInstance(instanceId);
|
||||
|
||||
for (const user of userManager.values()) {
|
||||
assert(!user.instanceStats.has(instanceId), `${user.id} still has instanceStats for ${instanceId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("static attemptMigrateLegacyUsersFile()", function () {
|
||||
const usersFile = path.join(TEMP_DIR, "users.json");
|
||||
const rolesFile = path.join(TEMP_DIR, "roles.json");
|
||||
|
||||
it("should do nothing if file does not exist", async function () {
|
||||
await UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile);
|
||||
});
|
||||
|
||||
it("should do nothing if roles key missing", async function () {
|
||||
await fs.writeJson(usersFile, { users: [] });
|
||||
await UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile);
|
||||
const content = await fs.readJson(usersFile);
|
||||
assert.deepEqual(content, { users: [] });
|
||||
});
|
||||
|
||||
it("should throw if users key missing in legacy file", async function () {
|
||||
await fs.writeJson(usersFile, { roles: [] });
|
||||
await assert.rejects(
|
||||
() => UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile),
|
||||
/Error: Legacy users json does not contain users property/
|
||||
);
|
||||
});
|
||||
|
||||
it("should migrate valid legacy file", async function () {
|
||||
const legacy = { roles: [{ id: 1 }, { id: 2 }], users: [{ name: "Karl" }] };
|
||||
await fs.writeJson(usersFile, legacy);
|
||||
|
||||
await UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile);
|
||||
|
||||
const writtenRoles = await fs.readJson(rolesFile);
|
||||
const writtenUsers = await fs.readJson(usersFile);
|
||||
|
||||
assert.deepEqual(writtenRoles, [{ id: 1 }, { id: 2 }]);
|
||||
assert.deepEqual(writtenUsers, [{ name: "Karl" }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { UserDetails, PlayerStats } = require("@clusterio/lib");
|
||||
const { UserRecord } = require("@clusterio/controller");
|
||||
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
|
||||
|
||||
describe("controller/UserRecord", function () {
|
||||
it("should be round trip serialisable", function () {
|
||||
testRoundTripJsonSerialisable(UserRecord, testMatrix(
|
||||
[0, 50], // tokenValidAfter
|
||||
["TestUser"], // name
|
||||
));
|
||||
});
|
||||
|
||||
describe("constructor", function () {
|
||||
it("should default tokenValidAfter to 0", function () {
|
||||
const user = new UserRecord(undefined, "TestUser");
|
||||
assert.equal(user.tokenValidAfter, 0);
|
||||
});
|
||||
|
||||
it("should set tokenValidAfter when provided", function () {
|
||||
const user = new UserRecord(123, "TestUser");
|
||||
assert.equal(user.tokenValidAfter, 123);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromJSON()", function () {
|
||||
it("should construct from JSON without token_valid_after", function () {
|
||||
const json = {
|
||||
name: "TestUser",
|
||||
};
|
||||
|
||||
const user = UserRecord.fromJSON(json);
|
||||
assert.equal(user.tokenValidAfter, 0);
|
||||
assert.equal(user.name, "TestUser");
|
||||
});
|
||||
|
||||
it("should construct from JSON with token_valid_after", function () {
|
||||
const json = {
|
||||
name: "TestUser",
|
||||
token_valid_after: 99,
|
||||
};
|
||||
|
||||
const user = UserRecord.fromJSON(json);
|
||||
assert.equal(user.tokenValidAfter, 99);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toJSON()", function () {
|
||||
it("should omit token_valid_after when 0", function () {
|
||||
const user = new UserRecord(0, "TestUser");
|
||||
|
||||
const json = user.toJSON();
|
||||
assert.equal(json.token_valid_after, undefined);
|
||||
});
|
||||
|
||||
it("should include token_valid_after when > 0", function () {
|
||||
const user = new UserRecord(42, "TestUser");
|
||||
|
||||
const json = user.toJSON();
|
||||
assert.equal(json.token_valid_after, 42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromUserDetails()", function () {
|
||||
it("should copy all fields from UserDetails", function () {
|
||||
const details = new UserDetails(
|
||||
"TestUser", new Set([1]), new Set([2]),
|
||||
true, true, false, "reason",
|
||||
100, false,
|
||||
new Map()
|
||||
);
|
||||
|
||||
const record = UserRecord.fromUserDetails(details, 55);
|
||||
|
||||
assert.equal(record.name, details.name);
|
||||
assert.deepEqual(record.roleIds, details.roleIds);
|
||||
assert.deepEqual(record.instances, details.instances);
|
||||
assert.equal(record.isAdmin, details.isAdmin);
|
||||
assert.equal(record.tokenValidAfter, 55);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toUserDetails()", function () {
|
||||
it("should create equivalent UserDetails without tokenValidAfter", function () {
|
||||
const record = new UserRecord(10, "TestUser");
|
||||
const details = record.toUserDetails();
|
||||
|
||||
assert(details instanceof UserDetails);
|
||||
assert.equal(details.name, record.name);
|
||||
assert.equal(details.tokenValidAfter, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge", function () {
|
||||
it("should throw if ids do not match", function () {
|
||||
const a = new UserRecord(0, "UserA");
|
||||
const b = new UserDetails("UserB");
|
||||
|
||||
assert.throws(() => a.merge(b), /different ids/);
|
||||
});
|
||||
|
||||
it("should merge sets and boolean flags", function () {
|
||||
const a = new UserRecord(
|
||||
0, "TestUser", new Set([1]), new Set([10]),
|
||||
true, false, false, "old",
|
||||
100, false
|
||||
);
|
||||
|
||||
const b = new UserDetails(
|
||||
"TestUser", new Set([2]), new Set([20]),
|
||||
false, true, true, "new",
|
||||
200, false
|
||||
);
|
||||
|
||||
const beforeUpdated = a.updatedAtMs;
|
||||
|
||||
a.merge(b);
|
||||
|
||||
assert.deepEqual(a.roleIds, new Set([1, 2])); // roleIds union
|
||||
assert.deepEqual(a.instances, new Set([10, 20])); // instances union
|
||||
assert.equal(a.isAdmin, false); // isAdmin uses &&
|
||||
assert.equal(a.isBanned, true); // isBanned uses ||
|
||||
assert.equal(a.isWhitelisted, true); // isWhitelisted uses ||
|
||||
assert.equal(a.banReason, "new"); // banReason chosen by updatedAtMs comparison
|
||||
assert(a.updatedAtMs > beforeUpdated); // updatedAtMs changed
|
||||
});
|
||||
|
||||
it("should merge instanceStats entries", function () {
|
||||
const a = UserRecord.fromJSON({ name: "TestUser", instance_stats: [
|
||||
[1, { join_count: 1 }],
|
||||
]});
|
||||
|
||||
const b = UserRecord.fromJSON({ name: "TestUser", instance_stats: [
|
||||
[1, { join_count: 3 }], // merge existing
|
||||
[2, { join_count: 5 }], // add new
|
||||
]});
|
||||
|
||||
a.merge(b);
|
||||
|
||||
assert.equal(a.instanceStats.has(1), true);
|
||||
assert.equal(a.instanceStats.get(1).joinCount, 4);
|
||||
assert.equal(a.instanceStats.has(2), true);
|
||||
assert.equal(a.instanceStats.get(2).joinCount, 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
const assert = require("assert").strict;
|
||||
const events = require("events");
|
||||
const FormData = require("form-data");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const http = require("http");
|
||||
|
||||
const { wait } = require("@clusterio/lib");
|
||||
@@ -88,9 +87,10 @@ describe("controller/src/routes", function() {
|
||||
});
|
||||
});
|
||||
describe("/api/upload-save", function() {
|
||||
let endpoint;
|
||||
let endpoint, testToken;
|
||||
beforeEach(function() {
|
||||
endpoint = `http://localhost:${port}/api/upload-save`;
|
||||
testToken = controller.users.signUserToken({ id: "test" });
|
||||
});
|
||||
it("should respond with 401 with invalid token", async function() {
|
||||
let response;
|
||||
@@ -113,21 +113,19 @@ describe("controller/src/routes", function() {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"X-Access-Token": jwt.sign(
|
||||
{ aud: "user", user: "invalid" }, Buffer.from("TestSecretDoNotUse", "base64")
|
||||
),
|
||||
"X-Access-Token": controller.users.signUserToken({ id: "invalid" }),
|
||||
},
|
||||
body: "totally a zip file",
|
||||
});
|
||||
assert.equal(response.status, 401);
|
||||
controller.userManager.getByName("test").tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
|
||||
const user = controller.users.getOrCreateUser("test");
|
||||
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
|
||||
user.saveRecord();
|
||||
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"X-Access-Token": jwt.sign(
|
||||
{ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")
|
||||
),
|
||||
"X-Access-Token": testToken,
|
||||
},
|
||||
body: "totally a zip file",
|
||||
});
|
||||
@@ -135,20 +133,18 @@ describe("controller/src/routes", function() {
|
||||
});
|
||||
|
||||
it("should respond with 403 when user has insufficient permission", async function() {
|
||||
const user = controller.users.getOrCreateUser("player");
|
||||
let response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"X-Access-Token": jwt.sign(
|
||||
{ aud: "user", user: "player" }, Buffer.from("TestSecretDoNotUse", "base64")
|
||||
),
|
||||
"X-Access-Token": controller.users.signUserToken(user),
|
||||
},
|
||||
body: "totally a zip file",
|
||||
});
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
let testToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
it("should respond with 415 if content type is missing or invalid", async function() {
|
||||
let response;
|
||||
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ describe("Host testing", function() {
|
||||
describe("discoverInstances()", function() {
|
||||
it("should discover test instance", async function() {
|
||||
const instancePath = path.join("test", "file", "instances");
|
||||
const instanceInfos = await _discoverInstances(instancePath);
|
||||
const instances = await _discoverInstances(instancePath);
|
||||
|
||||
const configPath = path.join(instancePath, "test", "instance.json");
|
||||
const referenceConfig = new lib.InstanceConfig("host", {
|
||||
@@ -21,7 +21,7 @@ describe("Host testing", function() {
|
||||
"instance.name": "test",
|
||||
}, configPath);
|
||||
|
||||
assert.deepEqual(instanceInfos, new Map([
|
||||
assert.deepEqual(instances, new Map([
|
||||
[1, {
|
||||
config: referenceConfig,
|
||||
path: path.join(instancePath, "test"),
|
||||
|
||||
@@ -109,10 +109,12 @@ describe("Integration of Clusterio", function() {
|
||||
describe("clusteriocontroller", function() {
|
||||
describe("bootstrap generate-user-token", function() {
|
||||
it("work for existing user", async function() {
|
||||
slowTest(this);
|
||||
await execController("bootstrap generate-user-token test");
|
||||
});
|
||||
|
||||
it("fails if user does not exist", async function() {
|
||||
slowTest(this);
|
||||
await assert.rejects(
|
||||
execController("bootstrap generate-user-token invalid")
|
||||
);
|
||||
@@ -935,20 +937,22 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
it("should create the user if instructed to", async function() {
|
||||
slowTest(this);
|
||||
getControl().userUpdates = [];
|
||||
const userUpdates = [];
|
||||
getControl().userUpdates = userUpdates;
|
||||
for (let [listName, prop] of lists) {
|
||||
await execCtl(`user set-${listName} --create test_create_${listName}`);
|
||||
let user = (await getUsers()).get(`test_create_${listName}`);
|
||||
assert.equal(user && user[prop], true, `user not created and added to ${listName}`);
|
||||
}
|
||||
assert.equal(getControl().userUpdates.length, 3);
|
||||
assert.equal(userUpdates.length, 6); // 1 for create, 1 for setting the property
|
||||
});
|
||||
it("should send ban reason", async function() {
|
||||
slowTest(this);
|
||||
getControl().userUpdates = [];
|
||||
const userUpdates = [];
|
||||
getControl().userUpdates = userUpdates;
|
||||
await execCtl("user set-banned --create test_ban_reason --reason a-reason");
|
||||
assert.equal(getControl().userUpdates.length, 1);
|
||||
assert.equal(getControl().userUpdates[0].banReason, "a-reason");
|
||||
assert.equal(userUpdates.length, 2); // 1 for create, 1 for ban reason
|
||||
assert.equal(userUpdates[1].banReason, "a-reason");
|
||||
let user = await getUser("test_ban_reason");
|
||||
assert.equal(user.banReason, "a-reason");
|
||||
});
|
||||
@@ -1719,6 +1723,12 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
|
||||
describe("user import", function() {
|
||||
beforeEach(async function() {
|
||||
slowTest(this);
|
||||
await execCtl("user set-admin import_control --revoke --create");
|
||||
await execCtl("user set-banned import_control --pardon");
|
||||
await execCtl("user set-whitelisted import_control --remove");
|
||||
});
|
||||
it("should import users", async function() {
|
||||
await fs.writeJSON(path.join("temp", "test", "import-users.json"), {
|
||||
export_version: "2.0.0.alpha20",
|
||||
@@ -1728,12 +1738,14 @@ describe("Integration of Clusterio", function() {
|
||||
{ username: "import_user_ban_reason", is_banned: true, ban_reason: "banned" },
|
||||
{ username: "import_user_whitelist", is_whitelisted: true },
|
||||
{ username: "import_user_none" },
|
||||
{ username: "import_control", is_admin: true },
|
||||
],
|
||||
});
|
||||
|
||||
await execCtl("user import --users import-users.json");
|
||||
|
||||
const users = await getUsers();
|
||||
assert(users.get("import_control").isAdmin, "import_control was not admin");
|
||||
assert(users.get("import_user_admin").isAdmin, "import_user_admin was not admin");
|
||||
assert(users.get("import_user_banned").isBanned, "import_user_banned was not banned");
|
||||
assert(
|
||||
@@ -1765,6 +1777,7 @@ describe("Integration of Clusterio", function() {
|
||||
await fs.writeJSON(path.join("temp", "test", "import-bans.json"), [
|
||||
{ username: "import_ban_reason", reason: "banned" },
|
||||
"import_ban",
|
||||
"import_control",
|
||||
]);
|
||||
|
||||
await execCtl("user import --bans import-bans.json");
|
||||
@@ -1778,6 +1791,8 @@ describe("Integration of Clusterio", function() {
|
||||
const userBar = users.get("import_ban");
|
||||
assert(userBar.isBanned, "import_ban was not banned");
|
||||
assert.equal(userBar.banReason, "", "import_ban had incorrect ban reason");
|
||||
|
||||
assert(users.get("import_control").isBanned, "import_control was not banned");
|
||||
});
|
||||
it("should import bans by filename", async function() {
|
||||
await fs.writeJSON(path.join("temp", "test", "import-bans.json"), [
|
||||
@@ -1789,11 +1804,13 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
it("should import admins", async function() {
|
||||
await fs.writeJSON(path.join("temp", "test", "import-admins.json"), [
|
||||
"import_admin",
|
||||
"import_admin", "import_control",
|
||||
]);
|
||||
|
||||
await execCtl("user import --admins import-admins.json");
|
||||
assert((await getUsers()).get("import_admin").isAdmin, "import_admin was not admin");
|
||||
const users = await getUsers();
|
||||
assert(users.get("import_admin").isAdmin, "import_admin was not admin");
|
||||
assert(users.get("import_control").isAdmin, "import_control was not admin");
|
||||
});
|
||||
it("should import admins by filename", async function() {
|
||||
await fs.writeJSON(path.join("temp", "test", "import-admins.json"), [
|
||||
@@ -1805,14 +1822,13 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
it("should import whitelist", async function() {
|
||||
await fs.writeJSON(path.join("temp", "test", "import-whitelist.json"), [
|
||||
"import_whitelist",
|
||||
"import_whitelist", "import_control",
|
||||
]);
|
||||
|
||||
await execCtl("user import --whitelist import-whitelist.json");
|
||||
assert(
|
||||
(await getUsers()).get("import_whitelist").isWhitelisted,
|
||||
"import_whitelist was not whitelisted"
|
||||
);
|
||||
const users = await getUsers();
|
||||
assert(users.get("import_whitelist").isWhitelisted, "import_whitelist was not whitelisted");
|
||||
assert(users.get("import_control").isWhitelisted, "import_control was not whitelisted");
|
||||
});
|
||||
it("should import whitelist by filename", async function() {
|
||||
await fs.writeJSON(path.join("temp", "test", "import-whitelist.json"), [
|
||||
@@ -1882,7 +1898,10 @@ describe("Integration of Clusterio", function() {
|
||||
it("should restore users", async function() {
|
||||
await fs.writeJSON(path.join("temp", "test", "restore-users.json"), {
|
||||
export_version: "2.0.0.alpha20",
|
||||
users: [{ username: "restore_user", is_admin: true, is_whitelisted: true }],
|
||||
users: [
|
||||
{ username: "restore_user", is_admin: true, is_whitelisted: true },
|
||||
{ username: "restore_user_two", is_banned: true, ban_reason: "Test" },
|
||||
],
|
||||
});
|
||||
|
||||
await execCtl("user restore restore-users.json");
|
||||
@@ -1892,10 +1911,14 @@ describe("Integration of Clusterio", function() {
|
||||
assert(user.isAdmin, "restore_user was not admin");
|
||||
assert(user.isWhitelisted, "restore_user was not whitelisted");
|
||||
|
||||
const user2 = users.get("restore_user_two");
|
||||
assert(user2.isBanned, "restore_user_two was not banned");
|
||||
assert.equal(user2.banReason, "Test");
|
||||
|
||||
const control = users.get("restore_control");
|
||||
assert(!control.isAdmin, "restore_user was admin");
|
||||
assert(!control.isBanned, "restore_user was banned");
|
||||
assert(!control.isWhitelisted, "restore_user was whitelisted");
|
||||
assert(!control.isAdmin, "restore_control was admin");
|
||||
assert(!control.isBanned, "restore_control was banned");
|
||||
assert(!control.isWhitelisted, "restore_control was whitelisted");
|
||||
|
||||
const data = await fs.readJson(path.join("temp", "test", "users-backup.json"));
|
||||
assert(data.users, "Users array does not exist");
|
||||
@@ -1921,7 +1944,7 @@ describe("Integration of Clusterio", function() {
|
||||
assert(userBar.isBanned, "restore_ban was not banned");
|
||||
assert.equal(userBar.banReason, "", "restore_ban had incorrect ban reason");
|
||||
|
||||
assert(!users.get("restore_control").isBanned, "restore_user was banned");
|
||||
assert(!users.get("restore_control").isBanned, "restore_control was banned");
|
||||
|
||||
const data = await fs.readJson(path.join("temp", "test", "bans-backup.json"));
|
||||
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
|
||||
@@ -1935,7 +1958,7 @@ describe("Integration of Clusterio", function() {
|
||||
const users = await getUsers();
|
||||
|
||||
assert(users.get("restore_admin").isAdmin, "restore_admin was not admin");
|
||||
assert(!users.get("restore_control").isAdmin, "restore_user was admin");
|
||||
assert(!users.get("restore_control").isAdmin, "restore_control was admin");
|
||||
|
||||
const data = await fs.readJson(path.join("temp", "test", "admins-backup.json"));
|
||||
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
|
||||
@@ -1949,7 +1972,7 @@ describe("Integration of Clusterio", function() {
|
||||
const users = await getUsers();
|
||||
|
||||
assert(users.get("restore_whitelist").isWhitelisted, "restore_whitelist was not whitelisted");
|
||||
assert(!users.get("restore_control").isWhitelisted, "restore_user was whitelisted");
|
||||
assert(!users.get("restore_control").isWhitelisted, "restore_control was whitelisted");
|
||||
|
||||
const data = await fs.readJson(path.join("temp", "test", "whitelist-backup.json"));
|
||||
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require("assert").strict;
|
||||
|
||||
const lib = require("@clusterio/lib");
|
||||
const { Host, Instance, InstanceConnection } = require("@clusterio/host");
|
||||
const { ControlConnection, Controller, HostConnection, InstanceInfo } = require("@clusterio/controller");
|
||||
const { ControlConnection, Controller, HostConnection, InstanceRecord } = require("@clusterio/controller");
|
||||
|
||||
// Although there are no imports, the file has side-effects such as "before" which can effect tests
|
||||
require("./index");
|
||||
@@ -38,8 +38,8 @@ function connectInstance(controller, host, instanceId) {
|
||||
const instanceConfig = new lib.InstanceConfig("host");
|
||||
instanceConfig.set("instance.id", instanceId);
|
||||
instanceConfig.set("instance.assigned_host", host.config.get("host.id"));
|
||||
host.instanceInfos.set(instanceId, { path: "invalid", config: instanceConfig });
|
||||
controller.instances.set(new InstanceInfo(instanceConfig, "stopped"));
|
||||
host.assignedInstances.set(instanceId, { path: "invalid", config: instanceConfig });
|
||||
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
|
||||
const instance = new Instance(host, instanceSide, "invalid", "invalid", instanceConfig);
|
||||
const instanceConnection = new InstanceConnection(hostSide, host, instance);
|
||||
host.instanceConnections.set(instanceId, instanceConnection);
|
||||
@@ -53,10 +53,7 @@ function connectInstance(controller, host, instanceId) {
|
||||
function connectControl(controller, controlId) {
|
||||
const [controllerSide, controlSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ controlId }));
|
||||
const registerData = new lib.RegisterControlData("", "0.0.0");
|
||||
let user = controller.userManager.getByName("test");
|
||||
if (!user) {
|
||||
user = controller.userManager.createUser("test");
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,7 @@ const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const lib = require("@clusterio/lib");
|
||||
const { InstanceManager } = require("@clusterio/controller");
|
||||
const { FactorioServer, _getFactorioVersion } = require("@clusterio/host/dist/node/src/server");
|
||||
const { logger } = lib;
|
||||
|
||||
@@ -78,20 +79,16 @@ describe("Integration of host/src/server", function() {
|
||||
assert(typeof settings === "object");
|
||||
});
|
||||
|
||||
it("contains the settings used by Clusterio", async function() {
|
||||
let keysUsed = new Set([
|
||||
"name", "description", "tags", "visibility", "username", "token",
|
||||
"game_password", "require_user_verification", "allow_commands",
|
||||
"auto_pause",
|
||||
]);
|
||||
it("aligns with the default settings in Clusterio", async function() {
|
||||
const unusedDefaultKeys = new Set(Object.keys(InstanceManager.DefaultFactorioSettings));
|
||||
|
||||
for (let key of Object.keys(settings)) {
|
||||
keysUsed.delete(key);
|
||||
for (const key of Object.keys(settings)) {
|
||||
unusedDefaultKeys.delete(key);
|
||||
}
|
||||
|
||||
assert(
|
||||
keysUsed.size === 0,
|
||||
`Factorio's server-settings.example.json does not contain the key(s) ${[...keysUsed]}`
|
||||
unusedDefaultKeys.size === 0,
|
||||
`Factorio's server-settings.example.json does not contain the key(s) ${[...unusedDefaultKeys]}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { HostDetails } = require("@clusterio/lib");
|
||||
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
|
||||
|
||||
describe("lib/data/HostDetails", function () {
|
||||
|
||||
it("should be round trip serialisable", function () {
|
||||
testRoundTripJsonSerialisable(HostDetails, testMatrix(
|
||||
["1.0.0"], // version (required)
|
||||
["TestHost"], // name (required)
|
||||
[1], // id (required)
|
||||
[true, false], // connected (required)
|
||||
[undefined, "10.0.0.1"], // remoteAddress
|
||||
[undefined, "203.0.113.1"], // publicAddress
|
||||
[undefined, 123456], // tokenValidAfter (sec)
|
||||
[undefined, 999999], // updatedAtMs (ms)
|
||||
[undefined, true], // isDeleted
|
||||
));
|
||||
});
|
||||
|
||||
describe("constructor", function () {
|
||||
it("should set defaults correctly", function () {
|
||||
const host = new HostDetails("1.0.0", "TestHost", 1, true);
|
||||
|
||||
assert.equal(host.version, "1.0.0");
|
||||
assert.equal(host.name, "TestHost");
|
||||
assert.equal(host.id, 1);
|
||||
assert.equal(host.connected, true);
|
||||
assert.equal(host.remoteAddress, "");
|
||||
assert.equal(host.publicAddress, "");
|
||||
assert.equal(host.tokenValidAfter, 0);
|
||||
assert.equal(host.updatedAtMs, 0);
|
||||
assert.equal(host.isDeleted, false);
|
||||
});
|
||||
|
||||
it("should accept all constructor parameters", function () {
|
||||
const host = new HostDetails(
|
||||
"2.0.0", "FullHost", 42,
|
||||
false, "10.1.1.1", "198.51.100.5",
|
||||
123, 999, true
|
||||
);
|
||||
|
||||
assert.equal(host.version, "2.0.0");
|
||||
assert.equal(host.name, "FullHost");
|
||||
assert.equal(host.id, 42);
|
||||
assert.equal(host.connected, false);
|
||||
assert.equal(host.remoteAddress, "10.1.1.1");
|
||||
assert.equal(host.publicAddress, "198.51.100.5");
|
||||
assert.equal(host.tokenValidAfter, 123);
|
||||
assert.equal(host.updatedAtMs, 999);
|
||||
assert.equal(host.isDeleted, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toJSON()", function () {
|
||||
it("should only include required fields when optional fields are not given", function () {
|
||||
const host = new HostDetails("1.0.0", "TestHost", 1, false);
|
||||
|
||||
assert.deepEqual(host.toJSON(), {
|
||||
version: "1.0.0",
|
||||
name: "TestHost",
|
||||
id: 1,
|
||||
connected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should include all optional fields when set", function () {
|
||||
const host = new HostDetails(
|
||||
"2.0.0", "FullHost", 99,
|
||||
true, "10.0.0.5", "203.0.113.5",
|
||||
500, 1000, true
|
||||
);
|
||||
|
||||
assert.deepEqual(host.toJSON(), {
|
||||
version: "2.0.0",
|
||||
name: "FullHost",
|
||||
id: 99,
|
||||
connected: true,
|
||||
remoteAddress: "10.0.0.5",
|
||||
publicAddress: "203.0.113.5",
|
||||
tokenValidAfter: 500,
|
||||
updatedAtMs: 1000,
|
||||
isDeleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit optional fields when equal to defaults", function () {
|
||||
const host = new HostDetails(
|
||||
"1.0.0", "TestHost", 1,
|
||||
true, "", "",
|
||||
0, 0, false
|
||||
);
|
||||
|
||||
assert.deepEqual(host.toJSON(), {
|
||||
version: "1.0.0",
|
||||
name: "TestHost",
|
||||
id: 1,
|
||||
connected: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromJSON()", function () {
|
||||
it("should construct minimal object", function () {
|
||||
const host = HostDetails.fromJSON({
|
||||
version: "1.0.0",
|
||||
name: "TestHost",
|
||||
id: 1,
|
||||
connected: true,
|
||||
});
|
||||
|
||||
assert.equal(host.version, "1.0.0");
|
||||
assert.equal(host.name, "TestHost");
|
||||
assert.equal(host.id, 1);
|
||||
assert.equal(host.connected, true);
|
||||
assert.equal(host.remoteAddress, "");
|
||||
assert.equal(host.publicAddress, "");
|
||||
assert.equal(host.tokenValidAfter, 0);
|
||||
assert.equal(host.updatedAtMs, 0);
|
||||
assert.equal(host.isDeleted, false);
|
||||
});
|
||||
|
||||
it("should preserve optional fields exactly when provided", function () {
|
||||
const host = HostDetails.fromJSON({
|
||||
version: "3.0.0",
|
||||
name: "RemoteHost",
|
||||
id: 7,
|
||||
connected: false,
|
||||
remoteAddress: "192.168.1.5",
|
||||
publicAddress: "198.51.100.10",
|
||||
tokenValidAfter: 100,
|
||||
updatedAtMs: 2000,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
assert.equal(host.remoteAddress, "192.168.1.5");
|
||||
assert.equal(host.publicAddress, "198.51.100.10");
|
||||
assert.equal(host.tokenValidAfter, 100);
|
||||
assert.equal(host.updatedAtMs, 2000);
|
||||
assert.equal(host.isDeleted, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { InstanceDetails } = require("@clusterio/lib");
|
||||
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
|
||||
|
||||
describe("lib/data/InstanceDetails", function () {
|
||||
|
||||
it("should be round trip serialisable", function () {
|
||||
testRoundTripJsonSerialisable(InstanceDetails, testMatrix(
|
||||
["TestInstance"], // name (required)
|
||||
[1], // id (required)
|
||||
[undefined, 5], // assignedHost
|
||||
[undefined, 34197], // gamePort
|
||||
["running", "stopped"], // status (required)
|
||||
[undefined, "1.0.0"], // factorioVersion
|
||||
[undefined, 1234], // startedAtMs
|
||||
[undefined, 9999], // updatedAtMs
|
||||
[undefined, true], // excludeFromStartAll
|
||||
));
|
||||
});
|
||||
|
||||
describe("constructor", function () {
|
||||
it("should set defaults correctly", function () {
|
||||
const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running");
|
||||
|
||||
assert.equal(instance.name, "TestInstance");
|
||||
assert.equal(instance.id, 1);
|
||||
assert.equal(instance.assignedHost, undefined);
|
||||
assert.equal(instance.gamePort, undefined);
|
||||
assert.equal(instance.status, "running");
|
||||
assert.equal(instance.factorioVersion, undefined);
|
||||
assert.equal(instance.startedAtMs, 0);
|
||||
assert.equal(instance.updatedAtMs, 0);
|
||||
assert.equal(instance.excludeFromStartAll, false);
|
||||
});
|
||||
|
||||
it("should accept all constructor parameters", function () {
|
||||
const instance = new InstanceDetails(
|
||||
"FullInstance", 42, 10, 34197,
|
||||
"starting", "1.0.0", 1000, 2000, true
|
||||
);
|
||||
|
||||
assert.equal(instance.name, "FullInstance");
|
||||
assert.equal(instance.id, 42);
|
||||
assert.equal(instance.assignedHost, 10);
|
||||
assert.equal(instance.gamePort, 34197);
|
||||
assert.equal(instance.status, "starting");
|
||||
assert.equal(instance.factorioVersion, "1.0.0");
|
||||
assert.equal(instance.startedAtMs, 1000);
|
||||
assert.equal(instance.updatedAtMs, 2000);
|
||||
assert.equal(instance.excludeFromStartAll, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toJSON()", function () {
|
||||
it("should only include required fields when optional fields are not given", function () {
|
||||
const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running");
|
||||
|
||||
assert.deepEqual(instance.toJSON(), {
|
||||
name: "TestInstance",
|
||||
id: 1,
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("should include all optional fields when set", function () {
|
||||
const instance = new InstanceDetails(
|
||||
"FullInstance", 99, 5, 34197,
|
||||
"running", "1.0.0", 500, 1000, true
|
||||
);
|
||||
|
||||
assert.deepEqual(instance.toJSON(), {
|
||||
name: "FullInstance",
|
||||
id: 99,
|
||||
status: "running",
|
||||
assignedHost: 5,
|
||||
gamePort: 34197,
|
||||
factorioVersion: "1.0.0",
|
||||
startedAtMs: 500,
|
||||
updatedAtMs: 1000,
|
||||
excludeFromStartAll: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit optional fields when equal to defaults", function () {
|
||||
const instance = new InstanceDetails(
|
||||
"TestInstance", 1, undefined, undefined,
|
||||
"stopped", undefined, 0, 0, false
|
||||
);
|
||||
|
||||
assert.deepEqual(instance.toJSON(), {
|
||||
name: "TestInstance",
|
||||
id: 1,
|
||||
status: "stopped",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromJSON()", function () {
|
||||
it("should construct minimal object and apply constructor defaults", function () {
|
||||
const instance = InstanceDetails.fromJSON({
|
||||
name: "TestInstance",
|
||||
id: 1,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
assert.equal(instance.name, "TestInstance");
|
||||
assert.equal(instance.id, 1);
|
||||
assert.equal(instance.assignedHost, undefined);
|
||||
assert.equal(instance.gamePort, undefined);
|
||||
assert.equal(instance.factorioVersion, undefined);
|
||||
assert.equal(instance.startedAtMs, 0);
|
||||
assert.equal(instance.updatedAtMs, 0);
|
||||
assert.equal(instance.excludeFromStartAll, false);
|
||||
});
|
||||
|
||||
it("should preserve optional fields exactly when provided", function () {
|
||||
const instance = InstanceDetails.fromJSON({
|
||||
name: "RemoteInstance",
|
||||
id: 7,
|
||||
status: "starting",
|
||||
assignedHost: 2,
|
||||
gamePort: 34197,
|
||||
factorioVersion: "1.0.0",
|
||||
startedAtMs: 100,
|
||||
updatedAtMs: 200,
|
||||
excludeFromStartAll: true,
|
||||
});
|
||||
|
||||
assert.equal(instance.assignedHost, 2);
|
||||
assert.equal(instance.gamePort, 34197);
|
||||
assert.equal(instance.factorioVersion, "1.0.0");
|
||||
assert.equal(instance.startedAtMs, 100);
|
||||
assert.equal(instance.updatedAtMs, 200);
|
||||
assert.equal(instance.excludeFromStartAll, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get .isDeleted", function () {
|
||||
it("should return true when status is 'deleted'", function () {
|
||||
const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "deleted");
|
||||
|
||||
assert.equal(instance.isDeleted, true);
|
||||
});
|
||||
|
||||
it("should return false when status is not 'deleted'", function () {
|
||||
const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running");
|
||||
|
||||
assert.equal(instance.isDeleted, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
|
||||
describe("lib/data/User", function() {
|
||||
describe("class User", function() {
|
||||
it("should round trip serialize", function() {
|
||||
function test_roundtrip(serialized) {
|
||||
let user = lib.User.fromJSON(serialized);
|
||||
let user_serialized = user.toJSON();
|
||||
assert.deepEqual(user_serialized, serialized);
|
||||
let user_deserialized = lib.User.fromJSON(user_serialized);
|
||||
assert.deepEqual(user_deserialized, user);
|
||||
}
|
||||
|
||||
test_roundtrip({ name: "admin", roles: [1] });
|
||||
test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true });
|
||||
test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" });
|
||||
test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]});
|
||||
});
|
||||
it("should keep invalid roles", function() {
|
||||
let user = lib.User.fromJSON({ name: "test", roles: [1, 4, 55] });
|
||||
assert.equal(user.roleIds.size, 3, "Unexpected count of roles");
|
||||
});
|
||||
it("should calculate playerStats", function() {
|
||||
let user = lib.User.fromJSON({ name: "test", roles: [1], instance_stats: [
|
||||
[1, {
|
||||
join_count: 1,
|
||||
online_time_ms: 60e3,
|
||||
last_join_at_ms: new Date("2020-05T12:00Z").getTime(),
|
||||
last_leave_at_ms: new Date("2020-05T12:01Z").getTime(),
|
||||
last_leave_reason: "quit",
|
||||
}],
|
||||
[2, {
|
||||
join_count: 1,
|
||||
online_time_ms: 0,
|
||||
first_join_at_ms: new Date("2020-05T12:00Z").getTime(),
|
||||
last_join_at_ms: new Date("2020-05T12:02Z").getTime(),
|
||||
}],
|
||||
]});
|
||||
|
||||
assert.equal(user.playerStats.onlineTimeMs, 60e3);
|
||||
assert.equal(user.playerStats.joinCount, 2);
|
||||
assert.deepEqual(user.playerStats.firstJoinAt, new Date("2020-05T12:00Z"));
|
||||
assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T12:02Z"));
|
||||
assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T12:01Z"));
|
||||
assert.equal(user.playerStats.lastLeaveReason, "quit");
|
||||
|
||||
user.instanceStats.set(
|
||||
3,
|
||||
lib.PlayerStats.fromJSON({
|
||||
join_count: 2,
|
||||
online_time_ms: 120e3,
|
||||
first_join_at_ms: new Date("2020-05T11:00Z").getTime(),
|
||||
last_join_at_ms: new Date("2020-05T13:00Z").getTime(),
|
||||
last_leave_at_ms: new Date("2020-05T13:02Z").getTime(),
|
||||
last_leave_reason: "afk",
|
||||
}),
|
||||
);
|
||||
|
||||
user.recalculatePlayerStats();
|
||||
assert.equal(user.playerStats.onlineTimeMs, 180e3);
|
||||
assert.equal(user.playerStats.joinCount, 4);
|
||||
assert.deepEqual(user.playerStats.firstJoinAt, new Date("2020-05T11:00Z"));
|
||||
assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T13:00Z"));
|
||||
assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T13:02Z"));
|
||||
assert.equal(user.playerStats.lastLeaveReason, "afk");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { UserDetails, PlayerStats } = require("@clusterio/lib");
|
||||
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
|
||||
|
||||
describe("lib/data/UserDetails", function () {
|
||||
it("should be round trip serialisable", function () {
|
||||
testRoundTripJsonSerialisable(UserDetails, testMatrix(
|
||||
["TestUser"], // name (required)
|
||||
[undefined, new Set([1])], // roleIds
|
||||
[undefined, new Set([2])], // instances
|
||||
[undefined, true], // isAdmin
|
||||
[undefined, true], // isBanned
|
||||
[undefined, true], // isWhitelisted
|
||||
[undefined, "reason"], // banReason
|
||||
[undefined, 123], // updatedAtMs
|
||||
[undefined, true], // isDeleted
|
||||
[ // instanceStats
|
||||
undefined,
|
||||
new Map([
|
||||
[1, PlayerStats.fromJSON({ join_count: 1, online_time_ms: 100 })],
|
||||
]),
|
||||
],
|
||||
));
|
||||
});
|
||||
|
||||
describe("constructor", function () {
|
||||
it("should set defaults correctly", function () {
|
||||
const user = new UserDetails("TestUser");
|
||||
|
||||
assert.equal(user.name, "TestUser");
|
||||
assert.equal(user.roleIds.size, 0);
|
||||
assert.equal(user.instances.size, 0);
|
||||
assert.equal(user.isAdmin, false);
|
||||
assert.equal(user.isBanned, false);
|
||||
assert.equal(user.isWhitelisted, false);
|
||||
assert.equal(user.banReason, "");
|
||||
assert.equal(user.updatedAtMs, 0);
|
||||
assert.equal(user.isDeleted, false);
|
||||
assert(user.playerStats instanceof PlayerStats);
|
||||
assert(user.instanceStats instanceof Map);
|
||||
});
|
||||
|
||||
it("should accept all constructor parameters", function () {
|
||||
const user = new UserDetails(
|
||||
"TestUser", new Set([1]), new Set([2]),
|
||||
true, true, true, "reason",
|
||||
100, true,
|
||||
new Map()
|
||||
);
|
||||
|
||||
assert.equal(user.name, "TestUser");
|
||||
assert.deepEqual(user.roleIds, new Set([1]));
|
||||
assert.deepEqual(user.instances, new Set([2]));
|
||||
assert.equal(user.isAdmin, true);
|
||||
assert.equal(user.isBanned, true);
|
||||
assert.equal(user.isWhitelisted, true);
|
||||
assert.equal(user.banReason, "reason");
|
||||
assert.equal(user.updatedAtMs, 100);
|
||||
assert.equal(user.isDeleted, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get .id", function () {
|
||||
it("should return lowercase name", function () {
|
||||
const user = new UserDetails("AdMiN");
|
||||
assert.equal(user.id, "admin");
|
||||
});
|
||||
|
||||
it("should reflect name changes dynamically", function () {
|
||||
const user = new UserDetails("User");
|
||||
user.name = "NEWNAME";
|
||||
assert.equal(user.id, "newname");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toJSON()", function () {
|
||||
it("should only include required fields when optional fields are not given", function () {
|
||||
const user = new UserDetails("Test");
|
||||
assert.deepEqual(user.toJSON(), { name: "Test" });
|
||||
});
|
||||
|
||||
it("should include all optional fields when set", function () {
|
||||
const stats = new PlayerStats({ join_count: 1 });
|
||||
const user = new UserDetails(
|
||||
"Test", new Set([1, 2]), new Set([10]),
|
||||
true, true, true, "reason",
|
||||
123, true,
|
||||
new Map([[1, stats]])
|
||||
);
|
||||
|
||||
assert.deepEqual(user.toJSON(), {
|
||||
name: "Test",
|
||||
roles: [1, 2],
|
||||
instances: [10],
|
||||
is_admin: true,
|
||||
is_banned: true,
|
||||
is_whitelisted: true,
|
||||
ban_reason: "reason",
|
||||
updated_at_ms: 123,
|
||||
is_deleted: true,
|
||||
instance_stats: [[1, stats.toJSON()]],
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit optional fields when equal to defaults", function () {
|
||||
const user = new UserDetails(
|
||||
"Test", new Set(), new Set(),
|
||||
false, false, false, "",
|
||||
0, false,
|
||||
new Map()
|
||||
);
|
||||
|
||||
assert.deepEqual(user.toJSON(), { name: "Test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("static fromJSON()", function () {
|
||||
it("should construct minimal object", function () {
|
||||
const user = UserDetails.fromJSON({ name: "Test" });
|
||||
|
||||
assert.equal(user.name, "Test");
|
||||
assert.deepEqual(user.roleIds, new Set());
|
||||
assert.deepEqual(user.instances, new Set());
|
||||
assert.deepEqual(user.isAdmin, false);
|
||||
assert.equal(user.isBanned, false);
|
||||
assert.equal(user.isWhitelisted, false);
|
||||
assert.equal(user.banReason, "");
|
||||
assert.equal(user.updatedAtMs, 0);
|
||||
assert.equal(user.isDeleted, false);
|
||||
assert.deepEqual(user.instanceStats, new Map());
|
||||
});
|
||||
|
||||
it("should preserve optional fields exactly when provided", function () {
|
||||
const stats = new PlayerStats({ join_count: 1 });
|
||||
const user = UserDetails.fromJSON({
|
||||
name: "Test",
|
||||
roles: [1, 2],
|
||||
instances: [10],
|
||||
is_admin: true,
|
||||
is_banned: true,
|
||||
is_whitelisted: true,
|
||||
ban_reason: "reason",
|
||||
updated_at_ms: 123,
|
||||
is_deleted: true,
|
||||
instance_stats: [[1, stats.toJSON()]],
|
||||
});
|
||||
|
||||
assert.equal(user.name, "Test");
|
||||
assert.deepEqual(user.roleIds, new Set([1, 2]));
|
||||
assert.deepEqual(user.instances, new Set([10]));
|
||||
assert.deepEqual(user.isAdmin, true);
|
||||
assert.equal(user.isBanned, true);
|
||||
assert.equal(user.isWhitelisted, true);
|
||||
assert.equal(user.banReason, "reason");
|
||||
assert.equal(user.updatedAtMs, 123);
|
||||
assert.equal(user.isDeleted, true);
|
||||
assert.deepEqual(user.instanceStats, new Map([[1, stats]]));
|
||||
});
|
||||
|
||||
it("should preserve roles exactly (including invalid ones)", function () {
|
||||
const user = UserDetails.fromJSON({
|
||||
name: "Test",
|
||||
roles: [1, 4, 55],
|
||||
});
|
||||
|
||||
assert.equal(user.roleIds.size, 3);
|
||||
assert(user.roleIds.has(55));
|
||||
});
|
||||
|
||||
it("should reconstruct instanceStats and calculate playerStats", function () {
|
||||
const json = {
|
||||
name: "Test",
|
||||
instance_stats: [
|
||||
[1, { join_count: 1, online_time_ms: 100 }],
|
||||
[2, { join_count: 2, online_time_ms: 200 }],
|
||||
],
|
||||
};
|
||||
|
||||
const user = UserDetails.fromJSON(json);
|
||||
|
||||
assert.equal(user.instanceStats.size, 2);
|
||||
assert.equal(user.playerStats.joinCount, 3);
|
||||
assert.equal(user.playerStats.onlineTimeMs, 300);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".recalculatePlayerStats()", function () {
|
||||
it("should aggregate stats correctly across multiple instances", function () {
|
||||
const user = UserDetails.fromJSON({
|
||||
name: "test",
|
||||
instance_stats: [
|
||||
[1, {
|
||||
join_count: 1,
|
||||
online_time_ms: 60e3,
|
||||
last_join_at_ms: new Date("2020-05-05T12:00Z").getTime(),
|
||||
last_leave_at_ms: new Date("2020-05-05T12:01Z").getTime(),
|
||||
last_leave_reason: "quit",
|
||||
}],
|
||||
[2, {
|
||||
join_count: 1,
|
||||
online_time_ms: 0,
|
||||
first_join_at_ms: new Date("2020-05-05T12:00Z").getTime(),
|
||||
last_join_at_ms: new Date("2020-05-05T12:02Z").getTime(),
|
||||
}],
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(user.playerStats.joinCount, 2);
|
||||
assert.equal(user.playerStats.onlineTimeMs, 60e3);
|
||||
|
||||
user.instanceStats.set(3, PlayerStats.fromJSON({
|
||||
join_count: 2,
|
||||
online_time_ms: 120e3,
|
||||
first_join_at_ms: new Date("2020-05-05T11:00Z").getTime(),
|
||||
last_join_at_ms: new Date("2020-05-05T13:00Z").getTime(),
|
||||
last_leave_at_ms: new Date("2020-05-05T13:02Z").getTime(),
|
||||
last_leave_reason: "afk",
|
||||
}));
|
||||
|
||||
user.recalculatePlayerStats();
|
||||
|
||||
assert.equal(user.playerStats.joinCount, 4);
|
||||
assert.equal(user.playerStats.onlineTimeMs, 180e3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static _calculatePlayerStats()", function () {
|
||||
it("should return empty stats for empty map", function () {
|
||||
const stats = UserDetails._calculatePlayerStats(new Map());
|
||||
assert.equal(stats.joinCount, 0);
|
||||
assert.equal(stats.onlineTimeMs, 0);
|
||||
});
|
||||
|
||||
it("should pick earliest firstJoin and latest joins/leaves", function () {
|
||||
const a = PlayerStats.fromJSON({
|
||||
first_join_at_ms: new Date("2020-01-01").getTime(),
|
||||
last_join_at_ms: new Date("2020-01-02").getTime(),
|
||||
last_leave_at_ms: new Date("2020-01-02").getTime(),
|
||||
last_leave_reason: "quit",
|
||||
join_count: 1,
|
||||
online_time_ms: 100,
|
||||
});
|
||||
|
||||
const b = PlayerStats.fromJSON({
|
||||
first_join_at_ms: new Date("2019-01-01").getTime(),
|
||||
last_join_at_ms: new Date("2021-01-01").getTime(),
|
||||
last_leave_at_ms: new Date("2021-01-02").getTime(),
|
||||
last_leave_reason: "afk",
|
||||
join_count: 2,
|
||||
online_time_ms: 200,
|
||||
});
|
||||
|
||||
const stats = UserDetails._calculatePlayerStats(
|
||||
new Map([[1, a], [2, b]])
|
||||
);
|
||||
|
||||
assert.equal(stats.joinCount, 3);
|
||||
assert.equal(stats.onlineTimeMs, 300);
|
||||
assert.deepEqual(stats.firstJoinAt, new Date("2019-01-01"));
|
||||
assert.deepEqual(stats.lastJoinAt, new Date("2021-01-01"));
|
||||
assert.deepEqual(stats.lastLeaveAt, new Date("2021-01-02"));
|
||||
assert.equal(stats.lastLeaveReason, "afk");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,21 @@ describe("lib/datastore", function() {
|
||||
await fs.copy(path.join(testFiles, "json", "invalid.json"), dataPath);
|
||||
await assert.rejects(() => datastoreProvider.load(dataPath));
|
||||
});
|
||||
it("writes a backup if size changes after load", async function() {
|
||||
datastoreProvider = new lib.JsonDatastoreProvider(
|
||||
dataPath,
|
||||
json => json, // Nop
|
||||
rawJson => rawJson, // Nop
|
||||
() => undefined, // Throw away all data
|
||||
);
|
||||
|
||||
const entriesBefore = await fs.readdir(baseDir);
|
||||
const loadedData = await datastoreProvider.load();
|
||||
const entriesAfter = await fs.readdir(baseDir);
|
||||
|
||||
assert.equal(entriesAfter.length, entriesBefore.length + 1, "Backup was not created");
|
||||
assert.equal(loadedData.size, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -260,6 +275,21 @@ describe("lib/datastore", function() {
|
||||
await fs.copy(path.join(testFiles, "json", "invalid.json"), dataPath);
|
||||
await assert.rejects(() => datastoreProvider.load(dataPath));
|
||||
});
|
||||
it("writes a backup if size changes after load", async function() {
|
||||
datastoreProvider = new lib.JsonIdDatastoreProvider(
|
||||
dataPath,
|
||||
json => json, // Nop
|
||||
rawJson => rawJson, // Nop
|
||||
() => undefined, // Throw away all data
|
||||
);
|
||||
|
||||
const entriesBefore = await fs.readdir(baseDir);
|
||||
const loadedData = await datastoreProvider.load();
|
||||
const entriesAfter = await fs.readdir(baseDir);
|
||||
|
||||
assert.equal(entriesAfter.length, entriesBefore.length + 1, "Backup was not created");
|
||||
assert.equal(loadedData.size, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const assert = require("assert").strict;
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
const { Controller, ControlConnection, ControllerUser } = require("@clusterio/controller");
|
||||
const { Controller, ControlConnection } = require("@clusterio/controller");
|
||||
|
||||
describe("messages/controller", function() {
|
||||
/** @type {Controller} */
|
||||
@@ -17,7 +17,7 @@ describe("messages/controller", function() {
|
||||
lib.Address.fromShorthand({ controlId: 1 }),
|
||||
);
|
||||
controller = new Controller(lib.logger, [], controllerConfig);
|
||||
const user = new ControllerUser(controller.userManager, undefined, "test");
|
||||
const user = controller.users.getOrCreateUser("test");
|
||||
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const { ModDependencyResolveRequest, ModDependency, ModInfo } = lib;
|
||||
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
|
||||
|
||||
const { Controller, ControlConnection, ControllerUser } = require("@clusterio/controller");
|
||||
const { Controller, ControlConnection } = require("@clusterio/controller");
|
||||
const { slowTest } = require("../integration");
|
||||
|
||||
describe("messages/mod", function() {
|
||||
@@ -58,7 +58,7 @@ describe("messages/mod", function() {
|
||||
lib.Address.fromShorthand({ controlId: 1 }),
|
||||
);
|
||||
controller = new Controller(lib.logger, [], controllerConfig);
|
||||
const user = new ControllerUser(controller.userManager, undefined, "test");
|
||||
const user = controller.users.getOrCreateUser("test");
|
||||
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const assert = require("assert").strict;
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
const { Controller, ControlConnection, ControllerUser, BaseControllerPlugin } = require("@clusterio/controller");
|
||||
const { Controller, ControlConnection } = require("@clusterio/controller");
|
||||
const { Host } = require("@clusterio/host");
|
||||
|
||||
describe("messages/plugin", function() {
|
||||
@@ -20,7 +20,7 @@ describe("messages/plugin", function() {
|
||||
lib.Address.fromShorthand({ controlId: 1 }),
|
||||
);
|
||||
controller = new Controller(lib.logger, [], controllerConfig);
|
||||
const user = new ControllerUser(controller.userManager, undefined, "test");
|
||||
const user = controller.users.getOrCreateUser("test");
|
||||
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
|
||||
|
||||
const hostConfig = new lib.HostConfig("host");
|
||||
|
||||
+13
-10
@@ -4,9 +4,7 @@ const http = require("http");
|
||||
const express = require("express");
|
||||
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
const UserManager = require("@clusterio/controller/dist/node/src/UserManager").default;
|
||||
const ControllerUser = require("@clusterio/controller/dist/node/src/ControllerUser").default;
|
||||
const { User, UserManager, UserRecord, InstanceManager } = require("@clusterio/controller");
|
||||
|
||||
const addr = lib.Address.fromShorthand;
|
||||
|
||||
@@ -179,15 +177,20 @@ class MockController {
|
||||
]));
|
||||
|
||||
this.roles = new lib.SubscribableDatastore(roleProvider, roleProvider.value);
|
||||
this.roles.get(1).grantDefaultPermissions();
|
||||
this.roles.get(1).grantDefaultPermissions(); // Todo replace "core.admin" above with grantAdminPermissions
|
||||
|
||||
this.userManager = new UserManager(this.config, this.roles);
|
||||
this.userManager.users = new Map([
|
||||
["test", ControllerUser.fromJSON({ name: "test", roles: [0, 1] }, this.roles)],
|
||||
["player", ControllerUser.fromJSON({ name: "player", roles: [1] }, this.roles)],
|
||||
]);
|
||||
const userProvider = new lib.MemoryDatastoreProvider(new Map([
|
||||
["test", UserRecord.fromJSON({ name: "test", roles: [0, 1] })],
|
||||
["player", UserRecord.fromJSON({ name: "player", roles: [1] })],
|
||||
]));
|
||||
|
||||
this.users = new UserManager(
|
||||
new lib.SubscribableDatastore(userProvider, userProvider.value),
|
||||
this.roles, this.config
|
||||
);
|
||||
|
||||
this.instances = new InstanceManager(new lib.KeyValueDatastore(), this);
|
||||
|
||||
this.instances = new lib.KeyValueDatastore();
|
||||
this.hosts = new lib.KeyValueDatastore();
|
||||
this.handles = new Map();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user