mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 23:24:52 +09:00
* Event types for controller * Clean up ctl event emitters * Event types for server * Event types for lib * Remove TypedEventEmitter * Event types for webui * Fix compile issues * Small cleanup of connectors
609 lines
18 KiB
TypeScript
609 lines
18 KiB
TypeScript
// Configuration classes
|
|
import { Type, Static } from "@sinclair/typebox";
|
|
import * as fs from "fs-extra";
|
|
import events from "events";
|
|
|
|
import isDeepStrictEqual from "../is_deep_strict_equal";
|
|
import { basicType } from "../helpers";
|
|
import * as libSchema from "../schema";
|
|
import { StringEnum } from "../data/composites";
|
|
import { safeOutputFile } from "../file_ops";
|
|
|
|
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
|
export type ConfigLocation = Static<typeof ConfigLocation>;
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export class InvalidAccess extends Error { };
|
|
|
|
/**
|
|
* Invalid Value exception
|
|
*
|
|
* Exception class for when invalid values are attempted to be set on a
|
|
* field.
|
|
*
|
|
* @extends Error
|
|
*/
|
|
export class InvalidValue extends Error { };
|
|
|
|
/**
|
|
* Invalid Field exception
|
|
*
|
|
* Exception class for when a group or field that does not exist is
|
|
* attempted to be accessed.
|
|
*
|
|
* @extends Error
|
|
*/
|
|
export class InvalidField extends Error { };
|
|
|
|
|
|
const OldConfigGroupSchema = Type.Object({
|
|
"name": Type.String(),
|
|
"fields": Type.Record(Type.String(), Type.Unknown()),
|
|
});
|
|
type OldConfigGroupSchema = Static<typeof OldConfigGroupSchema>;
|
|
|
|
const OldConfigSchema = Type.Object({
|
|
"groups": Type.Array(OldConfigGroupSchema),
|
|
});
|
|
export type OldConfigSchema = Static<typeof OldConfigSchema>
|
|
|
|
export const ConfigSchema = Type.Record(Type.String(), Type.Union([
|
|
Type.Null(),
|
|
Type.Boolean(),
|
|
Type.Number(),
|
|
Type.String(),
|
|
Type.Object({}),
|
|
]));
|
|
export type ConfigSchema = Static<typeof ConfigSchema>;
|
|
|
|
type FieldType = "boolean" | "string" | "number" | "object";
|
|
type FieldValue = null | boolean | string | number | object;
|
|
export interface FieldDefinition {
|
|
/**
|
|
* string declaring the type of the config value, supports boolean,
|
|
* string, number and object.
|
|
*/
|
|
type: FieldType;
|
|
/**
|
|
* Text used to identify the config field in user interfaces.
|
|
*/
|
|
title?: string;
|
|
/**
|
|
* Text used to describe the config field in user interfaces.
|
|
*/
|
|
description?: string;
|
|
/**
|
|
* Value to use for the autocomplete attribute in the Web UI's input element.
|
|
* Useful for making browsers behave properly with credential inputs.
|
|
* Only used for string and number fields.
|
|
*/
|
|
autoComplete?: string;
|
|
/**
|
|
* Web UI component to use for rending this input. Can be extended by
|
|
* @{link "@clusterio/web_ui".BaseWebPlugin.inputComponents}
|
|
*/
|
|
inputComponent?: string;
|
|
/**
|
|
* True if this field can be set to null. Defaults to false.
|
|
*/
|
|
optional?: boolean;
|
|
/**
|
|
* Value this config field should take in a newly initialized config.
|
|
* This can also be an function returning the value to use.
|
|
*/
|
|
initialValue?: unknown | (() => unknown);
|
|
/**
|
|
* Array of all values that are acceptible. Useful for making
|
|
* enumerated values.
|
|
*/
|
|
enum?: unknown[];
|
|
/**
|
|
* True if a restart of the entity this config is attached to is
|
|
* required for changes to take effect. Informative only, defaults to
|
|
* false.
|
|
*/
|
|
restartRequired?: boolean;
|
|
/**
|
|
* Properties to invert the value of restartRequired for if present.
|
|
*/
|
|
restartRequiredProps?: string[];
|
|
/**
|
|
* Array of strings declaring where this config entry can be read and
|
|
* modified from.
|
|
*/
|
|
access?: ConfigLocation[];
|
|
/**
|
|
* If present treat the config value as a credential.
|
|
* Only the locations specified will be able to read the current value.
|
|
* Access can be used to specify which locations are able to overwrite the value.
|
|
*/
|
|
credential?: ConfigLocation[];
|
|
/**
|
|
* If present treat the config value as readonly.
|
|
* Only the locations specified will be able to update the config value.
|
|
* Access can be used to specify which locations are able to read the value.
|
|
*/
|
|
readonly?: ConfigLocation[];
|
|
/**
|
|
* True if graphical interfaces should hide the config value
|
|
*/
|
|
hidden?: boolean;
|
|
}
|
|
|
|
export type ConfigDefs<Fields> = {
|
|
[Prop in keyof Fields]: FieldDefinition;
|
|
}
|
|
|
|
function defaultValue(def: FieldDefinition) {
|
|
if (def.initialValue === undefined) {
|
|
return null;
|
|
}
|
|
if (typeof def.initialValue === "function") {
|
|
return def.initialValue();
|
|
}
|
|
return def.initialValue;
|
|
}
|
|
|
|
export type ConfigEvents = {
|
|
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
|
|
}
|
|
|
|
export enum ConfigAccess {
|
|
read = 0x1,
|
|
write = 0x2,
|
|
readWrite = 0x3,
|
|
}
|
|
|
|
/**
|
|
* Collection of config entries
|
|
*
|
|
* Type checks and stores a collection of related config entries. Attempt
|
|
* to set invalid values for fields will result in an error thrown while
|
|
* loading a config with invalid values will result in those being replaced
|
|
* by the innitial value for those fields.
|
|
*
|
|
* This is an EventEmitter with the following events:
|
|
* - fieldChanged -
|
|
* invoked after a field has been updated. Takes three parameters: name,
|
|
* curr, prev.
|
|
*
|
|
* @extends events.EventEmitter
|
|
*/
|
|
export class Config<
|
|
Fields extends { [Field in keyof Fields]: FieldValue },
|
|
> extends events.EventEmitter<ConfigEvents> {
|
|
/**
|
|
* Mapping of config name to field meta data
|
|
* Note that mutating this object will lead to unexpected behaviour.
|
|
*/
|
|
declare static fieldDefinitions: ConfigDefs<any>;
|
|
declare ["constructor"]: typeof Config;
|
|
|
|
/**
|
|
* Handle migration between clusterio versions
|
|
*
|
|
* @param config - Input config read and validated from file
|
|
* @returns Output config to be loaded into the class
|
|
* @protected
|
|
*/
|
|
static migrations(config: Static<typeof this.jsonSchema>) {
|
|
return config;
|
|
}
|
|
|
|
/** Defines the default access locations for the config class */
|
|
static defaultAccess: ConfigLocation[] = ["controller", "host", "control"];
|
|
|
|
/** Contains the fields and their values */
|
|
private fields: Fields;
|
|
private _unknownFields: Record<string, FieldValue> = {};
|
|
|
|
/** Set to true when a field in the config is changed. */
|
|
private dirty = false;
|
|
/** Set to true when a 'restart required' field in the config is changed. */
|
|
restartRequired = false;
|
|
|
|
/**
|
|
* Create a new instance of the given config
|
|
*
|
|
* @param location -
|
|
* Location to evaluate access for this instance from.
|
|
* @param fields -
|
|
* Serialized representation of fields to load.
|
|
* @param filepath -
|
|
* Filepath for the config to save to.
|
|
*/
|
|
constructor(
|
|
public location: ConfigLocation,
|
|
fields?: Static<typeof Config.jsonSchema>,
|
|
public filepath?: string,
|
|
) {
|
|
if (typeof location !== "string") {
|
|
throw new Error("location must be a string");
|
|
}
|
|
super();
|
|
|
|
this.fields = Object.fromEntries(
|
|
Object.entries(
|
|
this.constructor.fieldDefinitions
|
|
).filter(
|
|
([name, def]) => this._checkAccess(name, def, location, ConfigAccess.read, false)
|
|
).map(
|
|
([name, def]) => [name, defaultValue(def)]
|
|
)
|
|
) as Fields;
|
|
|
|
if (fields) {
|
|
this.update(this.constructor.migrations(fields), false, location);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check access for a field
|
|
*
|
|
* @param name - Name of the field to check.
|
|
* @param def - definition for for the field to check.
|
|
* @param location - location to check access for.
|
|
* @param mode - type of access to check for.
|
|
* @param error - throw on failure to pass check.
|
|
* @throws {InvalidAccess}
|
|
* if access is not granted and error is true.
|
|
* @returns true if access is granted
|
|
* @private
|
|
*/
|
|
_checkAccess(name: string, def: FieldDefinition, location: ConfigLocation, mode: ConfigAccess, error: boolean) {
|
|
// If credential array is present it acts as read permission and the access array as write permission,
|
|
// If readonly array is present it acts as write permission and the access array as the read permission.
|
|
// If neither is present access array acts as a combined read/write permission check.
|
|
if (
|
|
def.credential && mode & ConfigAccess.read && !(def.credential).includes(location)
|
|
|| def.readonly && mode & ConfigAccess.write && !(def.readonly).includes(location)
|
|
|| (
|
|
(!def.credential || mode & ConfigAccess.write)
|
|
&& (!def.readonly || mode & ConfigAccess.read)
|
|
&& !(def.access ?? this.constructor.defaultAccess).includes(location)
|
|
)
|
|
) {
|
|
if (error) {
|
|
throw new InvalidAccess(`Field '${name}' is not accessible from ${location}`);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Set the value of a field, while also settings the required flags and events.
|
|
* This method does not perform any validation on the value being set
|
|
*
|
|
* @param name - Name of the field to set
|
|
* @param value - The new value of the field
|
|
* @param notify - False to skip setting dirty flags and emitting events
|
|
*/
|
|
_set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) {
|
|
const prev = this.fields[name];
|
|
this.fields[name] = value as any;
|
|
if (notify && !isDeepStrictEqual(value, prev)) {
|
|
const def = this.constructor.fieldDefinitions[name];
|
|
if (def.restartRequired) {
|
|
this.restartRequired = true;
|
|
}
|
|
this.dirty = true;
|
|
this.emit("fieldChanged", name, value, prev);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create config from file
|
|
*
|
|
* @param location - Location used for access control.
|
|
* @param filepath - Filepath for the config to save to.
|
|
* @returns Instance of this config
|
|
*/
|
|
static async fromFile(location: ConfigLocation, filepath: string) {
|
|
const fields = await fs.readJson(filepath, { encoding: "utf8" });
|
|
return this.fromJSON(fields, location, filepath);
|
|
}
|
|
|
|
/** Saves this config to file if there are unsaved changes */
|
|
async save() {
|
|
if (!this.dirty) {
|
|
return;
|
|
}
|
|
if (!this.filepath) {
|
|
throw new Error("Cannot save config which has no filepath");
|
|
}
|
|
await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"));
|
|
this.dirty = false;
|
|
}
|
|
|
|
static jsonSchema = ConfigSchema;
|
|
static validate = libSchema.compile(this.jsonSchema as any);
|
|
|
|
/**
|
|
* Create config from a serialized object
|
|
*
|
|
* @param json - Serialized config to load.
|
|
* @param location - Location used for access control.
|
|
* @param filepath - Filepath for the config to save to.
|
|
* @returns Instance of this config
|
|
*/
|
|
static fromJSON(
|
|
json: Static<typeof this.jsonSchema>,
|
|
location: ConfigLocation,
|
|
filepath?: string,
|
|
) {
|
|
// migrate: Pre alpha 14 config format
|
|
if (typeof json === "object" && json !== null && json.groups instanceof Array) {
|
|
json = Object.fromEntries(
|
|
(json.groups as unknown as OldConfigGroupSchema[]).flatMap(
|
|
group => Object.entries(group.fields).map(
|
|
([name, value]) => [`${group.name}.${name}`, value as FieldValue]
|
|
),
|
|
),
|
|
);
|
|
}
|
|
const valid = Config.validate(json);
|
|
if (!valid) {
|
|
throw new Error("Invalid config");
|
|
}
|
|
return new this(location, json, filepath);
|
|
}
|
|
|
|
toJSON() {
|
|
return this.toRemote(this.location);
|
|
}
|
|
|
|
/**
|
|
* Serialize the config to a plain JavaScript object
|
|
*
|
|
* @param remote - Location this serialised representation is for
|
|
* @param filter - When provided, only the given fields are serialized
|
|
* @returns JSON serializable representation of the config.
|
|
*/
|
|
toRemote(remote: ConfigLocation, filter?: (keyof Fields)[]): Static<typeof Config.jsonSchema> {
|
|
let fields: Record<string, FieldValue> = {};
|
|
for (let [name, value] of Object.entries(this.fields)) {
|
|
if (filter && !filter.includes(name as keyof Fields)) {
|
|
continue;
|
|
}
|
|
|
|
let def = this.constructor.fieldDefinitions[name];
|
|
if (
|
|
this.location !== "control" && !this._checkAccess(name, def, this.location, ConfigAccess.write, false)
|
|
|| this.location !== remote && !this._checkAccess(name, def, remote, ConfigAccess.read, false)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
fields[name] = value as FieldValue;
|
|
}
|
|
|
|
for (let [name, value] of Object.entries(this._unknownFields)) {
|
|
fields[name] = value;
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Update a config from a serialized config
|
|
*
|
|
* Overwrites the values of all the fields in this config that has a
|
|
* valid value in the passed serialized config with that value.
|
|
*
|
|
* @param json -
|
|
* Result from a previous call to .toRemote().
|
|
* @param notify - Invoke fieldChanged events if true.
|
|
* @param remote - Location this serialised representation was received from.
|
|
*/
|
|
update(
|
|
json: Static<typeof Config.jsonSchema>,
|
|
notify: boolean,
|
|
remote = this.location
|
|
) {
|
|
const valid = Config.validate(json);
|
|
if (!valid) {
|
|
throw new Error("Invalid config");
|
|
}
|
|
|
|
if (basicType(json) !== "object") {
|
|
throw new Error(`Expected object, not ${basicType(json)} for Config`);
|
|
}
|
|
|
|
for (let [name, value] of Object.entries(json) as [keyof Fields & string, FieldValue][]) {
|
|
let def = this.constructor.fieldDefinitions[name];
|
|
if (!def) {
|
|
this._unknownFields[name as string] = value;
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
this.location !== "control" && !this._checkAccess(name, def, remote, ConfigAccess.write, false)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (def.optional) {
|
|
if (value !== null && basicType(value) !== def.type) {
|
|
continue;
|
|
}
|
|
|
|
} else if (basicType(value) !== def.type) {
|
|
continue;
|
|
}
|
|
|
|
if (def.enum && !def.enum.includes(value)) {
|
|
continue;
|
|
}
|
|
|
|
this._set(name, value, notify);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if field can be accessed from the given location
|
|
*
|
|
* @param name - Name of field to check.
|
|
* @param mode - Mode to access the field as.
|
|
* @param location - Location used for access control.
|
|
* @returns true if field is accessible
|
|
*/
|
|
canAccess(name: string, mode: ConfigAccess, location = this.location) {
|
|
if (typeof mode !== "number") {
|
|
throw new TypeError("mode argument is required to canAccess");
|
|
}
|
|
let def = this.constructor.fieldDefinitions[name];
|
|
if (!def) {
|
|
throw new InvalidField(`No field named '${name}'`);
|
|
}
|
|
return this._checkAccess(name, def, location, mode, false);
|
|
}
|
|
|
|
/**
|
|
* Get value for field
|
|
*
|
|
* @param name - Name of field to get.
|
|
* @param remote - Location this value is being read from.
|
|
* @returns Value stored for the field.
|
|
*/
|
|
get<Field extends keyof Fields & string>(name: Field, remote = this.location) {
|
|
let def = this.constructor.fieldDefinitions[name];
|
|
if (!def) {
|
|
throw new InvalidField(`No field named '${name}'`);
|
|
}
|
|
// If remote access is requested require it to be able to read the field
|
|
if (remote !== this.location) {
|
|
this._checkAccess(name, def, remote, ConfigAccess.read, true);
|
|
}
|
|
// Require that the field is either read or writable locally so that you can get the value
|
|
// back of write only fields recently written to.
|
|
if (
|
|
!this._checkAccess(name, def, this.location, ConfigAccess.write, false)
|
|
&& !this._checkAccess(name, def, this.location, ConfigAccess.read, false)
|
|
) {
|
|
throw new InvalidAccess(`Field '${name}' is not accessible from ${this.location}`);
|
|
}
|
|
|
|
return this.fields[name];
|
|
}
|
|
|
|
/**
|
|
* Set value of field
|
|
*
|
|
* The field must be defined for the config and the value is
|
|
* type checked against the field definition.
|
|
*
|
|
* @param name - Name of field to set.
|
|
* @param newValue - Value to set for field.
|
|
* @param remote - Location this field is being set from.
|
|
* @throws {InvalidField} if field is not defined.
|
|
* @throws {InvalidValue} if value is not allowed for the field.
|
|
*/
|
|
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
|
let value: FieldValue = newValue;
|
|
let def = this.constructor.fieldDefinitions[name];
|
|
if (!def) {
|
|
throw new InvalidField(`No field named '${name}'`);
|
|
}
|
|
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
|
if (remote !== this.location) {
|
|
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
|
}
|
|
|
|
// Empty strings are treates as null
|
|
if (value === "") {
|
|
value = null;
|
|
}
|
|
|
|
if (def.enum && !def.enum.includes(value)) {
|
|
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
if (def.type === "boolean") {
|
|
if (value === "true") {
|
|
value = true;
|
|
} else if (value === "false") {
|
|
value = false;
|
|
}
|
|
|
|
} else if (def.type === "number") {
|
|
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
|
|
if (numberRegExp.test(value.trim())) {
|
|
value = Number.parseFloat(value);
|
|
}
|
|
|
|
} else if (def.type === "object") {
|
|
try {
|
|
value = JSON.parse(value);
|
|
} catch (err: any) {
|
|
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (value === null) {
|
|
if (!def.optional) {
|
|
throw new InvalidValue(`Field ${name} cannot be null`);
|
|
}
|
|
|
|
} else if (basicType(value) !== def.type) {
|
|
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
|
}
|
|
|
|
this._set(name, value);
|
|
}
|
|
|
|
/**
|
|
* Set property of object field
|
|
*
|
|
* Update value of stored object field by setting the specified property
|
|
* on it. The field must be defined as an object for the config.
|
|
*
|
|
* @param name - Name of field to set property on.
|
|
* @param prop - Name of property to set on field.
|
|
* @param value - the value to set the property to.
|
|
* @param remote - Location this property is being set from
|
|
* @throws {InvalidField} if field is not defined.
|
|
* @throws {InvalidValue} if field is not an object.
|
|
*/
|
|
setProp<Field extends keyof Fields & string>(
|
|
name: Field,
|
|
prop: string,
|
|
value?: unknown,
|
|
remote = this.location,
|
|
) {
|
|
let def = this.constructor.fieldDefinitions[name];
|
|
if (!def) {
|
|
throw new InvalidField(`No field named '${name}'`);
|
|
}
|
|
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
|
if (remote !== this.location) {
|
|
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
|
}
|
|
|
|
if (def.type !== "object") {
|
|
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
|
|
}
|
|
|
|
let prev = this.fields[name] as Record<string, unknown>;
|
|
let updated = {...prev || {}};
|
|
|
|
if (value !== undefined) {
|
|
updated[prop] = value;
|
|
} else {
|
|
delete updated[prop];
|
|
}
|
|
|
|
this._set(name, updated);
|
|
}
|
|
}
|