diff --git a/docs/config-system.md b/docs/config-system.md index 058dba45..3b9c8278 100644 --- a/docs/config-system.md +++ b/docs/config-system.md @@ -28,6 +28,16 @@ after the groupName have been set and before the class has been finalized. The `define` method takes an object as argument with the following properties: +**access**: + The locations this config field can be read and modified from. This + is an array of strings which can contain the values `"master"`, + `"slave"` and `"control"`. If a party is missing in this array then + that party will not receive the value of this field when the config + is shared, and will be unable to modify the field. Takes the value + of `defaultAccess` set on the config group class if not passed. + Note that instance config groups should have both `"master"` and + `"slave"` for all of its fields or unexpected behaviour may occur. + **type**: The type of config value this field will support. The supported values are boolean, string, number, and object, and if an attept is diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 62b8c854..1f624fad 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -224,12 +224,13 @@ but for event handlers registered on EventEmitters it's critical that exceptions are catched and handled appropriately. -Defining Configuration ----------------------- +Plugin Configuration +-------------------- Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you. You can -take advantage of it by subclassing `PluginConfigGroup`, setting the +take advantage of it by subclassing `PluginConfigGroup`, setting +`defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in the `info.js` export. For example in info.js: @@ -237,6 +238,7 @@ the `info.js` export. For example in info.js: const libConfig = require("@clusterio/lib/config"); class MasterConfigGroup extends libConfig.PluginConfigGroup { } + MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = "foo_frobber"; MasterConfigGroup.define({ name: "level", @@ -261,7 +263,7 @@ for example in the MasterPluginClass: } The same applies for instance configs, replace "master" with "instance" -where appropriate. See [Configuration System](configuration-system.md) +where appropriate. See [Configuration System](config-system.md) for more details on how this system works. diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index beb4159a..7ca51d56 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -31,6 +31,79 @@ function print(...content) { console.log(...content); } + +const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" }); +const masterConfigCommands = new libCommand.CommandTree({ + name: "config", alias: ["c"], description: "master config management", +}); +masterConfigCommands.add(new libCommand.Command({ + definition: ["list", "List master configuration"], + handler: async function(args, control) { + let response = await libLink.messages.getMasterConfig.send(control); + + for (let group of response.serialized_config.groups) { + for (let [name, value] of Object.entries(group.fields)) { + print(`${group.name}.${name} ${JSON.stringify(value)}`); + } + } + }, +})); + +masterConfigCommands.add(new libCommand.Command({ + definition: ["set [value]", "Set field in master config", (yargs) => { + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("value", { describe: "Value to set", type: "string" }); + yargs.options({ + "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await libLink.messages.setMasterConfigField.send(control, { + field: args.field, + value: args.value, + }); + }, +})); + +masterConfigCommands.add(new libCommand.Command({ + definition: ["set-prop [value]", "Set property of field in master config", (yargs) => { + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("prop", { describe: "Property to set", type: "string" }); + yargs.positional("value", { describe: "JSON parsed value to set", type: "string" }); + yargs.options({ + "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let request = { + field: args.field, + prop: args.prop, + }; + try { + if (args.value !== undefined) { + request.value = JSON.parse(args.value); + } + } catch (err) { + // See note for the instance version of set-prop + if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { + throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + request.value = args.value; + } + await libLink.messages.setMasterConfigProp.send(control, request); + }, +})); +masterCommands.add(masterConfigCommands); + + const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" }); slaveCommands.add(new libCommand.Command({ definition: [["list", "l"], "List slaves connected to the master"], @@ -104,13 +177,13 @@ instanceCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("control"); await instanceConfig.init(); if (args.id) { instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize(); + let serialized_config = instanceConfig.serialize("master"); let response = await libLink.messages.createInstance.send(control, { serialized_config }); }, })); @@ -701,6 +774,7 @@ async function loadPlugins(pluginList) { async function registerCommands(controlPlugins, yargs) { const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); + rootCommands.add(masterCommands); rootCommands.add(slaveCommands); rootCommands.add(instanceCommands); rootCommands.add(permissionCommands); @@ -792,7 +866,7 @@ async function startControl() { ; logger.verbose(`Loading config from ${args.config}`); - let controlConfig = new libConfig.ControlConfig(); + let controlConfig = new libConfig.ControlConfig("control"); try { await controlConfig.load(JSON.parse(await fs.readFile(args.config))); diff --git a/packages/lib/config/classes.js b/packages/lib/config/classes.js index df5f2ae2..0394564a 100644 --- a/packages/lib/config/classes.js +++ b/packages/lib/config/classes.js @@ -7,6 +7,17 @@ const isDeepStrictEqual = require("../is_deep_strict_equal"); const { basicType } = require("@clusterio/lib/helpers"); +/** + * Invalid Access exception + * + * Exception class for when an access attempt is made to a field that + * does not permitt this access is done. + * + * @extends Error + * @memberof module:lib/config + */ +class InvalidAccess extends Error { }; + /** * Invalid Value exception * @@ -69,13 +80,24 @@ function splitOn(separator, string) { * @memberof module:lib/config */ class Config extends events.EventEmitter { - constructor() { + /** + * Create a new instance of the given config + * + * @param {string} location - + * Location to evaluate access for this instance from + */ + constructor(location) { super(); if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`); } + if (typeof location !== "string") { + throw new Error("location must be a string"); + } + + this.location = location; this._groups = new Map(); this._unknownGroups = new Map(); } @@ -83,8 +105,8 @@ class Config extends events.EventEmitter { async init() { for (let [name, GroupClass] of this.constructor._groups) { if (!this._groups.has(name)) { - let group = new GroupClass(); - await group.init(this); + let group = new GroupClass(this); + await group.init(); this._groups.set(name, group); } } @@ -101,7 +123,13 @@ class Config extends events.EventEmitter { } } - async load(serializedConfig) { + /** + * Load config from a serialized object + * + * @param {object} serializedConfig - Serialized config to load. + * @param {string=} location - Location used for access control. + */ + async load(serializedConfig, location = this.location) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let GroupClass = this.constructor._groups.get(serializedGroup.name); @@ -110,8 +138,8 @@ class Config extends events.EventEmitter { continue; } - let group = new GroupClass(); - await group.load(this, serializedGroup); + let group = new GroupClass(this); + await group.load(serializedGroup, location); this._groups.set(group.name, group); } @@ -121,12 +149,13 @@ class Config extends events.EventEmitter { /** * Serialize the config to a plain JavaScript object * + * @param {string=} location - Location used for access control. * @returns {Object} JSON serializable representation of the config. */ - serialize() { + serialize(location = this.location) { let groups = [...this._unknownGroups.values()]; for (let group of this._groups.values()) { - groups.push(group.serialize()); + groups.push(group.serialize(location)); } return { groups }; @@ -140,28 +169,42 @@ class Config extends events.EventEmitter { * * @param {Object} serializedConfig - Output from .serialize(). * @param {boolean} notify - Invoke fieldChanged events if true. + * @param {string=} location - Location used for access control. */ - update(serializedConfig, notify) { + update(serializedConfig, notify, location = this.location) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let group = this._groups.get(serializedGroup.name); if (group) { - group.update(serializedGroup, notify); + group.update(serializedGroup, notify, location); } else { this._unknownGroups.set(serializedGroup.name, serializedGroup); } } } + /** + * Check if field can be accessed from the given location + * + * @param {string} name - Name of the field to check. + * @param {string=} location - Location used for access control. + * @returns {boolean} true if field is accessible + */ + canAccess(name, location = this.location) { + let [group, field] = splitOn(".", name); + return this.group(group).canAccess(field, location); + } + /** * Get the value for a config field * * @param {string} name - Name of field to get. + * @param {string=} location - Location used for access control. * @returns {*} the value for the field. */ - get(name) { + get(name, location = this.location) { let [group, field] = splitOn(".", name); - return this.group(group).get(field); + return this.group(group).get(field, location); } /** @@ -169,10 +212,11 @@ class Config extends events.EventEmitter { * * @param {string} name - Name of field to set. * @param {*} value - the value to set on field + * @param {string=} location - Location used for access control. */ - set(name, value) { + set(name, value, location = this.location) { let [group, field] = splitOn(".", name); - this.group(group).set(field, value); + this.group(group).set(field, value, location); } /** @@ -181,10 +225,11 @@ class Config extends events.EventEmitter { * @param {string} name - Name of field to set property on. * @param {string} prop - Name of property to set on field. * @param {*} value - the value to set the property to. + * @param {string=} location - Location used for access control. */ - setProp(name, prop, value) { + setProp(name, prop, value, location = this.location) { let [group, field] = splitOn(".", name); - this.group(group).setProp(field, prop, value); + this.group(group).setProp(field, prop, value, location); } /** @@ -280,15 +325,42 @@ class ConfigGroup { * * After the creation of the new config group you have to call * either .init() or .load() on it in order to fully initialize it. + * + * @param {module:lib/config.Config} config - + * Parent config for this group instance. */ - constructor() { + constructor(config) { if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`); } this._fields = new Map(); this._unknownFields = new Map(); - this._config = null; + this.config = config; + } + + /** + * Check access for a field + * + * @param {object} def - definition for for the field to check. + * @param {string} remote - location of remote access to check for. + * @param {boolean} error - throw on failure to pass check. + * @throws {module:lib/config.InvalidAccess} + * if access is not granted and error is true. + * @returns {boolean} true if access is granted + * @private + */ + _checkAccess(def, remote, error) { + for (let loc of [remote, this.config.location]) { + if (!def.access.includes(loc)) { + if (error) { + throw new InvalidAccess(`Field '${def.name}' is not accessible from ${loc}`); + } else { + return false; + } + } + } + return true; } /** @@ -296,16 +368,15 @@ class ConfigGroup { * * Computes and assigns the initial values for all fields of the * config group. - * - * @param {module:lib/config.Config} config - Config group belongs to. */ - async init(config) { - this._config = config; - + async init() { for (let [name, def] of this.constructor._definitions) { if (this._fields.has(name)) { continue; } + if (!def.access.includes(this.config.location)) { + continue; + } let value = null; if (typeof def.initial_value === "function") { @@ -326,13 +397,13 @@ class ConfigGroup { * group and then initializes any possible missing fields from it to * their default values. * - * @param {module:lib/config.Config} config - Config group belongs to. * @param {Object} serializedGroup - * Result from a previous call to .serialize(). + * @param {string=} location - Location used for access control. */ - async load(config, serializedGroup) { - this.update(serializedGroup, false); - await this.init(config); + async load(serializedGroup, location = this.config.location) { + this.update(serializedGroup, false, location); + await this.init(); } /** @@ -344,16 +415,34 @@ class ConfigGroup { return this.constructor.groupName; } + /** + * Check if field can be accessed from the given location + * + * @param {string} name - Name of field to check. + * @param {string=} location - Location used for access control. + * @returns {boolean} true if field is accessible + */ + canAccess(name, location = this.config.location) { + let def = this.constructor._definitions.get(name); + if (!def) { + throw new InvalidField(`No field named '${name}'`); + } + return this._checkAccess(def, location, false); + } + /** * Get value for field * * @param {string} name - Name of field to get. + * @param {string=} location - Location used for access control. * @returns {*} Value stored for the field. */ - get(name) { - if (!this.constructor._definitions.has(name)) { + get(name, location = this.config.location) { + let def = this.constructor._definitions.get(name); + if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); return this._fields.get(name); } @@ -366,14 +455,16 @@ class ConfigGroup { * * @param {string} name - Name of field to set. * @param {*} value - Value to set for field. + * @param {string=} location - Location used for access control. * @throws {module:lib/config.InvalidField} if field is not defined. * @throws {module:lib/config.InvalidValue} if value is not allowed for the field. */ - set(name, value) { + set(name, value, location = this.config.location) { let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); // Empty strings are treates as null if (value === "") { @@ -418,8 +509,8 @@ class ConfigGroup { let prev = this._fields.get(name); this._fields.set(name, value); - if (this._config && !isDeepStrictEqual(value, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (this.config && !isDeepStrictEqual(value, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } @@ -432,14 +523,16 @@ class ConfigGroup { * @param {string} name - Name of field to set property on. * @param {string} prop - Name of property to set on field. * @param {*} value - the value to set the property to. + * @param {string=} location - Location used for access control. * @throws {module:lib/config.InvalidField} if field is not defined. * @throws {module:lib/config.InvalidValue} if field is not an object. */ - setProp(name, prop, value) { + setProp(name, prop, value, location = this.config.location) { let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); if (def.type !== "object") { throw new InvalidValue(`Cannot set property on non-object field '${name}'`); @@ -454,8 +547,8 @@ class ConfigGroup { delete updated[prop]; } this._fields.set(name, updated); - if (this._config && !isDeepStrictEqual(updated, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (this.config && !isDeepStrictEqual(updated, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } @@ -468,8 +561,9 @@ class ConfigGroup { * @param {Object} serializedGroup - * Result from a previous call to .serialize(). * @param {boolean} notify - Invoke fieldChanged events if true. + * @param {string=} location - Location used for access control. */ - update(serializedGroup, notify) { + update(serializedGroup, notify, location = this.config.location) { if (basicType(serializedGroup) !== "object") { throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`); } @@ -489,6 +583,10 @@ class ConfigGroup { continue; } + if (!this._checkAccess(def, location, false)) { + continue; + } + if (def.optional) { if (value !== null && basicType(value) !== def.type) { continue; @@ -504,8 +602,8 @@ class ConfigGroup { let prev = this._fields.get(name); this._fields.set(name, value); - if (notify && this._config && !isDeepStrictEqual(value, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (notify && this.config && !isDeepStrictEqual(value, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } } @@ -513,11 +611,17 @@ class ConfigGroup { /** * Serialize the config to a plain JavaScript object * + * @param {string=} location - Location used for access control. * @returns {Object} JSON serializable representation of the group. */ - serialize() { + serialize(location = this.config.location) { let fields = {}; for (let [name, value] of this._fields) { + let def = this.constructor._definitions.get(name); + if (!this._checkAccess(def, location, false)) { + continue; + } + fields[name] = value; } @@ -557,6 +661,9 @@ class ConfigGroup { * Define a new configuration field * * @param {Object} item - Field definition. + * @param {Array=} item.access - + * Array of strings declaring where this config entry can be read + * and modified from. * @param {string} item.type - * string declaring the type of the config value, supports * boolean, string, number and object. @@ -609,6 +716,12 @@ class ConfigGroup { } break; + case "access": + if (valueType !== "array") { + throw new Error("access must be an array"); + } + break; + case "enum": if (valueType !== "array") { throw new Error("enum must be an array"); @@ -655,11 +768,16 @@ class ConfigGroup { } let computed = { + access: this.defaultAccess, optional: false, fullName: `${this.groupName}.${item.name}`, ...item, }; + if (!computed.access) { + throw new Error(`access not set and no defaultAccess defined for ${this.name}`); + } + if (!computed.optional && computed.initial_value === undefined) { throw new Error(`Non-optional field ${item.name} needs an initial_value`); } @@ -706,6 +824,7 @@ class PluginConfigGroup extends ConfigGroup { module.exports = { + InvalidAccess, InvalidField, InvalidValue, diff --git a/packages/lib/config/definitions.js b/packages/lib/config/definitions.js index 5c6fff11..5b5aacc8 100644 --- a/packages/lib/config/definitions.js +++ b/packages/lib/config/definitions.js @@ -10,6 +10,7 @@ const classes = require("./classes"); * @memberof module:lib/config */ class MasterGroup extends classes.ConfigGroup { } +MasterGroup.defaultAccess = ["master", "slave", "control"]; MasterGroup.groupName = "master"; MasterGroup.define({ name: "name", @@ -69,6 +70,7 @@ MasterGroup.define({ optional: true, }); MasterGroup.define({ + access: ["master"], name: "auth_secret", title: "Master Authentication Secret", description: @@ -124,6 +126,7 @@ MasterConfig.registerGroup(MasterGroup); * @memberof module:lib/config */ class SlaveGroup extends classes.ConfigGroup {} +SlaveGroup.defaultAccess = ["master", "slave", "control"]; SlaveGroup.groupName = "slave"; SlaveGroup.define({ name: "name", @@ -197,6 +200,7 @@ SlaveConfig.registerGroup(SlaveGroup); * @memberof module:lib/config */ class InstanceGroup extends classes.ConfigGroup { } +InstanceGroup.defaultAccess = ["master", "slave", "control"]; InstanceGroup.groupName = "instance"; InstanceGroup.define({ name: "name", @@ -228,6 +232,7 @@ InstanceGroup.finalize(); * @memberof module:lib/config */ class FactorioGroup extends classes.ConfigGroup { } +FactorioGroup.defaultAccess = ["master", "slave", "control"]; FactorioGroup.groupName = "factorio"; FactorioGroup.define({ name: "version", @@ -325,6 +330,7 @@ InstanceConfig.registerGroup(FactorioGroup); * @memberof module:lib/config */ class ControlGroup extends classes.ConfigGroup {} +ControlGroup.defaultAccess = ["control"]; ControlGroup.groupName = "control"; ControlGroup.define({ name: "master_url", @@ -390,6 +396,7 @@ function registerPluginConfigGroups(pluginInfos) { } else { class MasterConfigGroup extends classes.PluginConfigGroup { } + MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = pluginInfo.name; MasterConfigGroup.finalize(); MasterConfig.registerGroup(MasterConfigGroup); @@ -402,6 +409,7 @@ function registerPluginConfigGroups(pluginInfos) { } else { class InstanceConfigGroup extends classes.PluginConfigGroup { } + InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = pluginInfo.name; InstanceConfigGroup.finalize(); InstanceConfig.registerGroup(InstanceConfigGroup); diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index a68ae538..89a6cbdd 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -300,6 +300,37 @@ messages.debugDumpWs = new Request({ // Management requests +messages.getMasterConfig = new Request({ + type: "get_master_config", + links: ["control-master"], + permission: "core.master.get_config", + responseProperties: { + "serialized_config": { type: "object" }, + }, +}); + +messages.setMasterConfigField = new Request({ + type: "set_master_config_field", + links: ["control-master"], + permission: "core.master.update_config", + requestProperties: { + "field": { type: "string" }, + "value": { type: "string" }, + }, +}); + +messages.setMasterConfigProp = new Request({ + type: "set_master_config_prop", + links: ["control-master"], + permission: "core.master.update_config", + requestRequired: ["field", "prop"], + requestProperties: { + "field": { type: "string" }, + "prop": { type: "string" }, + "value": {}, + }, +}); + messages.listSlaves = new Request({ type: "list_slaves", links: ["control-master"], diff --git a/packages/lib/users.js b/packages/lib/users.js index a55ef972..3bb8f60a 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -302,6 +302,16 @@ definePermission({ grantByDefault: true, }); +definePermission({ + name: "core.master.get_config", + title: "Get master config", + description: "Get the config of master.", +}); +definePermission({ + name: "core.master.update_config", + title: "Modify master config", + description: "Modify the master config or entries of the master config.", +}); definePermission({ name: "core.slave.list", title: "List slaves", diff --git a/packages/master/master.js b/packages/master/master.js index e5a2a1aa..2474c0d6 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -372,7 +372,7 @@ async function loadInstances(databaseDirectory, file) { try { let serialized = JSON.parse(await fs.readFile(filePath)); for (let serializedConfig of serialized) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("master"); await instanceConfig.load(serializedConfig); let status = instanceConfig.get("instance.assigned_slave") === null ? "unassigned" : "unknown"; instances.set(instanceConfig.get("instance.id"), { config: instanceConfig, status }); @@ -684,6 +684,19 @@ class ControlConnection extends BaseConnection { }); } + async getMasterConfigRequestHandler() { + return { serialized_config: masterConfig.serialize("control") }; + } + + async setMasterConfigFieldRequestHandler(message) { + masterConfig.set(message.data.field, message.data.value, "control"); + } + + async setMasterConfigPropRequestHandler(message) { + let { field, prop, value } = message.data; + masterConfig.setProp(field, prop, value, "control"); + } + async listSlavesRequestHandler(message) { let list = []; for (let slave of db.slaves.values()) { @@ -707,7 +720,7 @@ class ControlConnection extends BaseConnection { } async createSlaveConfigRequestHandler(message) { - let slaveConfig = new libConfig.SlaveConfig(); + let slaveConfig = new libConfig.SlaveConfig("control"); await slaveConfig.init(); slaveConfig.set("slave.master_url", getMasterUrl()); @@ -739,7 +752,7 @@ class ControlConnection extends BaseConnection { // XXX should probably add a hook for slave reuqests? async createInstanceRequestHandler(message) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("master"); await instanceConfig.load(message.data.serialized_config); let instanceId = instanceConfig.get("instance.id"); @@ -795,7 +808,7 @@ class ControlConnection extends BaseConnection { } return { - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("control"), }; } @@ -806,7 +819,7 @@ class ControlConnection extends BaseConnection { if (connection) { await libLink.messages.assignInstance.send(connection, { instance_id: instance.config.get("instance.id"), - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } @@ -827,7 +840,7 @@ class ControlConnection extends BaseConnection { throw new libErrors.RequestError("Setting instance.id is not supported"); } - instance.config.set(message.data.field, message.data.value); + instance.config.set(message.data.field, message.data.value, "control"); await this.updateInstanceConfig(instance); } @@ -838,7 +851,7 @@ class ControlConnection extends BaseConnection { } let { field, prop, value } = message.data; - instance.config.setProp(field, prop, value); + instance.config.setProp(field, prop, value, "control"); await this.updateInstanceConfig(instance); } @@ -877,7 +890,7 @@ class ControlConnection extends BaseConnection { if (slave_id !== null) { await libLink.messages.assignInstance.send(newSlaveConnection, { instance_id, - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } @@ -1202,15 +1215,15 @@ class SlaveConnection extends BaseConnection { if (instance.config.get("instance.assigned_slave") === this._id) { await libLink.messages.assignInstance.send(this, { instance_id: instance.config.get("instance.id"), - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } // Assign instances the slave has but master does not for (let instance of message.data.instances) { - let instanceConfig = new libConfig.InstanceConfig(); - await instanceConfig.load(instance.serialized_config); + let instanceConfig = new libConfig.InstanceConfig("master"); + await instanceConfig.load(instance.serialized_config, "slave"); let masterInstance = db.instances.get(instanceConfig.get("instance.id")); if (masterInstance) { @@ -1239,7 +1252,7 @@ class SlaveConnection extends BaseConnection { }); await libLink.messages.assignInstance.send(this, { instance_id: instanceConfig.get("instance.id"), - serialized_config: instanceConfig.serialize(), + serialized_config: instanceConfig.serialize("slave"), }); } @@ -1797,7 +1810,7 @@ async function handleBootstrapCommand(args) { process.exitCode = 1; return; } - let controlConfig = new libConfig.ControlConfig(); + let controlConfig = new libConfig.ControlConfig("control"); await controlConfig.init(); controlConfig.set("control.master_url", getMasterUrl()); @@ -1909,7 +1922,7 @@ async function initialize() { masterConfigPath = args.config; logger.info(`Loading config from ${masterConfigPath}`); - masterConfig = new libConfig.MasterConfig(); + masterConfig = new libConfig.MasterConfig("master"); try { await masterConfig.load(JSON.parse(await fs.readFile(masterConfigPath))); diff --git a/packages/master/src/routes.js b/packages/master/src/routes.js index 7e0ca52a..bcfde4ee 100644 --- a/packages/master/src/routes.js +++ b/packages/master/src/routes.js @@ -2,15 +2,14 @@ // Routes used in the web interface and served by the master server module.exports = [ "/", - "/instances", - "/instances/:id/view", + "/master", "/slaves", "/slaves/:id/view", + "/instances", + "/instances/:id/view", "/users", "/users/:id/view", "/roles", "/roles/:id/view", - "/permissions", - "/permissions/:id/view", "/settings", ]; diff --git a/packages/slave/slave.js b/packages/slave/slave.js index 4e2ad729..5461a2c3 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -799,7 +799,7 @@ async function discoverInstances(instancesDir) { let instanceInfos = new Map(); for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) { if (entry.isDirectory()) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); let configPath = path.join(instancesDir, entry.name, "instance.json"); try { @@ -1175,17 +1175,17 @@ class Slave extends libLink.Link { let { instance_id, serialized_config } = message.data; let instanceInfo = this.instanceInfos.get(instance_id); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true); + instanceInfo.config.update(serialized_config, true, "master"); logger.verbose(`Updated config for ${instanceInfo.path}`); } else { instanceInfo = this.discoveredInstanceInfos.get(instance_id); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true); + instanceInfo.config.update(serialized_config, true, "master"); } else { - let instanceConfig = new libConfig.InstanceConfig(); - await instanceConfig.load(serialized_config); + let instanceConfig = new libConfig.InstanceConfig("slave"); + await instanceConfig.load(serialized_config, "master"); // XXX: race condition on multiple simultanious calls let instanceDir = await this._findNewInstanceDir(instanceConfig.get("instance.name")); @@ -1348,7 +1348,7 @@ class Slave extends libLink.Link { for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { let instanceConnection = this.instanceConnections.get(instanceId); list.push({ - serialized_config: instanceInfo.config.serialize(), + serialized_config: instanceInfo.config.serialize("master"), status: instanceConnection ? instanceConnection.status : "stopped", }); } @@ -1561,7 +1561,7 @@ async function startSlave() { libConfig.finalizeConfigs(); logger.info(`Loading config from ${args.config}`); - let slaveConfig = new libConfig.SlaveConfig(); + let slaveConfig = new libConfig.SlaveConfig("slave"); try { await slaveConfig.load(JSON.parse(await fs.readFile(args.config))); diff --git a/packages/web_ui/src/components/AssignInstanceModal.jsx b/packages/web_ui/src/components/AssignInstanceModal.jsx new file mode 100644 index 00000000..8a0b2399 --- /dev/null +++ b/packages/web_ui/src/components/AssignInstanceModal.jsx @@ -0,0 +1,91 @@ +import React, { useContext, useState } from "react"; +import { Button, Form, Modal, Select, Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + +const { Paragraph } = Typography; + + +export default function AssignInstanceModal(props) { + let [visible, setVisible] = useState(false); + let [slaves, setSlaves] = useState([]); + let [loading, setLoading] = useState(true); + let [applying, setApplying] = useState(false); + let [form] = Form.useForm(); + let control = useContext(ControlContext); + + function open() { + setVisible(true); + libLink.messages.listSlaves.send(control).then((result) => { + setSlaves(result.list); + setLoading(false); + }); + } + + function handleAssign() { + let slaveId = form.getFieldValue("slave"); + if (slaveId === undefined) { + setVisible(false); + return; + } + + setApplying(true); + libLink.messages.assignInstanceCommand.send(control, { + instance_id: props.id, + slave_id: slaveId, + }).then(() => { + setVisible(false); + if (props.onFinish) { + props.onFinish(); + } + }).catch( + notifyErrorHandler("Error assigning instance") + ).finally( + () => setApplying(false) + ); + } + + function handleCancel() { + setVisible(false); + } + + return <> + + + + Select a Slave to assign this instance to. Assignment + creates the necessary files on the slave to start up + the instance. Note that reassigning an instance from + one slave to another will not move the server save over. + +
+ + + +
+
+ ; +} + diff --git a/packages/web_ui/src/components/BaseConfigTree.jsx b/packages/web_ui/src/components/BaseConfigTree.jsx new file mode 100644 index 00000000..7f6fd457 --- /dev/null +++ b/packages/web_ui/src/components/BaseConfigTree.jsx @@ -0,0 +1,252 @@ +import React, { useEffect, useContext, useState } from "react"; +import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd"; + +const { Title } = Typography; + + +export default function BaseConfigTree(props) { + let [config, setConfig] = useState(null); + let [form] = Form.useForm(); + let [changedFields, setChangedFields] = useState(new Set()); + let [errorFields, setErrorFields] = useState(new Map()); + let [applying, setApplying] = useState(false); + + async function updateConfig() { + let serializedConfig = await props.retrieveConfig(); + let newConfig = new props.ConfigClass("control"); + await newConfig.load(serializedConfig); + setConfig(newConfig); + } + useEffect(() => { updateConfig(); }, [props.id]); + + function renderInput(def) { + if (def.type === "boolean") { + return ; + } + if (def.type === "string") { + return ; + } + if (def.type === "number") { + return ; + } + + return `Unknown type ${def.type}`; + } + + if (config === null) { + return ; + } + + let initialValues = {}; + function onValuesChange(changedValues) { + let newChangedFields = new Set(changedFields); + let changed = false; + for (let [key, value] of Object.entries(changedValues)) { + if (key.endsWith(":add.name") || key.endsWith(":add.value")) { + continue; + } + if (initialValues[key] === value) { + changed = newChangedFields.delete(key) || changed; + } else { + changed = !newChangedFields.has(key) || changed; + newChangedFields.add(key); + } + } + + if (changed) { + setChangedFields(newChangedFields); + } + } + + let treeData = []; + let propsMap = new Map(); + for (let [name, GroupClass] of props.ConfigClass.groups) { + let group = config.group(name); + let treeNode = { + key: group.name, + title: group.name, + children: [], + }; + + for (let def of GroupClass.definitions.values()) { + if (!group.canAccess(def.name)) { + continue; + } + + let value = group.get(def.name); + let fieldName = `${group.name}.${def.name}`; + let childNode = { + key: fieldName, + children: [], + }; + + if (def.type === "object") { + childNode.title = ; + for (let prop of Object.keys(value)) { + let propPath = `${group.name}.${def.name}.${prop}`; + childNode.children.push({ + key: propPath, + title: { + if (!fieldValue.length) { + return Promise.reject(new Error("Will be removed")); + } + try { + JSON.parse(fieldValue); + } catch (err) { + return Promise.reject(new Error("Must be valid json")); + } + return Promise.resolve(); + }}]} + > + + , + }); + + initialValues[propPath] = JSON.stringify(value[prop]); + propsMap.set(propPath, [fieldName, prop]); + } + + let newPropPath = `${group.name}.${def.name}:add`; + childNode.children.push({ + key: newPropPath, + title: + + + : + + + + + , + }); + + } else { + childNode.title = + {renderInput(def)} + ; + initialValues[fieldName] = value; + } + + treeNode.children.push(childNode); + } + + treeData.push(treeNode); + } + + return <> + + Config + <Space style={{ float: "right" }}> + <Button + type="primary" + size="small" + loading={applying} + onClick={async () => { + setApplying(true); + for (let field of changedFields) { + let value = form.getFieldValue(field); + let request; + if (propsMap.has(field)) { + let [fieldName, prop] = propsMap.get(field); + request = props.setProp(fieldName, prop, value); + + } else { + request = props.setField(field, value); + } + + try { + await request; + setChangedFields(changed => { + let newChanged = new Set(changed); + newChanged.delete(field); + return newChanged; + }); + + } catch (err) { + setErrorFields(errors => { + let newErrors = new Map(errors); + newErrors.set(field, err.message); + return newErrors; + }); + } + } + + await updateConfig(); + setApplying(false); + + if (props.onApply) { + props.onApply(); + } + }} + disabled={changedFields.size === 0} + > + Apply + </Button> + <Button + size="small" + onClick={() => { + form.resetFields(); + setChangedFields(new Set()); + setErrorFields(new Map()); + }} + > + Revert + </Button> + </Space> + + +
+ changedFields.has(node.key)} + /> + +
+ ; +} diff --git a/packages/web_ui/src/components/ErrorBoundary.jsx b/packages/web_ui/src/components/ErrorBoundary.jsx new file mode 100644 index 00000000..4fed84e1 --- /dev/null +++ b/packages/web_ui/src/components/ErrorBoundary.jsx @@ -0,0 +1,20 @@ +import React from "react"; + + +export default class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error) { + return { error }; + } + + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } +} diff --git a/packages/web_ui/src/components/ErrorPage.jsx b/packages/web_ui/src/components/ErrorPage.jsx new file mode 100644 index 00000000..855bfce7 --- /dev/null +++ b/packages/web_ui/src/components/ErrorPage.jsx @@ -0,0 +1,16 @@ +import React, { useContext } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import PageLayout from "./PageLayout"; + +const { Paragraph } = Typography; + + +export default function ErrorPage(props) { + return +

An unexpected error occured

+ {props.error.stack} +
; +}; diff --git a/packages/web_ui/src/components/InstanceConfigTree.jsx b/packages/web_ui/src/components/InstanceConfigTree.jsx new file mode 100644 index 00000000..eaefa3c2 --- /dev/null +++ b/packages/web_ui/src/components/InstanceConfigTree.jsx @@ -0,0 +1,52 @@ +import React, { useContext } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; +import libConfig from "@clusterio/lib/config"; + +import BaseConfigTree from "./BaseConfigTree"; +import ControlContext from "./ControlContext"; + + +export default function InstanceConfigTree(props) { + let control = useContext(ControlContext); + + async function retrieveConfig() { + let result = await libLink.messages.getInstanceConfig.send(control, { + instance_id: props.id, + }); + return result.serialized_config; + } + + async function setField(field, value) { + await libLink.messages.setInstanceConfigField.send(control, { + instance_id: props.id, + field, + value: String(value), + }); + } + + async function setProp(field, prop, value) { + let data = { + instance_id: props.id, + field, + prop, + }; + if (value) { + try { + data.value = JSON.parse(value); + } catch (err) { + return; + } + } + await libLink.messages.setInstanceConfigProp.send(control, data); + } + + return ; +} diff --git a/packages/web_ui/src/components/InstanceConsole.jsx b/packages/web_ui/src/components/InstanceConsole.jsx new file mode 100644 index 00000000..07542676 --- /dev/null +++ b/packages/web_ui/src/components/InstanceConsole.jsx @@ -0,0 +1,94 @@ +import React, { useEffect, useContext, useRef, useState } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; + +const { Paragraph } = Typography; + + +function formatParsedOutput(parsed, key) { + let time = ""; + if (parsed.format === "seconds") { + time = {parsed.time.padStart(8)} ; + } else if (parsed.format === "date") { + time = {parsed.time} ; + } + + let info = ""; + if (parsed.type === "log") { + let level = parsed.level; + if (level === "Script") { + level = {level}; + } else if (level === "Verbose") { + level = {level}; + } else if (level === "Info") { + level = {level}; + } else if (parsed.level === "Warning") { + level = {level}; + } else if (parsed.level === "Error") { + level = {level}; + } + + info = <>{level} {parsed.file}: ; + + } else if (parsed.type === "action") { + info = <>[{parsed.action}] ; + } + + return {time}{info}{parsed.message}
; +} + +function formatLog(info, key) { + if (info.level === "server" && info.parsed) { + return formatParsedOutput(info.parsed, key); + } + let level = {info.level}; + return [{level}] {info.message}
; +} + +export default function InstanceConsole(props) { + let control = useContext(ControlContext); + let anchor = useRef(); + let [pastLines, setPastLines] = useState([{"Loading past entries..."}
]); + let [lines, setLines] = useState([]); + + useEffect(() => { + // Scroll view to the anchor so it sticks to the bottom + let parent = anchor.current.parentElement; + parent.scrollTop = parent.scrollHeight - parent.clientHeight; + + libLink.messages.queryLog.send(control, { + all: false, + master: false, + slave_ids: [], + instance_ids: [props.id], + max_level: null, + }).then(result => { + setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index))); + }).catch(err => { + setPastLines([{`Error loading log: ${err.message}`}
]); + }); + + function logHandler(info) { + setLines(currentLines => currentLines.concat( + [formatLog(info, currentLines.length)] + )); + } + + control.onInstanceLog(props.id, logHandler); + return () => { + control.offInstanceLog(props.id, logHandler); + }; + }, [props.id]); + + return <> + + {pastLines} + {lines} +
+ + ; +} + diff --git a/packages/web_ui/src/components/InstanceRcon.jsx b/packages/web_ui/src/components/InstanceRcon.jsx new file mode 100644 index 00000000..ce1e07ee --- /dev/null +++ b/packages/web_ui/src/components/InstanceRcon.jsx @@ -0,0 +1,49 @@ +import React, { useContext, useState } from "react"; +import { Input, Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + +const { Title, Paragraph } = Typography; + + +export default function InstanceRcon(props) { + let control = useContext(ControlContext); + let [output, setOutput] = useState(null); + let [running, setRunning] = useState(false); + + async function sendCommand(command) { + if (!command) { + setOutput(null); + return; + } + + setRunning(true); + try { + let result = await libLink.messages.sendRcon.send(control, { + instance_id: props.id, + command: command, + }); + setOutput(result.result); + } finally { + setRunning(false); + } + } + + return <> + {output && <> + Rcon result + {output} + } + sendCommand(command).catch(notifyErrorHandler("Error sending command"))} + loading={running} + /> + ; +} + diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index af981afa..f44b5c34 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -1,587 +1,24 @@ -import React, { useEffect, useContext, useRef, useState } from "react"; +import React, { useContext, useState } from "react"; import { useHistory, useParams } from "react-router-dom"; -import { - notification, Button, Card, Checkbox, Col, Descriptions, Dropdown, Form, Input, InputNumber, Modal, - Popconfirm, Row, Select, Space, Spin, Tree, Typography, -} from "antd"; +import { Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd"; import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; import libLink from "@clusterio/lib/link"; -import libConfig from "@clusterio/lib/config"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; +import InstanceConfigTree from "./InstanceConfigTree"; +import InstanceConsole from "./InstanceConsole"; +import InstanceRcon from "./InstanceRcon"; +import AssignInstanceModal from "./AssignInstanceModal"; +import StartStopInstanceButton from "./StartStopInstanceButton"; import { notifyErrorHandler } from "../util/notify"; +import { useInstance } from "../model/instance"; +import { useSlave } from "../model/slave"; const { Title, Paragraph } = Typography; -function useInstance(id) { - let control = useContext(ControlContext); - let [instance, setInstance] = useState({ loading: true }); - - function updateInstance() { - if (!Number.isInteger(id)) { - setInstance({ missing: true }); - return; - } - - // XXX optimize by requesting only the instance in question - libLink.messages.listInstances.send(control).then(result => { - let match = result.list.find(i => i.id === id); - if (!match) { - setInstance({ missing: true }); - } else { - setInstance({ ...match, present: true }); - } - }); - } - - useEffect(() => { - updateInstance(); - }, [id]); - - return [instance, updateInstance]; -} - -function useSlave(id) { - let control = useContext(ControlContext); - let [slave, setSlave] = useState({ loading: true }); - - function updateSlave() { - if (!Number.isInteger(id)) { - setSlave({ missing: true }); - return; - } - - // XXX optimize by requesting only the slave in question - libLink.messages.listSlaves.send(control).then(result => { - let match = result.list.find(i => i.id === id); - if (!match) { - setSlave({ missing: true }); - } else { - setSlave({ ...match, present: true }); - } - }); - } - - useEffect(() => { - updateSlave(); - }, [id]); - - return [slave, updateSlave]; -} - -function formatParsedOutput(parsed, key) { - let time = ""; - if (parsed.format === "seconds") { - time = {parsed.time.padStart(8)} ; - } else if (parsed.format === "date") { - time = {parsed.time} ; - } - - let info = ""; - if (parsed.type === "log") { - let level = parsed.level; - if (level === "Script") { - level = {level}; - } else if (level === "Verbose") { - level = {level}; - } else if (level === "Info") { - level = {level}; - } else if (parsed.level === "Warning") { - level = {level}; - } else if (parsed.level === "Error") { - level = {level}; - } - - info = <>{level} {parsed.file}: ; - - } else if (parsed.type === "action") { - info = <>[{parsed.action}] ; - } - - return {time}{info}{parsed.message}
; -} - -function formatLog(info, key) { - if (info.level === "server" && info.parsed) { - return formatParsedOutput(info.parsed, key); - } - let level = {info.level}; - return [{level}] {info.message}
; -} - -function InstanceConsole(props) { - let control = useContext(ControlContext); - let anchor = useRef(); - let [pastLines, setPastLines] = useState([{"Loading past entries..."}
]); - let [lines, setLines] = useState([]); - - useEffect(() => { - // Scroll view to the anchor so it sticks to the bottom - let parent = anchor.current.parentElement; - parent.scrollTop = parent.scrollHeight - parent.clientHeight; - - libLink.messages.queryLog.send(control, { - all: false, - master: false, - slave_ids: [], - instance_ids: [props.id], - max_level: null, - }).then(result => { - setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index))); - }).catch(err => { - setPastLines([{`Error loading log: ${err.message}`}
]); - }); - - function logHandler(info) { - setLines(currentLines => currentLines.concat( - [formatLog(info, currentLines.length)] - )); - } - - control.onInstanceLog(props.id, logHandler); - return () => { - control.offInstanceLog(props.id, logHandler); - }; - }, [props.id]); - - return <> - - {pastLines} - {lines} -
- - ; -} - -function InstanceRcon(props) { - let control = useContext(ControlContext); - let [output, setOutput] = useState(null); - let [running, setRunning] = useState(false); - - async function sendCommand(command) { - if (!command) { - setOutput(null); - return; - } - - setRunning(true); - try { - let result = await libLink.messages.sendRcon.send(control, { - instance_id: props.id, - command: command, - }); - setOutput(result.result); - } finally { - setRunning(false); - } - } - - return <> - {output && <> - Rcon result - {output} - } - sendCommand(command).catch(notifyErrorHandler("Error sending command"))} - loading={running} - /> - ; -} - -function AssignInstanceModal(props) { - let [visible, setVisible] = useState(false); - let [slaves, setSlaves] = useState([]); - let [loading, setLoading] = useState(true); - let [applying, setApplying] = useState(false); - let [form] = Form.useForm(); - let control = useContext(ControlContext); - - function open() { - setVisible(true); - libLink.messages.listSlaves.send(control).then((result) => { - setSlaves(result.list); - setLoading(false); - }); - } - - function handleAssign() { - let slaveId = form.getFieldValue("slave"); - if (slaveId === undefined) { - setVisible(false); - return; - } - - setApplying(true); - libLink.messages.assignInstanceCommand.send(control, { - instance_id: props.id, - slave_id: slaveId, - }).then(() => { - setVisible(false); - if (props.onFinish) { - props.onFinish(); - } - }).catch( - notifyErrorHandler("Error assigning instance") - ).finally( - () => setApplying(false) - ); - } - - function handleCancel() { - setVisible(false); - } - - return <> - - - - Select a Slave to assign this instance to. Assignment - creates the necessary files on the slave to start up - the instance. Note that reassigning an instance from - one slave to another will not move the server save over. - -
- - - -
-
- ; -} - -function StartStopInstanceButton(props) { - let control = useContext(ControlContext); - let [switching, setSwitching] = useState(false); - - function onClick() { - setSwitching(true); - let action; - if (props.instance["status"] === "stopped") { - action = libLink.messages.startInstance.send( - control, { instance_id: props.instance["id"], save: null } - ).catch( - notifyErrorHandler("Error starting instance") - ); - - } else if (["starting", "running"].includes(props.instance["status"])) { - action = libLink.messages.stopInstance.send( - control, { instance_id: props.instance["id"] } - ).catch( - notifyErrorHandler("Error stopping instance") - ); - - } else { - setSwitching(false); - return; - } - - action.finally(() => { - setSwitching(false); - if (props.onFinish) { - props.onFinish(); - } - }); - } - - return ; -} - - -function InstanceConfigTree(props) { - let control = useContext(ControlContext); - let [config, setConfig] = useState(null); - let [form] = Form.useForm(); - let [changedFields, setChangedFields] = useState(new Set()); - let [errorFields, setErrorFields] = useState(new Map()); - let [applying, setApplying] = useState(false); - - async function updateConfig() { - let result = await libLink.messages.getInstanceConfig.send(control, { - instance_id: props.id, - }); - - let newConfig = new libConfig.InstanceConfig(); - await newConfig.load(result.serialized_config); - setConfig(newConfig); - } - - useEffect(() => { updateConfig(); }, [props.id]); - - function renderInput(group, def) { - if (def.type === "boolean") { - return ; - } - if (def.type === "string") { - return ; - } - if (def.type === "number") { - return ; - } - - return `Unknown type ${def.type}`; - } - - if (config === null) { - return ; - } - - let initialValues = {}; - function onValuesChange(changedValues) { - let newChangedFields = new Set(changedFields); - let changed = false; - for (let [key, value] of Object.entries(changedValues)) { - if (initialValues[key] === value) { - changed = newChangedFields.delete(key) || changed; - } else { - changed = !newChangedFields.has(key) || changed; - newChangedFields.add(key); - } - } - - if (changed) { - setChangedFields(newChangedFields); - } - } - - let treeData = []; - let propsMap = new Map(); - for (let [name, GroupClass] of libConfig.InstanceConfig.groups) { - let group = config.group(name); - let treeNode = { - key: group.name, - title: group.name, - children: [], - }; - - for (let def of GroupClass.definitions.values()) { - let value = group.get(def.name); - let fieldName = `${group.name}.${def.name}`; - let childNode = { - key: fieldName, - children: [], - }; - - if (def.type === "object") { - childNode.title = ; - for (let prop of Object.keys(value)) { - let propPath = `${group.name}.${def.name}.${prop}`; - childNode.children.push({ - key: propPath, - title: { - if (!fieldValue.length) { - return Promise.reject(new Error("Will be removed")); - } - try { - JSON.parse(fieldValue); - } catch (err) { - return Promise.reject(new Error("Must be valid json")); - } - return Promise.resolve(); - }}]} - > - - , - }); - - initialValues[propPath] = JSON.stringify(value[prop]); - propsMap.set(propPath, [fieldName, prop]); - } - - let newPropPath = `${group.name}.${def.name}:add`; - childNode.children.push({ - key: newPropPath, - title: - - - : - - - - - , - }); - - } else { - childNode.title = - {renderInput(group, def)} - ; - initialValues[fieldName] = value; - } - - treeNode.children.push(childNode); - } - - treeData.push(treeNode); - } - - return <> - - Config - <Space style={{ float: "right" }}> - <Button - type="primary" - size="small" - loading={applying} - onClick={async () => { - setApplying(true); - for (let field of changedFields) { - let value = form.getFieldValue(field); - let request; - if (propsMap.has(field)) { - let [fieldName, prop] = propsMap.get(field); - let data = { - instance_id: props.id, - field: fieldName, - prop, - }; - if (value) { - try { - data.value = JSON.parse(value); - } catch (err) { - continue; - } - } - - request = libLink.messages.setInstanceConfigProp.send(control, data); - - } else { - request = libLink.messages.setInstanceConfigField.send(control, { - instance_id: props.id, - field, - value: String(value), - }); - } - - try { - await request; - setChangedFields(changed => { - let newChanged = new Set(changed); - newChanged.delete(field); - return newChanged; - }); - - } catch (err) { - setErrorFields(errors => { - let newErrors = new Map(errors); - newErrors.set(field, err.message); - return newErrors; - }); - } - } - - await updateConfig(); - setApplying(false); - - if (props.onApply) { - props.onApply(); - } - }} - disabled={changedFields.size === 0} - > - Apply - </Button> - <Button - size="small" - onClick={() => { - form.resetFields(); - setChangedFields(new Set()); - setErrorFields(new Map()); - }} - > - Revert - </Button> - </Space> - - -
- changedFields.has(node.key)} - /> - -
- ; -} - export default function InstanceViewPage(props) { let params = useParams(); let instanceId = Number(params.id); diff --git a/packages/web_ui/src/components/InstancesPage.jsx b/packages/web_ui/src/components/InstancesPage.jsx index 39315a10..0d4f9031 100644 --- a/packages/web_ui/src/components/InstancesPage.jsx +++ b/packages/web_ui/src/components/InstancesPage.jsx @@ -33,10 +33,10 @@ export default function InstancesPage() { title: "Name", }], insert: async args => { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("control"); await instanceConfig.init(); instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize(); + let serialized_config = instanceConfig.serialize("master"); let response = await libLink.messages.createInstance.send(control, { serialized_config }); }, }} diff --git a/packages/web_ui/src/components/MasterConfigTree.jsx b/packages/web_ui/src/components/MasterConfigTree.jsx new file mode 100644 index 00000000..fc817b90 --- /dev/null +++ b/packages/web_ui/src/components/MasterConfigTree.jsx @@ -0,0 +1,44 @@ +import React, { useContext } from "react"; +import { Typography } from "antd"; + +import libLink from "@clusterio/lib/link"; +import libConfig from "@clusterio/lib/config"; + +import BaseConfigTree from "./BaseConfigTree"; +import ControlContext from "./ControlContext"; + + +export default function MasterConfigTree(props) { + let control = useContext(ControlContext); + + async function retrieveConfig() { + let result = await libLink.messages.getMasterConfig.send(control); + return result.serialized_config; + } + + async function setField(field, value) { + await libLink.messages.setMasterConfigField.send(control, { + field, + value: String(value), + }); + } + + async function setProp(field, prop, value) { + let data = { field, prop }; + if (value) { + try { + data.value = JSON.parse(value); + } catch (err) { + return; + } + } + await libLink.messages.setMasterConfigProp.send(control, data); + } + + return ; +} diff --git a/packages/web_ui/src/components/MasterPage.jsx b/packages/web_ui/src/components/MasterPage.jsx new file mode 100644 index 00000000..3f14b548 --- /dev/null +++ b/packages/web_ui/src/components/MasterPage.jsx @@ -0,0 +1,12 @@ +import React, { useContext } from "react"; + +import MasterConfigTree from "./MasterConfigTree"; +import PageLayout from "./PageLayout"; + + +export default function SlavesPage() { + return +

Master

+ +
; +}; diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index d82ef9e5..f2ef9358 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -3,9 +3,13 @@ import { Switch, Route, useHistory } from "react-router-dom"; import { Layout, Menu } from "antd"; import webUiPackage from "../../package.json"; +import ErrorBoundary from "./ErrorBoundary"; +import ErrorPage from "./ErrorPage"; + const { Header, Sider } = Layout; +import MasterPage from "./MasterPage"; import SlavesPage from "./SlavesPage"; import InstancesPage from "./InstancesPage"; import InstanceViewPage from "./InstanceViewPage"; @@ -21,6 +25,7 @@ const pages = [ ]; const sidebar = [ + { name: "Master", path: "/master", content: }, { name: "Slaves", path: "/slaves", content: }, { name: "Instances", path: "/instances", content: }, { name: "Users", path: "/users", content: }, @@ -74,7 +79,9 @@ export default function SiteLayout(props) { )} {pages.map(page => - {page.content} + + {page.content} + )} diff --git a/packages/web_ui/src/components/StartStopInstanceButton.jsx b/packages/web_ui/src/components/StartStopInstanceButton.jsx new file mode 100644 index 00000000..1e7f7162 --- /dev/null +++ b/packages/web_ui/src/components/StartStopInstanceButton.jsx @@ -0,0 +1,55 @@ +import React, { useContext, useState } from "react"; +import { Button } from "antd"; + +import libLink from "@clusterio/lib/link"; + +import ControlContext from "./ControlContext"; +import { notifyErrorHandler } from "../util/notify"; + + +export default function StartStopInstanceButton(props) { + let control = useContext(ControlContext); + let [switching, setSwitching] = useState(false); + + function onClick() { + setSwitching(true); + let action; + if (props.instance["status"] === "stopped") { + action = libLink.messages.startInstance.send( + control, { instance_id: props.instance["id"], save: null } + ).catch( + notifyErrorHandler("Error starting instance") + ); + + } else if (["starting", "running"].includes(props.instance["status"])) { + action = libLink.messages.stopInstance.send( + control, { instance_id: props.instance["id"] } + ).catch( + notifyErrorHandler("Error stopping instance") + ); + + } else { + setSwitching(false); + return; + } + + action.finally(() => { + setSwitching(false); + if (props.onFinish) { + props.onFinish(); + } + }); + } + + return ; +} + + diff --git a/packages/web_ui/src/index.css b/packages/web_ui/src/index.css index ba26fe24..dda791ca 100644 --- a/packages/web_ui/src/index.css +++ b/packages/web_ui/src/index.css @@ -82,6 +82,14 @@ body { background-size: 64px; } +.error-traceback code { + display: block; + margin: 0; + background: #000; + border-color: #252525; + white-space: pre-wrap; +} + .rcon-result code { display: block; background: #000; diff --git a/packages/web_ui/src/model/instance.jsx b/packages/web_ui/src/model/instance.jsx new file mode 100644 index 00000000..c30227ab --- /dev/null +++ b/packages/web_ui/src/model/instance.jsx @@ -0,0 +1,33 @@ +import React, { useEffect, useContext, useState } from "react"; +import ControlContext from "../components/ControlContext"; + +import libLink from "@clusterio/lib/link"; + + +export function useInstance(id) { + let control = useContext(ControlContext); + let [instance, setInstance] = useState({ loading: true }); + + function updateInstance() { + if (!Number.isInteger(id)) { + setInstance({ missing: true }); + return; + } + + // XXX optimize by requesting only the instance in question + libLink.messages.listInstances.send(control).then(result => { + let match = result.list.find(i => i.id === id); + if (!match) { + setInstance({ missing: true }); + } else { + setInstance({ ...match, present: true }); + } + }); + } + + useEffect(() => { + updateInstance(); + }, [id]); + + return [instance, updateInstance]; +} diff --git a/packages/web_ui/src/model/slave.jsx b/packages/web_ui/src/model/slave.jsx new file mode 100644 index 00000000..ca6ee136 --- /dev/null +++ b/packages/web_ui/src/model/slave.jsx @@ -0,0 +1,33 @@ +import React, { useEffect, useContext, useState } from "react"; +import ControlContext from "../components/ControlContext"; + +import libLink from "@clusterio/lib/link"; + + +export function useSlave(id) { + let control = useContext(ControlContext); + let [slave, setSlave] = useState({ loading: true }); + + function updateSlave() { + if (!Number.isInteger(id)) { + setSlave({ missing: true }); + return; + } + + // XXX optimize by requesting only the slave in question + libLink.messages.listSlaves.send(control).then(result => { + let match = result.list.find(i => i.id === id); + if (!match) { + setSlave({ missing: true }); + } else { + setSlave({ ...match, present: true }); + } + }); + } + + useEffect(() => { + updateSlave(); + }, [id]); + + return [slave, updateSlave]; +} diff --git a/plugins/statistics_exporter/info.js b/plugins/statistics_exporter/info.js index c89f5c1e..001f039e 100644 --- a/plugins/statistics_exporter/info.js +++ b/plugins/statistics_exporter/info.js @@ -2,6 +2,7 @@ const libConfig = require("@clusterio/lib/config"); class InstanceConfigGroup extends libConfig.PluginConfigGroup {} +InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = "statistics_exporter"; InstanceConfigGroup.define({ name: "command_timeout", diff --git a/plugins/subspace_storage/info.js b/plugins/subspace_storage/info.js index b60fe71d..52534a65 100644 --- a/plugins/subspace_storage/info.js +++ b/plugins/subspace_storage/info.js @@ -3,6 +3,7 @@ const libLink = require("@clusterio/lib/link"); const libConfig = require("@clusterio/lib/config"); class MasterConfigGroup extends libConfig.PluginConfigGroup {} +MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = "subspace_storage"; MasterConfigGroup.define({ name: "autosave_interval", @@ -29,6 +30,7 @@ MasterConfigGroup.define({ MasterConfigGroup.finalize(); class InstanceConfigGroup extends libConfig.PluginConfigGroup {} +InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = "subspace_storage"; InstanceConfigGroup.define({ name: "log_item_transfers", diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 3ab0ffb4..8d3be325 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -35,6 +35,42 @@ describe("Integration of Clusterio", function() { }); describe("clusterioctl", function() { + describe("master config list", function() { + it("runs", async function() { + await execCtl("master config list"); + }); + it("should not leak auth_secret", async function() { + let result = await libLink.messages.getMasterConfig.send(getControl()); + let done = false; + for (let group of result.serialized_config.groups) { + if (group.name === "master") { + assert.equal(Object.prototype.hasOwnProperty.call(group.fields, "auth_secret"), false); + done = true; + break; + } + } + assert(done, "master group not found"); + }); + }); + describe("master config set", function() { + it("sets given config option", async function() { + await execCtl('master config set master.name "Test Cluster"'); + let result = await libLink.messages.getMasterConfig.send(getControl()); + let done = false; + for (let group of result.serialized_config.groups) { + if (group.name === "master") { + assert.equal(group.fields.name, "Test Cluster"); + done = true; + break; + } + } + assert(done, "master group not found"); + }); + it("should not allow setting auth_secret", async function() { + await assert.rejects(execCtl("master config set master.auth_secret root")); + }); + }); + describe("slave list", function() { it("runs", async function() { await execCtl("slave list"); diff --git a/test/lib/config/classes.js b/test/lib/config/classes.js index e566713e..49d5d224 100644 --- a/test/lib/config/classes.js +++ b/test/lib/config/classes.js @@ -1,11 +1,15 @@ "use strict"; const classes = require("@clusterio/lib/config/classes"); const assert = require("assert").strict; +const events = require("events"); describe("lib/config/classes", function() { describe("ConfigGroup", function() { + let mockConfig = new events.EventEmitter(); + mockConfig.location = "local"; class TestGroup extends classes.ConfigGroup { } + TestGroup.defaultAccess = ["local", "remote"]; TestGroup.groupName = "test_group"; describe(".define()", function() { it("should throw if groupName is not set", function() { @@ -23,6 +27,10 @@ describe("lib/config/classes", function() { }); it("should throw on invalid properties", function() { for (let [def, error] of [ + [ + { access: "local", name: "a", type: "string", optional: true }, + "access must be an array", + ], [ { name: "a", type: "invalid", optional: true }, "invalid is not a valid type", @@ -59,6 +67,14 @@ describe("lib/config/classes", function() { assert.throws(() => TestGroup.define(def), new Error(error)); } }); + it("should throw on defaultAccess not set", function() { + class NoAccessGroup extends classes.ConfigGroup { } + NoAccessGroup.groupName = "no_access"; + assert.throws( + () => NoAccessGroup.define({ name: "a", type: "string", optional: true }), + new Error("access not set and no defaultAccess defined for NoAccessGroup") + ); + }); it("should throw on missing properties", function() { assert.throws( () => TestGroup.define({ type: "string", optional: true }), @@ -74,11 +90,13 @@ describe("lib/config/classes", function() { TestGroup.define({ name: "func", type: "number", initial_value: () => 42 }); TestGroup.define({ name: "bool", type: "boolean", initial_value: false, optional: true }); TestGroup.define({ name: "json", type: "object", initial_value: {}, optional: true }); + TestGroup.define({ name: "priv", access: ["local"], type: "string", optional: true }); assert(TestGroup._definitions.has("test"), "field test was not defined"); assert(TestGroup._definitions.has("func"), "field func was not defined"); assert(TestGroup._definitions.has("bool"), "field bool was not defined"); assert(TestGroup._definitions.has("json"), "field json was not defined"); + assert(TestGroup._definitions.has("priv"), "field priv was not defined"); }); it("should throw on already defined entries", function() { @@ -90,6 +108,7 @@ describe("lib/config/classes", function() { it("should set the entries on the class", function() { let field = { + access: ["local", "remote"], type: "string", name: "enum", title: "Enum", @@ -122,7 +141,7 @@ describe("lib/config/classes", function() { }); it("should throw when creating an instance before finalize", function() { assert.throws( - () => new TestGroup(), + () => new TestGroup(mockConfig), new Error("Cannot instantiate incomplete ConfigGroup class TestGroup") ); }); @@ -141,24 +160,31 @@ describe("lib/config/classes", function() { describe("constructor", function() { it("should create an instance", function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); }); }); describe(".init()", function() { it("should initialize all fields with defaults", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.equal(testInstance.get("enum"), "b"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); + assert.equal(testInstance.get("priv"), null); + }); + it("should not initalize inaccessible fields", async function() { + let testInstance = new TestGroup({ location: "remote" }); + await testInstance.init(); + assert.equal(testInstance._fields.get("enum"), "b"); + assert.equal(testInstance._fields.get("priv"), undefined); }); }); describe("get name", function() { it("should give the name of the group", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.equal(testInstance.name, "test_group"); }); @@ -166,10 +192,26 @@ describe("lib/config/classes", function() { describe(".serialize()", function() { it("should serialize a basic group", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.deepEqual(testInstance.serialize(), { + name: "test_group", + fields: { + enum: "b", + test: null, + func: 42, + bool: false, + json: {}, + priv: null, + }, + }); + }); + it("should leave out inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.init(); + + assert.deepEqual(testInstance.serialize("remote"), { name: "test_group", fields: { enum: "b", @@ -184,29 +226,36 @@ describe("lib/config/classes", function() { describe(".load()", function() { it("should load a basic group", async function() { - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "c", test: "blah", func: 22, bool: null, json: { valid: true }, + priv: "bar", }}); assert.equal(testInstance.get("enum"), "c"); assert.equal(testInstance.get("test"), "blah"); assert.equal(testInstance.get("func"), 22); assert.equal(testInstance.get("bool"), null); + assert.equal(testInstance.get("priv"), "bar"); assert.deepEqual(testInstance.get("json"), { valid: true }); }); it("should load defaults for missing serialized fields", async function() { - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { enum: "a" } }); + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a" } }); assert.equal(testInstance.get("enum"), "a"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); }); + it("should ignore inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { priv: "bad" } }, "remote"); + assert.equal(testInstance.get("priv"), null); + }); it("should preserve unknown fields when serialized back", async function() { let testFields = { @@ -215,13 +264,14 @@ describe("lib/config/classes", function() { func: 50, bool: false, json: {}, + priv: null, alpha: null, beta: "decay", gamma: 99, }; - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: testFields }); + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: testFields }); assert.deepEqual(testInstance.serialize().fields, testFields); }); @@ -229,7 +279,7 @@ describe("lib/config/classes", function() { describe(".update()", function() { it("should skip updating invalid values", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); testInstance.update({ name: "test_group", fields: { enum: "wrong", test: 3, func: null }}, false); @@ -237,9 +287,19 @@ describe("lib/config/classes", function() { assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); }); + it("should skip updating inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.init(); + testInstance.update( + { name: "test_group", fields: { test: "a", priv: "bad" }}, false, "remote" + ); + + assert.equal(testInstance.get("test"), "a"); + assert.equal(testInstance.get("priv"), null); + }); it("should throw on invalid input", function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); assert.throws( () => testInstance.update(), new Error("Expected object, not undefined for ConfigGroup") @@ -263,13 +323,52 @@ describe("lib/config/classes", function() { }); }); + describe(".canAccess()", function() { + let testInstance; + before(async function() { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { + enum: "a", priv: "blah", func: 27, + }}); + }); + it("should return true for fields that are accessible", function() { + for (let field of ["enum", "test", "func", "bool", "json"]) { + assert.equal(testInstance.canAccess(field), true); + assert.equal(testInstance.canAccess(field, "local"), true); + assert.equal(testInstance.canAccess(field, "remote"), true); + } + assert.equal(testInstance.canAccess("priv"), true); + assert.equal(testInstance.canAccess("priv", "local"), true); + assert.equal(testInstance.canAccess("priv", "remote"), false); + }); + }); + + describe(".get()", function() { + let testInstance; + before(async function() { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { + enum: "a", priv: "blah", func: 27, + }}); + }); + it("should retreive value of field", function() { + assert.equal(testInstance.get("enum"), "a"); + assert.equal(testInstance.get("priv"), "blah"); + assert.equal(testInstance.get("func"), 27); + }); + it("should throw if field is inaccessible", function() { + assert.throws( + () => testInstance.get("priv", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + }); + describe(".set()", function() { let testInstance; before(async function() { - testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { - enum: "a", test: "blah", func: 27, - }}); + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27 }}); }); it("should throw if field does not exist", function() { @@ -279,6 +378,13 @@ describe("lib/config/classes", function() { ); }); + it("should throw if field is inaccesible", function() { + assert.throws( + () => testInstance.set("priv", "bad", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + it("should throw if field is not in enum", function() { assert.throws( () => testInstance.set("enum", "bar"), @@ -366,8 +472,8 @@ describe("lib/config/classes", function() { describe(".setProp()", function() { let testInstance; before(async function() { - testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27, }}); }); @@ -379,9 +485,16 @@ describe("lib/config/classes", function() { ); }); + it("should throw if field is inaccesible", function() { + assert.throws( + () => testInstance.setProp("priv", "prop", "bad", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + it("should throw if field is not an object", function() { assert.throws( - () => testInstance.setProp("enum", "a"), + () => testInstance.setProp("enum", "prop", "a"), new classes.InvalidField("Cannot set property on non-object field 'enum'") ); }); @@ -408,8 +521,10 @@ describe("lib/config/classes", function() { describe("Config", function() { class AlphaGroup extends classes.ConfigGroup { } + AlphaGroup.defaultAccess = ["local", "remote"]; AlphaGroup.groupName = "alpha"; class BetaGroup extends classes.ConfigGroup { } + BetaGroup.defaultAccess = ["local", "remote"]; BetaGroup.groupName = "beta"; class TestConfig extends classes.Config { } @@ -454,20 +569,26 @@ describe("lib/config/classes", function() { describe("constructor", function() { it("should throw if config is not finalized", function() { assert.throws( - () => new TestConfig(), + () => new TestConfig("local"), new Error("Cannot instantiate incomplete Config class TestConfig") ); }); it("should construct a finalized class", function() { TestConfig.finalize(); - let config = new TestConfig(); + let config = new TestConfig("local"); assert(config); }); + it("should throw if location is not passed", function() { + assert.throws( + () => new TestConfig(), + new Error("location must be a string") + ); + }); }); describe(".init()", function() { it("should initialize all fields", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.equal(testInstance.get("alpha.foo"), null); assert.deepEqual(testInstance.get("beta.bar"), {}); @@ -476,7 +597,7 @@ describe("lib/config/classes", function() { describe(".serialize()", function() { it("should serialize a basic config", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.deepEqual(testInstance.serialize(), { groups: [ @@ -495,7 +616,7 @@ describe("lib/config/classes", function() { describe(".load()", function() { it("should reject on incorrect input passed", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await assert.rejects( testInstance.load(null), new Error("Expected object, not null for config") @@ -511,7 +632,7 @@ describe("lib/config/classes", function() { }); it("should load defaults for missing serialized groups", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.load({ groups: [{ name: "alpha", fields: { foo: "a" }}] }); assert.equal(testInstance.get("alpha.foo"), "a"); @@ -534,7 +655,7 @@ describe("lib/config/classes", function() { }, ]; - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.load({ groups: testGroups }); assert.deepEqual(testInstance.serialize().groups, testGroups); @@ -543,7 +664,7 @@ describe("lib/config/classes", function() { describe(".update()", function() { it("should update a basic config", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); testInstance.update({ groups: [ { @@ -577,7 +698,7 @@ describe("lib/config/classes", function() { describe(".group()", function() { it("should throw if instance is not initialized", function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); assert.throws( () => testInstance.group("test"), new Error("TestConfig instance is uninitialized") @@ -585,16 +706,39 @@ describe("lib/config/classes", function() { }); }); + describe(".canAccess()", function() { + it("should throw if group does not exist", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.throws(() => testInstance.canAccess("invalid"), new Error("No config group named 'invalid'")); + }); + + it("should throw if no field is specified", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.throws(() => testInstance.canAccess("alpha"), new Error("No field named ''")); + }); + + it("should return true for an accessible field", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.equal(testInstance.canAccess("alpha.foo"), true); + }); + }); + describe(".get()", function() { it("should throw if group does not exist", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.get("invalid"), new Error("No config group named 'invalid'")); }); it("should throw if no field is specified", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.get("alpha"), new Error("No field named ''")); @@ -603,7 +747,7 @@ describe("lib/config/classes", function() { describe(".set()", function() { it("should set the value", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); testInstance.set("alpha.foo", "new value"); @@ -621,7 +765,7 @@ describe("lib/config/classes", function() { let testInstance; let called; beforeEach(async function() { - testInstance = new TestConfig(); + testInstance = new TestConfig("local"); await testInstance.init(); called = false; diff --git a/test/slave.js b/test/slave.js index f436b312..e54e5a0b 100644 --- a/test/slave.js +++ b/test/slave.js @@ -15,7 +15,7 @@ describe("Slave testing", function() { describe("class Instance", function() { let instance; before(async function() { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); await instanceConfig.init(); instanceConfig.set("instance.name", "foo"); instance = new slave._Instance({}, new libLink.VirtualConnector(), "dir", "factorioDir", instanceConfig); @@ -96,7 +96,7 @@ describe("Slave testing", function() { await fs.outputFile(path.join(testDir, "shared", "mod_b.zip"), "b"); await fs.outputFile(path.join(testDir, "shared", "mod.dat"), "c"); - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); await instanceConfig.init(); instanceConfig.set("instance.name", "test"); instance = new slave._Instance( @@ -149,7 +149,7 @@ describe("Slave testing", function() { let instancePath = path.join("test", "file", "instances"); let instanceInfos = await slave._discoverInstances(instancePath); - let referenceConfig = new libConfig.InstanceConfig(); + let referenceConfig = new libConfig.InstanceConfig("slave"); await referenceConfig.init(); referenceConfig.set("instance.id", 1); referenceConfig.set("instance.name", "test");