diff --git a/docs/configuration.md b/docs/configuration.md index 89650ca9..41683905 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -521,6 +521,31 @@ This allows you to prevent certain instances from being started when using the b Defaults to false. +### instance.is_template + +If enabled this instance is a template other instances can inherit config values from. +Templates cannot be assigned to a host and therefore never start. +Hidden from `instance list` in clusterioctl unless `--templates` is passed, and listed separately in the web interface. +An instance cannot stop being a template while other instances inherit from it. +Hidden in the web interface, set when creating the template. + +Defaults to false. + + +### instance.template_id + +ID of the template to inherit config values from. +Inherited fields take their value from the template and follow it when it changes, setting a field on this instance stops inheriting it. +Object fields like `factorio.settings` are inherited per property, properties set on this instance override the ones from the template. +When set on an existing instance fields with the same value as the template or the default value become inherited, except the server name in `factorio.settings`. +`instance.name`, `instance.is_template`, `instance.template_id`, the port fields, `factorio.rcon_password` and hidden fields are never inherited. +A template may itself inherit from another template, but not from itself through a chain of templates. +The fields currently inherited are stored in the hidden `instance.inherited_fields` field. +In the web interface inherited values are linked and read only until they are unlinked. + +Defaults to null meaning no template. + + ### factorio.version Version of Factorio to use for this instance. diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 51982131..5a048c27 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -79,15 +79,16 @@ Marks the host as requiring a restart, the same way a plugin update does. Use it ### List Instances - ctl> instance list + ctl> instance list [--templates] List instances. +Templates are only listed if `--templates` is passed. ### Create instance - ctl> instance create [--id ] + ctl> instance create [--id ] [--from ] [--template ] [--as-template] -Create a new instance. `name` is the instance name. `--id` is the optional id. +Create a new instance. `name` is the instance name. `--id` is the optional id. `--from` copies the config of an existing instance. `--template` makes the new instance inherit all config values from the given template. `--as-template` creates the instance as a template, see `instance.is_template` in the [configuration docs](configuration.md). Use `--as-template --from ` to make a template out of an existing instance. ### Kill instance @@ -120,7 +121,7 @@ Most notably Clusterio records wall clock time while the save time is recorded i ctl> instance config list -List config values for an instance. `instance` is the instance whose config values to list. +List config values for an instance. `instance` is the instance whose config values to list. Values inherited from a template are marked with `(inherited)`, object fields also list the properties set on the instance. #### Set config value @@ -132,11 +133,17 @@ Set field in instance config. `instance` is instance to set. `field` is field to To be written. +#### Inherit config value + + ctl> instance config inherit [prop] + +Make a field in the instance config take its value from the template again, undoing a previous `set`. `instance` is the instance to update, `field` is the field to inherit, `prop` limits it to one property of an object field. + #### Edit instance config ctl> instance config edit [editor] -Edit instance config in a text editor. Once file is saved and editor has been exited, config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables. +Edit instance config in a text editor. Once file is saved and editor has been exited, changed config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables. ### Assign instance to host diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 43af122d..aa618603 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -8,6 +8,7 @@ import { logFilter, logger } from "@clusterio/lib"; import BaseConnection from "./BaseConnection.js"; import User from "./User.js"; import UserRecord from "./UserRecord.js"; +import type InstanceRecord from "./InstanceRecord.js"; import * as routes from "./routes.js"; import Controller from "./Controller.js"; @@ -94,6 +95,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this)); this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this)); this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this)); + this.handle(lib.InstanceConfigInheritFieldRequest, this.handleInstanceConfigInheritFieldRequest.bind(this)); this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this)); this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this)); this.handle(lib.InstanceRenameSaveRequest, controller.sendRequestToHostByInstanceId.bind(controller)); @@ -315,6 +317,12 @@ export default class ControlConnection extends BaseConnection { instanceConfig.set("instance.assigned_host", null); // New instances are unassigned } instanceConfig.update(request.config, false, "control"); + this._controller.instances.checkInheritance( + instanceConfig, instanceConfig.get("instance.is_template"), instanceConfig.get("instance.template_id") + ); + if (Object.hasOwn(request.config, "instance.template_id")) { + this._controller.instances.applyTemplate(instanceConfig, Object.keys(request.config)); + } await this._controller.instances.createInstance(instanceConfig); } @@ -355,17 +363,41 @@ export default class ControlConnection extends BaseConnection { } } + this.checkStagedInheritance(instance); instance.config.commitStaging(); + for (const [fieldName, fieldValue] of Object.entries(request.fields)) { + const props = typeof fieldValue === "object" + ? Object.keys(fieldValue).filter(prop => fieldValue[prop] !== undefined) + : undefined; + this._controller.instances.overrideField(instance, fieldName, props); + } await this._controller.instanceConfigUpdated(instance); } finally { instance.config.revertStaging(); } } + private checkStagedInheritance(instance: InstanceRecord) { + const staged = instance.config.staging; + this._controller.instances.checkInheritance( + instance.config, staged.get("instance.is_template"), staged.get("instance.template_id") + ); + } + async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) { const instance = this._controller.instances.getForRequest(request.instanceId); - ControlConnection.validateInstanceConfigSetField(request.field); - instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control"); + const field = request.field as keyof lib.InstanceConfigFields; + ControlConnection.validateInstanceConfigSetField(field); + if (["instance.is_template", "instance.template_id"].includes(field)) { + try { + instance.config.stage(field, request.value, "control"); + this.checkStagedInheritance(instance); + } finally { + instance.config.revertStaging(); + } + } + instance.config.set(field, request.value, "control"); + this._controller.instances.overrideField(instance, field); await this._controller.instanceConfigUpdated(instance); } @@ -374,9 +406,18 @@ export default class ControlConnection extends BaseConnection { let { field, prop, value } = request; ControlConnection.validateInstanceConfigSetField(field); instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control"); + if (value !== undefined) { + this._controller.instances.overrideField(instance, field, [prop]); + } await this._controller.instanceConfigUpdated(instance); } + async handleInstanceConfigInheritFieldRequest(request: lib.InstanceConfigInheritFieldRequest) { + ControlConnection.validateInstanceConfigSetField(request.field); + this._controller.instances.inheritField(request.instanceId, request.field, request.prop); + await this._controller.instanceConfigUpdated(this._controller.instances.getForRequest(request.instanceId)); + } + async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) { await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null); } diff --git a/packages/controller/src/InstanceManager.ts b/packages/controller/src/InstanceManager.ts index 4323d59a..c9feda88 100644 --- a/packages/controller/src/InstanceManager.ts +++ b/packages/controller/src/InstanceManager.ts @@ -1,9 +1,14 @@ +import { isDeepStrictEqual } from "node:util"; import * as lib from "@clusterio/lib"; +import { logger } from "@clusterio/lib"; import type Controller from "./Controller.js"; import type HostConnection from "./HostConnection.js"; import InstanceRecord from "./InstanceRecord.js"; +type InheritedFields = lib.InstanceConfigFields["instance.inherited_fields"]; +type InheritableField = keyof lib.InstanceConfigFields & string; + export default class InstanceManager { private static readonly DefaultFactorioSettings = { tags: ["clusterio"], @@ -24,6 +29,9 @@ export default class InstanceManager { non_blocking_saving: false, } as const; + /** Non-zero while template values are being copied into inheriting instances */ + private _inheriting = 0; + constructor( public readonly records: lib.SubscribableDatastore, private readonly _controller: Controller, @@ -106,11 +114,271 @@ export default class InstanceManager { */ addInstanceHooks(instance: InstanceRecord): void { instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => { + if (field === "instance.template_id") { + this._linkMatchingFields(instance); + } else if (!this._inheriting && instance.config.isInherited(field)) { + this._unlinkEditedField(instance, field, curr, prev); + } this.records.set(instance); this._notifyConfigFieldChanged(instance, field, curr, prev); + this._updateInheritors(instance, field, curr); }); } + /* ------------------------------------------------------------------------- */ + /* Inheritance */ + /* ------------------------------------------------------------------------- */ + + /** + * Get the instances that inherit directly from the given template. + * + * @param templateId - ID of the template. + * @returns Instances with `instance.template_id` set to templateId. + */ + inheritorsOf(templateId: number): InstanceRecord[] { + return [...this.records.values()].filter( + instance => instance.config.get("instance.template_id") === templateId + ); + } + + /** + * Check that new values for `instance.is_template` and `instance.template_id` are allowed. + * + * @param config - Current config of the instance. + * @param isTemplate - New value for `instance.is_template`. + * @param templateId - New value for `instance.template_id`. + * @throws {module:lib.ValidationError} if the template is not a template or would create a cycle. + * @throws {module:lib.ConflictError} if the instance is assigned or still has inheritors. + */ + checkInheritance(config: lib.InstanceConfig, isTemplate: boolean, templateId: number | null): void { + const instanceId = config.get("instance.id"); + if (isTemplate && config.get("instance.assigned_host") !== null) { + throw new lib.ConflictError("Instance must be unassigned before it can be made a template"); + } + if (!isTemplate && config.get("instance.is_template")) { + const count = this.inheritorsOf(instanceId).length; + if (count) { + throw new lib.ConflictError(`Instance is the template of ${count} other instance(s)`); + } + } + if (templateId === null) { + return; + } + const template = this.records.get(templateId); + if (!template) { + throw new lib.NotFoundError(`Instance with ID ${templateId} does not exist`); + } + if (!template.config.get("instance.is_template")) { + throw new lib.ValidationError(`Instance ${template.config.get("instance.name")} is not a template`); + } + const visited = new Set(); + for (let id: number | null = templateId; id !== null && !visited.has(id);) { + if (id === instanceId) { + throw new lib.ValidationError("Templates cannot inherit from themselves"); + } + visited.add(id); + id = this.records.get(id)?.config.get("instance.template_id") ?? null; + } + } + + /** + * Copy the inheritable values from the template set in `instance.template_id` + * into a config that is not yet part of the cluster and mark them inherited. + * + * @param config - Config of the instance to be created. + * @param own - Fields set by the creator, object fields keep their properties as overrides. + */ + applyTemplate(config: lib.InstanceConfig, own: string[] = []): void { + const template = this._templateOf(config); + if (!template) { + return; + } + const inherited: InheritedFields = {}; + for (const name of InstanceManager._inheritableFields()) { + const def = lib.InstanceConfig.fieldDefinitions[name]; + if (def.type === "object") { + inherited[name] = own.includes(name) ? Object.keys(config.get(name) as object ?? {}) : []; + } else if (!own.includes(name)) { + inherited[name] = true; + } + } + config.set("instance.inherited_fields", inherited); + config.update(InstanceManager._inheritedValues(config, template.config, Object.keys(inherited)), false); + } + + /** + * Make a field, or a property of an object field, take its value from the template again. + * + * @param instanceId - ID of the instance to update. + * @param field - Name of the field to inherit. + * @param prop - Property of an object field to inherit, inherits all properties if not given. + * @throws {module:lib.ValidationError} if the instance has no template or the field cannot be inherited. + */ + inheritField(instanceId: number, field: string, prop?: string): void { + const instance = this.getForRequest(instanceId); + if (!this._templateOf(instance.config)) { + throw new lib.ValidationError("Instance does not have a template"); + } + if (!lib.InstanceConfig.isInheritable(field)) { + throw new lib.ValidationError(`Field ${field} cannot be inherited`); + } + const name = field as InheritableField; + const inherited = { ...instance.config.get("instance.inherited_fields") }; + if (lib.InstanceConfig.fieldDefinitions[name].type !== "object") { + inherited[field] = true; + } else if (prop === undefined) { + inherited[field] = []; + } else { + const entry = inherited[field]; + const overrides = entry instanceof Array ? entry : Object.keys(instance.config.get(name) as object ?? {}); + inherited[field] = overrides.filter(p => p !== prop); + } + instance.config.set("instance.inherited_fields", inherited); + this._copyInheritedValues(instance, [field]); + } + + /** + * Stop inheriting the given field or properties, used when a value is explicitly set. + * + * @param instance - Instance the field was set on. + * @param field - Name of the field set. + * @param props - Properties set on an object field. + */ + overrideField(instance: InstanceRecord, field: string, props?: string[]): void { + const entry = instance.config.get("instance.inherited_fields")[field]; + if (!instance.config.isInherited(field)) { + return; + } + const inherited = { ...instance.config.get("instance.inherited_fields") }; + if (entry instanceof Array && props) { + inherited[field] = [...new Set([...entry, ...props])]; + } else { + delete inherited[field]; + } + instance.config.set("instance.inherited_fields", inherited); + } + + private _templateOf(config: lib.InstanceConfig) { + const templateId = config.get("instance.template_id"); + return templateId !== null ? this.records.get(templateId) : undefined; + } + + private static _inheritableFields() { + return Object.keys(lib.InstanceConfig.fieldDefinitions).filter( + name => lib.InstanceConfig.isInheritable(name) + ) as InheritableField[]; + } + + /** Default value of a field, or undefined if it is generated */ + private static _defaultValue(name: InheritableField): unknown { + const def = lib.InstanceConfig.fieldDefinitions[name]; + if (name === "factorio.settings") { + return InstanceManager.DefaultFactorioSettings; + } + return typeof def.initialValue === "function" ? undefined : def.initialValue ?? null; + } + + /** Values the given inherited fields take from the template, with object overrides applied */ + private static _inheritedValues(config: lib.InstanceConfig, template: lib.InstanceConfig, fields: string[]) { + const inherited = config.get("instance.inherited_fields"); + const values: Record = {}; + for (const field of fields) { + const name = field as InheritableField; + const entry = inherited[field]; + if (!entry) { + continue; + } + const value = structuredClone(template.get(name)) as any; + if (entry instanceof Array) { + const own = config.get(name) as Record ?? {}; + for (const prop of entry) { + if (Object.hasOwn(own, prop)) { + value[prop] = structuredClone(own[prop]); + } + } + } + values[field] = value; + } + return values; + } + + /** Update the given inherited fields of a live instance from its template */ + private _copyInheritedValues(instance: InstanceRecord, fields: string[]): void { + const template = this._templateOf(instance.config); + if (!template) { + return; + } + const values = InstanceManager._inheritedValues(instance.config, template.config, fields); + this._inheriting += 1; + try { + instance.config.update(values, true); + } finally { + this._inheriting -= 1; + } + } + + /** Unlink a field, or the changed properties of an object field, edited on the instance */ + private _unlinkEditedField(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): void { + const entry = instance.config.get("instance.inherited_fields")[field]; + if (!(entry instanceof Array)) { + this.overrideField(instance, field); + return; + } + const currObj = curr as Record ?? {}; + const prevObj = prev as Record ?? {}; + const changed = Object.keys(currObj).filter(p => !isDeepStrictEqual(currObj[p], prevObj[p])); + const removed = Object.keys(prevObj).filter(p => !Object.hasOwn(currObj, p)); + const overrides = [...new Set([...entry, ...changed])].filter(p => !removed.includes(p)); + const inherited = { ...instance.config.get("instance.inherited_fields"), [field]: overrides }; + instance.config.set("instance.inherited_fields", inherited); + if (removed.length) { + // Removed properties fall back to the template value + this._copyInheritedValues(instance, [field]); + } + } + + /** Recompute inherited fields after template_id changed, values equal to the template or default are inherited */ + private _linkMatchingFields(instance: InstanceRecord): void { + const template = this._templateOf(instance.config); + const inherited: InheritedFields = {}; + if (template) { + for (const name of InstanceManager._inheritableFields()) { + const value = instance.config.get(name) as any; + const templateValue = template.config.get(name) as any; + const defaultValue = InstanceManager._defaultValue(name) as any; + if (lib.InstanceConfig.fieldDefinitions[name].type === "object") { + inherited[name] = Object.keys(value ?? {}).filter(prop => !( + isDeepStrictEqual(value[prop], templateValue?.[prop]) + || defaultValue && isDeepStrictEqual(value[prop], defaultValue[prop]) + ) || name === "factorio.settings" && prop === "name"); + } else if ( + isDeepStrictEqual(value, templateValue) + || defaultValue !== undefined && isDeepStrictEqual(value, defaultValue) + ) { + inherited[name] = true; + } + } + } + instance.config.set("instance.inherited_fields", inherited); + this._copyInheritedValues(instance, Object.keys(inherited)); + } + + /** Copy a changed value from a template to the instances inheriting it */ + private _updateInheritors(template: InstanceRecord, field: string, value: unknown): void { + if (!lib.InstanceConfig.isInheritable(field)) { + return; + } + for (const instance of this.inheritorsOf(template.id)) { + if (!instance.config.isInherited(field)) { + continue; + } + this._copyInheritedValues(instance, [field]); + this._controller.instanceConfigUpdated(instance).catch(err => { + logger.error(`Failed to send config of instance ${instance.id} to its host:\n${err.stack}`); + }); + } + } + /* ------------------------------------------------------------------------- */ /* Lifecycle */ /* ------------------------------------------------------------------------- */ @@ -137,12 +405,23 @@ export default class InstanceManager { } const controllerName = this._controller.config.get("controller.name"); - instanceConfig.set("factorio.settings", { - name: `${controllerName} - ${instanceConfig.get("instance.name")}`, - description: `Clusterio instance for ${controllerName}`, - ...InstanceManager.DefaultFactorioSettings, - ...instanceConfig.get("factorio.settings"), - }); + const serverName = `${controllerName} - ${instanceConfig.get("instance.name")}`; + const settings = instanceConfig.get("factorio.settings"); + if (!instanceConfig.isInherited("factorio.settings")) { + instanceConfig.set("factorio.settings", { + name: serverName, + description: `Clusterio instance for ${controllerName}`, + ...InstanceManager.DefaultFactorioSettings, + ...settings, + }); + } else if (instanceConfig.isInherited("factorio.settings", "name")) { + // Each instance gets its own server name + instanceConfig.setProp("factorio.settings", "name", serverName); + const inherited = instanceConfig.get("instance.inherited_fields"); + instanceConfig.set("instance.inherited_fields", { + ...inherited, "factorio.settings": [...inherited["factorio.settings"] as string[], "name"], + }); + } const instance = new InstanceRecord(instanceConfig, "unassigned"); this.addInstanceHooks(instance); @@ -186,6 +465,9 @@ export default class InstanceManager { if (currentAssignedHost === hostId) { return; } + if (hostId !== null && instance.config.get("instance.is_template")) { + throw new lib.ValidationError("Templates cannot be assigned to a host"); + } let newHostConnection: HostConnection | undefined; if (hostId !== null) { @@ -252,6 +534,10 @@ export default class InstanceManager { async deleteInstance(instanceId: number): Promise { const instance = this.getForRequest(instanceId); const hostId = instance.config.get("instance.assigned_host"); + const inheritors = this.inheritorsOf(instanceId).length; + if (inheritors) { + throw new lib.ConflictError(`Instance is the template of ${inheritors} other instance(s)`); + } if (hostId !== null) { await this._controller.sendTo( diff --git a/packages/controller/src/InstanceRecord.ts b/packages/controller/src/InstanceRecord.ts index 9ad0db58..02f3aa4e 100644 --- a/packages/controller/src/InstanceRecord.ts +++ b/packages/controller/src/InstanceRecord.ts @@ -83,6 +83,8 @@ export default class InstanceRecord { this.startedAtMs, this.updatedAtMs, this.config.get("instance.exclude_from_start_all"), + this.config.get("instance.is_template"), + this.config.get("instance.template_id") ?? undefined, ); } diff --git a/packages/ctl/src/commands_instance.ts b/packages/ctl/src/commands_instance.ts index 63440875..ff57d1af 100644 --- a/packages/ctl/src/commands_instance.ts +++ b/packages/ctl/src/commands_instance.ts @@ -20,10 +20,14 @@ export const instanceCommands = new lib.CommandTree({ name: "instance", alias: ["i"], description: "Instance management", }); instanceCommands.add(new lib.Command({ - definition: [["list", "l"], "List instances known to the controller"], - handler: async function(args: object, ctl: Ctl) { + definition: [["list", "l"], "List instances known to the controller", (yargs) => { + yargs.options({ + "templates": { describe: "Include templates", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: { templates: boolean }, ctl: Ctl) { let list = await ctl.send(new lib.InstanceDetailsListRequest()); - print(asTable(list)); + print(asTable(list.filter(instance => args.templates || !instance.isTemplate))); }, })); @@ -34,18 +38,30 @@ instanceCommands.add(new lib.Command({ yargs.options({ "id": { type: "number", nargs: 1, describe: "Instance id" }, "from": { type: "number", nargs: 1, describe: "Clone config from instance id" }, + "template": { type: "string", nargs: 1, describe: "Template to inherit config from" }, + "as-template": { type: "boolean", nargs: 0, describe: "Create the instance as a template" }, }); }], - handler: async function(args: { name: string, id?: number, from?: number, }, ctl: Ctl) { + handler: async function( + args: { name: string, id?: number, from?: number, template?: string, asTemplate?: boolean }, + ctl: Ctl, + ) { let instanceConfig = new lib.InstanceConfig("control"); + let fields: (keyof lib.InstanceConfigFields)[] = ["instance.id", "instance.name"]; if (args.id !== undefined) { instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); + if (args.template !== undefined) { + instanceConfig.set("instance.template_id", await lib.resolveInstance(ctl, args.template)); + fields.push("instance.template_id"); + } + if (args.asTemplate) { + instanceConfig.set("instance.is_template", true); + fields.push("instance.is_template"); + } await ctl.send(new lib.InstanceCreateRequest( - instanceConfig.toRemote("controller", [ - "instance.id", "instance.name", - ]), + instanceConfig.toRemote("controller", fields), args.from )); }, @@ -61,13 +77,42 @@ instanceConfigCommands.add(new lib.Command({ handler: async function(args: { instance: string }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId)); + const inherited = config["instance.template_id"] !== null + ? config["instance.inherited_fields"] as Record ?? {} + : {}; for (let [name, value] of Object.entries(config)) { - print(`${name} ${JSON.stringify(value)}`); + if (name === "instance.inherited_fields") { + continue; + } + const entry = inherited[name]; + let note = ""; + if (entry instanceof Array && entry.length) { + note = ` (inherited except ${entry.join(", ")})`; + } else if (entry) { + note = " (inherited)"; + } + print(`${name} ${JSON.stringify(value)}${note}`); } }, })); +instanceConfigCommands.add(new lib.Command({ + definition: [ + "inherit [prop]", + "Make field or property in instance config use the value of its template", + (yargs) => { + yargs.positional("instance", { describe: "Instance to update config on", type: "string" }); + yargs.positional("field", { describe: "Field to inherit", type: "string" }); + yargs.positional("prop", { describe: "Property of object field to inherit", type: "string" }); + }, + ], + handler: async function(args: { instance: string, field: string, prop?: string }, ctl: Ctl) { + let instanceId = await lib.resolveInstance(ctl, args.instance); + await ctl.send(new lib.InstanceConfigInheritFieldRequest(instanceId, args.field, args.prop)); + }, +})); + instanceConfigCommands.add(new lib.Command({ definition: ["set [value]", "Set field in instance config", (yargs) => { yargs.positional("instance", { describe: "Instance to set config on", type: "string" }); @@ -170,8 +215,10 @@ instanceConfigCommands.add(new lib.Command({ await events.once(editorSpawn, "exit"); const data = await fs.readFile(tmpFile, "utf8"); const final = await configToKeyVal(data); + const original = await configToKeyVal(allConfigElements); for (let index in final) { - if (index in final) { + // Unchanged fields are skipped so they keep inheriting from the template + if (index in final && final[index] !== original[index]) { try { await ctl.send(new lib.InstanceConfigSetFieldRequest( instanceId, diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b28db160..256441db 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -488,6 +488,9 @@ export interface InstanceConfigFields { "instance.assigned_host": number | null; "instance.auto_start": boolean; "instance.exclude_from_start_all": boolean; + "instance.is_template": boolean; + "instance.template_id": number | null; + "instance.inherited_fields": Record; [key: `${string}.load_plugin`]: boolean; [key: `${string}.version`]: string; @@ -539,6 +542,38 @@ export class InstanceConfig extends classes.Config { return config; } + /** Fields that are never inherited from a template, ports clash on a shared host and secrets stay per instance */ + static uninheritableFields = [ + "instance.name", "instance.is_template", "instance.template_id", + "factorio.game_port", "factorio.rcon_port", "factorio.rcon_password", "factorio.lua_udp_port", + ]; + + /** True if the field can take its value from a template */ + static isInheritable(name: string) { + const def = this.fieldDefinitions[name as keyof InstanceConfigFields]; + return Boolean(def) + && !def.hidden + && (def.access ?? this.defaultAccess).includes("controller") + && !this.uninheritableFields.includes(name); + } + + /** + * True if the field, or the given property of an object field, takes its value from the template + * + * @param name - Name of field to check. + * @param prop - Property of an object field to check. + */ + isInherited(name: string, prop?: string) { + if (this.get("instance.template_id") === null) { + return false; + } + const entry = this.get("instance.inherited_fields")[name]; + if (entry instanceof Array) { + return prop === undefined || !entry.includes(prop); + } + return Boolean(entry); + } + static fieldDefinitions: classes.ConfigDefs = { "instance.name": { type: "string", @@ -566,6 +601,33 @@ export class InstanceConfig extends classes.Config { type: "boolean", initialValue: false, }, + "instance.is_template": { + description: + "Mark this instance as a template other instances can inherit config from. " + + "Templates cannot be assigned to a host or started.", + access: ["controller", "control"], + type: "boolean", + initialValue: false, + hidden: true, + }, + "instance.template_id": { + title: "Template", + description: "ID of the template to inherit config values from", + access: ["controller", "control"], + inputComponent: "instance_template", + type: "number", + optional: true, + // Checked by the controller + }, + "instance.inherited_fields": { + description: + "Fields whose value comes from the template, for object fields the properties set on this instance", + access: ["controller", "control"], + readonly: ["controller"], + type: "object", + initialValue: {}, + hidden: true, + }, "factorio.version": { description: "Version of the game to run. Use 'latest' for the latest installed version, a release " + diff --git a/packages/lib/src/data/InstanceDetails.ts b/packages/lib/src/data/InstanceDetails.ts index adf0b9d8..cad0761d 100644 --- a/packages/lib/src/data/InstanceDetails.ts +++ b/packages/lib/src/data/InstanceDetails.ts @@ -41,7 +41,9 @@ export default class InstanceDetails { public startedAtMs = 0, /** Millisecond Unix timestamp this entry was last updated at */ public updatedAtMs = 0, - public excludeFromStartAll = false + public excludeFromStartAll = false, + public isTemplate = false, + public templateId?: number, ) {} get isDeleted() { @@ -61,6 +63,8 @@ export default class InstanceDetails { "startedAtMs": Type.Optional(Type.Number()), "updatedAtMs": Type.Optional(Type.Number()), "excludeFromStartAll": Type.Optional(Type.Boolean()), + "isTemplate": Type.Optional(Type.Boolean()), + "templateId": Type.Optional(Type.Integer()), }); static fromJSON(json: Static) { @@ -73,7 +77,9 @@ export default class InstanceDetails { json.factorioVersion, json.startedAtMs, json.updatedAtMs, - json.excludeFromStartAll + json.excludeFromStartAll, + json.isTemplate, + json.templateId, ); } @@ -102,6 +108,12 @@ export default class InstanceDetails { if (this.excludeFromStartAll !== false) { json.excludeFromStartAll = this.excludeFromStartAll; } + if (this.isTemplate !== false) { + json.isTemplate = this.isTemplate; + } + if (this.templateId !== undefined) { + json.templateId = this.templateId; + } return json; } diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index d107632c..77500fab 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -176,6 +176,30 @@ export class InstanceConfigSetPropRequest { } } +export class InstanceConfigInheritFieldRequest { + declare ["constructor"]: typeof InstanceConfigInheritFieldRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.instance.update_config" as const; + + constructor( + public instanceId: number, + public field: string, + public prop?: string, + ) { } + + static jsonSchema = Type.Object({ + "instanceId": Type.Integer(), + "field": Type.String(), + "prop": Type.Optional(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.instanceId, json.field, json.prop); + } +} + export class InstanceAssignRequest { declare ["constructor"]: typeof InstanceAssignRequest; static type = "request" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index f4e15976..c0ea4940 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -71,6 +71,7 @@ export const dataClasses: (RequestClass | EventClass) instance.InstanceConfigSetRequest, instance.InstanceConfigSetFieldRequest, instance.InstanceConfigSetPropRequest, + instance.InstanceConfigInheritFieldRequest, instance.InstanceAssignRequest, instance.InstanceMetricsRequest, instance.InstanceStartRequest, diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index d3e9f739..b87d4852 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -8,6 +8,7 @@ import * as lib from "@clusterio/lib"; import App from "./components/App"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; +import { InputInstanceTemplate } from "./components/InputInstance"; import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion"; import { Control, ControlConnector } from "./util/websocket"; import { loadedPluginSetKey } from "./util/pluginSet"; @@ -148,6 +149,7 @@ async function inputComponentsFromHooks(control: Control) { "target_version": InputTargetVersion, "mod_pack": InputModPack, "role": InputRole, + "instance_template": InputInstanceTemplate, }]); const iterable = result.map( diff --git a/packages/web_ui/src/components/BaseConfigTree.tsx b/packages/web_ui/src/components/BaseConfigTree.tsx index 058487ce..f352acb4 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -2,6 +2,8 @@ import React, { useContext, useEffect, useState } from "react"; import { Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, Select, Space, Spin, Tooltip, Tree, Typography, } from "antd"; +import DisconnectOutlined from "@ant-design/icons/DisconnectOutlined"; +import LinkOutlined from "@ant-design/icons/LinkOutlined"; import ReloadOutlined from "@ant-design/icons/ReloadOutlined"; import * as lib from "@clusterio/lib"; @@ -65,8 +67,28 @@ function renderInput(inputComponents: Map, def: lib.Fiel return `Unknown type ${def.type}`; } +/** Lets fields follow the value of another config, inherited values are locked until unlinking */ +export type ConfigInheritance = { + canInherit: (config: lib.Config, field: string) => boolean; + isInherited: (config: lib.Config, field: string, prop?: string) => boolean; + inherit: (field: string, prop?: string) => Promise; + templateValue: (field: string, prop?: string) => unknown; + notice?: (config: lib.Config) => React.ReactNode; +}; + +type Linking = Map; + +type LinkState = { + inheritance?: ConfigInheritance; + unlinking: Set; + setUnlinking: (unlinking: Set) => void; + linking: Linking; + setLinking: (linking: Linking) => void; +}; + type BaseConfigTreeProps = { ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig; + inheritance?: ConfigInheritance; retrieveConfig: () => Promise; setConfig: (fields: Record>) => Promise; id?: number; @@ -79,6 +101,8 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { let [changedFields, setChangedFields] = useState>(new Set()); let [errorFields, setErrorFields] = useState(new Map()); let [applying, setApplying] = useState(false); + let [unlinking, setUnlinking] = useState>(new Set()); + let [linking, setLinking] = useState(new Map()); const available = props.available ?? true; const control = useContext(ControlContext); @@ -136,6 +160,18 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { } } + async function applyChanges(fields: Record>) { + if (Object.keys(fields).length) { + await props.setConfig(fields); + } + for (const [field, prop] of linking.values()) { + await props.inheritance!.inherit(field, prop); + } + setChangedFields(new Set()); + setUnlinking(new Set()); + setLinking(new Map()); + } + const [propsMap, treeData] = computeTreeData( control, form, @@ -145,6 +181,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { setConfig, props.ConfigClass, onValuesChange, + { inheritance: props.inheritance, unlinking, setUnlinking, linking, setLinking }, ); return <> @@ -158,7 +195,8 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { onClick={async () => { setApplying(true); const fields: Record> = {}; - for (const field of changedFields) { + // Unlinked fields are sent even when unchanged so they stop following the template + for (const field of new Set([...changedFields, ...unlinking])) { const value = form.getFieldValue(field); if (propsMap.has(field)) { const [fieldName, prop] = propsMap.get(field)!; @@ -179,21 +217,26 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { } } + let applied = false; try { - await props.setConfig(fields); - setChangedFields(new Set()); + await applyChanges(fields); + applied = true; } catch (err: any) { notify(err.message, "error"); } - await updateConfig(); + const newConfig = await updateConfig(); + if (applied) { + // Linking can change other values + form.setFieldsValue(getInitialValues(newConfig, props)); + } setApplying(false); if (props.onApply) { props.onApply(); } }} - disabled={changedFields.size === 0} + disabled={changedFields.size === 0 && linking.size === 0 && unlinking.size === 0} > Apply @@ -202,6 +245,8 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { onClick={() => { form.resetFields(); setChangedFields(new Set()); + setUnlinking(new Set()); + setLinking(new Map()); setErrorFields(new Map()); }} > @@ -210,6 +255,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { + {props.inheritance?.notice?.(config)}
) => void, ConfigClass: BaseConfigTreeProps["ConfigClass"], onValuesChange: (changedValues: object) => void, + link: LinkState, ) { let restartTip = void, + ) => + } + ; + } + return { account.hasAnyPermission(...InstanceControlButtonPermissions) @@ -190,6 +236,12 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) { Connect via Steam {account.hasPermission("core.instance.load_scenario") && } + {canCreate && } {account.hasAnyPermission( "core.instance.export_data", "core.instance.extract_players", @@ -224,11 +276,16 @@ export default function InstanceViewPage() { return } + status={instance.isTemplate + ? Template + : } extra={} /> - + {instance.isTemplate + ? + : } + {!instance.isTemplate && <> {account.hasPermission("core.user.list") && } @@ -275,6 +332,7 @@ export default function InstanceViewPage() { account.hasPermission("core.instance.send_rcon") && } + } {account.hasPermission("core.instance.get_config") && } diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx index ed1498a2..7fbbd3b8 100644 --- a/packages/web_ui/src/components/InstancesPage.tsx +++ b/packages/web_ui/src/components/InstancesPage.tsx @@ -1,6 +1,7 @@ import React, { useContext, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Button, Checkbox, Form, Input, Modal, Select, Space, Tooltip, Typography } from "antd"; +import { Button, Checkbox, Form, Input, Modal, Select, Space, Table, Tooltip, Typography } from "antd"; +import type { ColumnType } from "antd/es/table/interface"; import * as lib from "@clusterio/lib"; @@ -13,13 +14,89 @@ import { useHosts } from "../model/host"; import { useInstanceConfig, useInstances } from "../model/instance"; import { useSaves } from "../model/saves"; import InstanceList from "./InstanceList"; +import Link from "./Link"; +import useRowNavigation from "../util/useRowNavigation"; import { notifyErrorHandler } from "../util/notify"; +import type { Control } from "../util/websocket"; type SaveMode = "none" | "copy" | "save_and_copy"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; -function CreateInstanceButton(props: { instances: ReturnType[0] }) { +function ConfigSourceFields(props: { + instances: ReturnType[0]; + templateId?: number | null; + source?: Readonly; + asTemplate: boolean; +}) { + const templates = [...props.instances.values()].filter(i => i.isTemplate); + const hasTemplate = typeof props.templateId === "number"; + return <> + {(templates.length > 0 || hasTemplate) && + + !i.isTemplate), + ].map(i => ({ value: i.id, label: i.name }))} + /> + + + {props.asTemplate && props.source && + Make {props.source.name} inherit from the new template + } + ; +} + +async function linkToTemplate(control: Control, source: lib.InstanceDetails | undefined, templateId: number) { + if (source) { + await control.send(new lib.InstanceConfigSetFieldRequest( + source.id, "instance.template_id", String(templateId), + )); + } +} + +function setTemplateFields( + config: lib.InstanceConfig, + fields: (keyof lib.InstanceConfigFields)[], + templateId: unknown, + asTemplate: boolean, +) { + if (typeof templateId === "number") { + config.set("instance.template_id", templateId); + fields.push("instance.template_id"); + } + if (asTemplate) { + config.set("instance.is_template", true); + fields.push("instance.is_template"); + } +} + +type CreateInstanceInitial = { + instanceClone?: number; + template?: number; + asTemplate?: boolean; + linkSource?: boolean; +}; + +export function CreateInstanceButton({ buttonText = "Create", buttonType = "primary", ...props }: { + instances: ReturnType[0]; + initial?: CreateInstanceInitial; + buttonText?: string; + buttonType?: "primary" | "default"; + buttonSize?: "small"; +}) { let control = useContext(ControlContext); let account = useAccount(); let navigate = useNavigate(); @@ -31,9 +108,11 @@ function CreateInstanceButton(props: { instances: ReturnType= 0 ? props.instances.get(cloneId) : undefined; const sourceRunning = source?.status === "running"; const activeSave = source ? [...saves.values()].find( @@ -42,6 +121,7 @@ function CreateInstanceButton(props: { instances: ReturnType | undefined; const sourceServerName = typeof sourceSettings?.name === "string" ? sourceSettings.name : undefined; + const copySettings = sourceSettings !== undefined && !asTemplate; useEffect(() => { if (open && sourceServerName !== undefined) { @@ -68,7 +148,8 @@ function CreateInstanceButton(props: { instances: ReturnType + >{buttonText} - {canAssign && + + + Create as template + + + {canAssign && !asTemplate && } - - - @@ -194,10 +286,59 @@ function CreateInstanceButton(props: { instances: ReturnType; } +function TemplateList(props: { instances: ReturnType[0] }) { + const account = useAccount(); + const rowNav = useRowNavigation(); + const all = [...props.instances.values()]; + const inheritors = (id: number) => all.filter(instance => instance.templateId === id).length; + const columns: ColumnType[] = [ + { + title: "Name", + dataIndex: "name", + defaultSortOrder: "ascend", + sorter: (a, b) => strcmp(a.name, b.name), + render: (_, template) => + {template.name} + , + }, + { + title: "Inherited by", + key: "inheritors", + render: (_, template) => `${inheritors(template.id)} instance(s)`, + sorter: (a, b) => inheritors(a.id) - inheritors(b.id), + }, + ]; + if (account.hasPermission("core.instance.create")) { + columns.push({ + key: "action", + align: "right", + // Clicks inside the modal bubble through the React tree to the row + render: (_, template) => e.stopPropagation()}> + + , + }); + } + return instance.isTemplate)} + rowKey={template => template.id} + pagination={false} + onRow={record => rowNav(`/instances/${record.id}/view`)} + />; +} + export default function InstancesPage() { let control = useContext(ControlContext); let account = useAccount(); let [instances] = useInstances(); + const plainInstances = new Map([...instances].filter(([, instance]) => !instance.isTemplate)); + const hasTemplates = plainInstances.size !== instances.size; return } /> - + + {hasTemplates && <> + Templates + + } ; } diff --git a/test/controller/ControlConnection.test.js b/test/controller/ControlConnection.test.js index 6311c2b1..845c56fb 100644 --- a/test/controller/ControlConnection.test.js +++ b/test/controller/ControlConnection.test.js @@ -156,6 +156,142 @@ describe("controller/src/ControlConnection", function() { assert.equal(clone.config.get("instance.name"), "clone"); assert.equal(clone.config.get("instance.assigned_host"), null); }); + + it("creates an instance inheriting from a template", async function() { + await create({ "instance.id": 4001, "instance.name": "template", "instance.is_template": true }); + mockController.instances.getMutable(4001).config.set("factorio.version", "2.0"); + + await create({ "instance.id": 4002, "instance.name": "child", "instance.template_id": 4001 }); + const child = mockController.instances.get(4002); + assert.equal(child.config.get("factorio.version"), "2.0"); + assert.equal(child.config.isInherited("factorio.version"), true); + assert.equal(child.config.isInherited("instance.name"), false); + }); + + it("rejects a template that is not a template", async function() { + await create({ "instance.id": 4001, "instance.name": "plain" }); + await assert.rejects( + create({ "instance.id": 4002, "instance.name": "child", "instance.template_id": 4001 }), + new lib.ValidationError("Instance plain is not a template"), + ); + assert.equal(mockController.instances.has(4002), false); + }); + }); + + describe("instance config set handlers", function() { + let mockController; + let connection; + + beforeEach(async function() { + mockController = new MockController(); + mockController.mockConfigEntries.set("controller.name", "Test"); + mockController.instances = new InstanceManager(new lib.SubscribableDatastore(), mockController); + mockController.instanceConfigUpdated = async () => {}; + connection = { + _controller: mockController, + checkStagedInheritance: ControlConnection.prototype.checkStagedInheritance, + }; + for (const [id, fields] of [ + [1, { "instance.is_template": true, "factorio.version": "2.0" }], + [2, { "instance.template_id": 1 }], + ]) { + const config = new lib.InstanceConfig("controller", { + "instance.id": id, "instance.name": `i${id}`, ...fields, + }); + if (fields["instance.template_id"]) { + mockController.instances.applyTemplate( + config, ["instance.id", "instance.name", "instance.template_id"], + ); + } + await mockController.instances.createInstance(config); + } + }); + + function call(handler, request) { + return ControlConnection.prototype[handler].call(connection, request); + } + async function create(config, cloneFromId) { + await ControlConnection.prototype.handleInstanceCreateRequest.call( + connection, new lib.InstanceCreateRequest(config, cloneFromId), + ); + } + const setField = (...args) => new lib.InstanceConfigSetFieldRequest(...args); + const setFields = (...args) => new lib.InstanceConfigSetRequest(...args); + + it("stops inheriting a field set to the inherited value", async function() { + await call("handleInstanceConfigSetFieldRequest", setField(2, "factorio.version", "2.0")); + assert.equal(mockController.instances.get(2).config.isInherited("factorio.version"), false); + }); + + it("stops inheriting a field set through set request", async function() { + await call("handleInstanceConfigSetRequest", setFields(2, { "factorio.version": "2.0" })); + assert.equal(mockController.instances.get(2).config.isInherited("factorio.version"), false); + }); + + it("stops inheriting a property set through set-prop", async function() { + await call( + "handleInstanceConfigSetPropRequest", + new lib.InstanceConfigSetPropRequest(2, "factorio.settings", "max_players", 0), + ); + const config = mockController.instances.get(2).config; + assert.equal(config.isInherited("factorio.settings", "max_players"), false); + assert.equal(config.isInherited("factorio.settings", "auto_pause"), true); + }); + + it("rejects template_id pointing at a non-template", async function() { + await assert.rejects( + call("handleInstanceConfigSetFieldRequest", setField(1, "instance.template_id", "2")), + new lib.ValidationError("Instance i2 is not a template"), + ); + assert.equal(mockController.instances.get(1).config.get("instance.template_id"), null); + }); + + it("rejects unmarking a template with inheritors through set request", async function() { + await assert.rejects( + call("handleInstanceConfigSetRequest", setFields(1, { "instance.is_template": "false" })), + new lib.ConflictError("Instance is the template of 1 other instance(s)"), + ); + assert.equal(mockController.instances.get(1).config.get("instance.is_template"), true); + }); + + it("stops inheriting only the properties set through set request", async function() { + await call("handleInstanceConfigSetRequest", setFields(2, { "factorio.settings": { max_players: 3 } })); + const config = mockController.instances.get(2).config; + assert.equal(config.isInherited("factorio.settings", "max_players"), false); + assert.equal(config.isInherited("factorio.settings", "auto_pause"), true); + }); + + it("inherits a property again", async function() { + await call("handleInstanceConfigSetPropRequest", new lib.InstanceConfigSetPropRequest( + 2, "factorio.settings", "max_players", 3, + )); + await call( + "handleInstanceConfigInheritFieldRequest", + new lib.InstanceConfigInheritFieldRequest(2, "factorio.settings", "max_players"), + ); + const config = mockController.instances.get(2).config; + assert.equal(config.get("factorio.settings").max_players, 0); + assert.equal(config.isInherited("factorio.settings", "max_players"), true); + }); + + it("creates a template from an existing instance", async function() { + await create({ "instance.id": 3, "instance.name": "copy", "instance.is_template": true }, 2); + const config = mockController.instances.get(3).config; + assert.equal(config.get("instance.is_template"), true); + assert.equal(config.get("instance.template_id"), 1); + assert.equal(config.get("factorio.version"), "2.0"); + }); + + it("inherits a field again", async function() { + await call("handleInstanceConfigSetFieldRequest", setField(2, "factorio.version", "2.1")); + await call( + "handleInstanceConfigInheritFieldRequest", + new lib.InstanceConfigInheritFieldRequest(2, "factorio.version"), + ); + const config = mockController.instances.get(2).config; + assert.equal(config.get("factorio.version"), "2.0"); + assert.equal(config.isInherited("factorio.version"), true); + }); }); describe("close cleanup", function() { diff --git a/test/controller/Controller.js b/test/controller/Controller.js index 732feceb..9a5908c0 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -34,6 +34,10 @@ class MockInstanceConfig extends EventEmitter { this._data.set(field, value); this.emit("fieldChanged", field, value, prev); } + + isInherited() { + return false; + } } describe("controller/src/Controller", function() { diff --git a/test/controller/InstanceManager.test.js b/test/controller/InstanceManager.test.js index dc254629..25b9e839 100644 --- a/test/controller/InstanceManager.test.js +++ b/test/controller/InstanceManager.test.js @@ -393,4 +393,253 @@ describe("controller/InstanceManager", function () { assert.equal(sentRequest.instanceId, 1); }); }); + + describe("inheritance", function () { + /** @type {InstanceRecord} */ + let template; + /** @type {InstanceRecord} */ + let child; + let updated; + + async function createInheritor(id, templateId, fields = {}) { + const config = new lib.InstanceConfig("controller", { + "instance.id": id, "instance.name": `Instance ${id}`, "instance.template_id": templateId, ...fields, + }); + instances.applyTemplate( + config, ["instance.id", "instance.name", "instance.template_id", ...Object.keys(fields)] + ); + return await instances.createInstance(config); + } + + beforeEach(async function () { + template = await instances.createInstance(new lib.InstanceConfig("controller", { + "instance.id": 10, + "instance.name": "Template", + "instance.is_template": true, + "factorio.version": "2.0", + "factorio.game_port": 34190, + "factorio.settings": { name: "Template server", max_players: 5 }, + })); + child = await createInheritor(11, 10, { "factorio.game_port": 34197 }); + updated = []; + controller.instanceConfigUpdated = async (inst) => { updated.push(inst.id); }; + }); + + describe(".applyTemplate()", function () { + it("should copy template values and mark them inherited", function () { + assert.equal(child.config.get("factorio.version"), "2.0"); + assert.equal(child.config.isInherited("factorio.version"), true); + assert.equal(child.config.get("factorio.game_port"), 34197); + assert.equal(child.config.isInherited("factorio.game_port"), false); + assert.equal(child.config.isInherited("instance.name"), false); + }); + it("should never inherit ports or the rcon password", function () { + template.config.set("factorio.rcon_password", "secret"); + const config = new lib.InstanceConfig("controller", { "instance.id": 12, "instance.template_id": 10 }); + instances.applyTemplate(config, ["instance.id", "instance.template_id"]); + assert.equal(config.get("factorio.game_port"), null); + assert.equal(config.isInherited("factorio.game_port"), false); + assert.equal(config.get("factorio.rcon_password"), null); + assert.equal(config.isInherited("factorio.rcon_password"), false); + }); + it("should inherit factorio.settings per property with its own server name", function () { + const settings = child.config.get("factorio.settings"); + assert.equal(settings.max_players, 5); + assert.equal(settings.name, "TestController - Instance 11"); + assert.equal(child.config.isInherited("factorio.settings", "max_players"), true); + assert.equal(child.config.isInherited("factorio.settings", "name"), false); + }); + it("should keep properties given by the creator as overrides", async function () { + const other = await createInheritor(12, 10, { "factorio.settings": { max_players: 8 } }); + assert.equal(other.config.get("factorio.settings").max_players, 8); + assert.equal(other.config.isInherited("factorio.settings", "max_players"), false); + assert.equal(other.config.isInherited("factorio.settings", "auto_pause"), true); + }); + }); + + describe("template changes", function () { + it("should propagate to inherited fields and send the config to the host", function () { + template.config.set("factorio.version", "2.1"); + assert.equal(child.config.get("factorio.version"), "2.1"); + assert.equal(child.config.isInherited("factorio.version"), true); + assert.deepEqual(updated, [11]); + }); + it("should mark the inheritor as requiring restart", function () { + child.config.restartRequired = false; + template.config.set("factorio.version", "2.1"); + assert.equal(child.config.restartRequired, true); + }); + it("should not touch fields the inheritor has set", function () { + child.config.set("factorio.version", "2.1"); + template.config.set("factorio.version", "2.2"); + assert.equal(child.config.get("factorio.version"), "2.1"); + }); + it("should merge object properties and keep overrides", function () { + template.config.setProp("factorio.settings", "max_players", 10); + template.config.setProp("factorio.settings", "name", "Renamed"); + template.config.setProp("factorio.settings", "auto_pause", true); + const settings = child.config.get("factorio.settings"); + assert.equal(settings.max_players, 10); + assert.equal(settings.auto_pause, true); + assert.equal(settings.name, "TestController - Instance 11"); + }); + it("should propagate through several levels", async function () { + child.config.set("instance.is_template", true); + const grandchild = await createInheritor(12, 11); + template.config.setProp("factorio.settings", "max_players", 10); + assert.equal(grandchild.config.get("factorio.settings").max_players, 10); + assert.deepEqual(updated.sort(), [11, 12]); + }); + }); + + describe("inheritor changes", function () { + it("should stop inheriting a field that is changed", function () { + child.config.set("factorio.version", "2.1"); + assert.equal(child.config.isInherited("factorio.version"), false); + }); + it("should only override the properties changed in an object", function () { + child.config.setProp("factorio.settings", "max_players", 3); + assert.equal(child.config.isInherited("factorio.settings", "max_players"), false); + assert.equal(child.config.isInherited("factorio.settings", "auto_pause"), true); + }); + it("should restore the template value of a removed property", function () { + child.config.setProp("factorio.settings", "max_players", 3); + child.config.setProp("factorio.settings", "max_players"); + assert.equal(child.config.get("factorio.settings").max_players, 5); + assert.equal(child.config.isInherited("factorio.settings", "max_players"), true); + }); + it("should stop inheriting with overrideField() even if the value is unchanged", function () { + instances.overrideField(child, "factorio.version"); + instances.overrideField(child, "factorio.settings", ["auto_pause"]); + assert.equal(child.config.isInherited("factorio.version"), false); + assert.equal(child.config.isInherited("factorio.settings", "auto_pause"), false); + }); + }); + + describe(".inheritField()", function () { + it("should restore the template value", function () { + child.config.set("factorio.version", "2.1"); + instances.inheritField(11, "factorio.version"); + assert.equal(child.config.get("factorio.version"), "2.0"); + assert.equal(child.config.isInherited("factorio.version"), true); + }); + it("should restore a single property", function () { + child.config.setProp("factorio.settings", "max_players", 3); + instances.inheritField(11, "factorio.settings", "max_players"); + assert.equal(child.config.get("factorio.settings").max_players, 5); + assert.equal(child.config.get("factorio.settings").name, "TestController - Instance 11"); + }); + it("should restore all properties", function () { + instances.inheritField(11, "factorio.settings"); + assert.equal(child.config.get("factorio.settings").name, "Template server"); + }); + it("should throw if the instance has no template", function () { + assert.throws( + () => instances.inheritField(1, "factorio.version"), + new lib.ValidationError("Instance does not have a template"), + ); + }); + it("should throw for fields that cannot be inherited", function () { + assert.throws( + () => instances.inheritField(11, "instance.name"), + new lib.ValidationError("Field instance.name cannot be inherited"), + ); + assert.throws( + () => instances.inheritField(11, "factorio.rcon_port"), + new lib.ValidationError("Field factorio.rcon_port cannot be inherited"), + ); + }); + }); + + describe("template_id changes", function () { + it("should inherit fields equal to the template or the default", function () { + instance.config.set("factorio.version", "2.0"); + instance.config.set("factorio.verbose_logging", true); + template.config.set("instance.auto_start", true); + instance.config.set("instance.template_id", 10); + assert.equal(instance.config.isInherited("factorio.version"), true); + assert.equal(instance.config.isInherited("instance.auto_start"), true); + assert.equal(instance.config.get("instance.auto_start"), true); + assert.equal(instance.config.isInherited("factorio.verbose_logging"), false); + }); + it("should inherit object properties equal to the template or the default", function () { + instance.config.setProp("factorio.settings", "max_players", 0); + instance.config.setProp("factorio.settings", "game_password", "secret"); + instance.config.set("instance.template_id", 10); + const settings = instance.config.get("factorio.settings"); + assert.equal(settings.max_players, 5); + assert.equal(settings.game_password, "secret"); + assert.equal(instance.config.isInherited("factorio.settings", "game_password"), false); + assert.equal(instance.config.isInherited("factorio.settings", "max_players"), true); + }); + it("should keep the server name of the instance", function () { + instance.config.setProp("factorio.settings", "name", "Template server"); + instance.config.set("instance.template_id", 10); + assert.equal(instance.config.isInherited("factorio.settings", "name"), false); + }); + it("should clear inherited fields when the template is removed", function () { + child.config.set("instance.template_id", null); + assert.deepEqual(child.config.get("instance.inherited_fields"), {}); + assert.equal(child.config.get("factorio.version"), "2.0"); + }); + }); + + describe(".checkInheritance()", function () { + it("should accept a valid template", function () { + instances.checkInheritance(instance.config, false, 10); + }); + it("should reject a missing template", function () { + assert.throws( + () => instances.checkInheritance(instance.config, false, 99), + new lib.NotFoundError("Instance with ID 99 does not exist"), + ); + }); + it("should reject an instance that is not a template", function () { + assert.throws( + () => instances.checkInheritance(instance.config, false, 11), + new lib.ValidationError("Instance Instance 11 is not a template"), + ); + }); + it("should reject cycles", function () { + child.config.set("instance.is_template", true); + assert.throws( + () => instances.checkInheritance(template.config, true, 11), + new lib.ValidationError("Templates cannot inherit from themselves"), + ); + }); + it("should reject making an assigned instance a template", function () { + instance.config.set("instance.assigned_host", 5); + assert.throws( + () => instances.checkInheritance(instance.config, true, null), + new lib.ConflictError("Instance must be unassigned before it can be made a template"), + ); + }); + it("should reject unmarking a template that has inheritors", function () { + assert.throws( + () => instances.checkInheritance(template.config, false, null), + new lib.ConflictError("Instance is the template of 1 other instance(s)"), + ); + }); + }); + + it("should not assign a template to a host", async function () { + controller.wsServer.hostConnections.set(5, { send: async () => {}, connector: { closing: false } }); + await assert.rejects( + instances.assignInstance(10, 5), + new lib.ValidationError("Templates cannot be assigned to a host"), + ); + }); + + it("should not delete a template that has inheritors", async function () { + await assert.rejects( + instances.deleteInstance(10), + new lib.ConflictError("Instance is the template of 1 other instance(s)"), + ); + }); + + it("should report templates in instance details", function () { + assert.equal(template.toInstanceDetails().isTemplate, true); + assert.equal(child.toInstanceDetails().isTemplate, false); + }); + }); }); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 54dcd29b..db4c34d8 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -971,6 +971,76 @@ describe("Integration of Clusterio", function() { }); }); + describe("instance template inheritance", function() { + after(async function() { + for (const id of [4403, 4402, 4401, 4400]) { + await getControl().send(new lib.InstanceDeleteRequest(id)).catch(() => {}); + } + }); + it("creates a template and an instance inheriting from it", async function() { + await execCtl("instance create inheritTemplate --id 4400 --as-template"); + await execCtl("instance config set inheritTemplate factorio.version 2.0"); + await execCtl("instance create inheritChild --id 4401 --template inheritTemplate"); + const config = await getControl().send(new lib.InstanceConfigGetRequest(4401)); + assert.equal(config["instance.template_id"], 4400); + assert.equal(config["factorio.version"], "2.0"); + assert.equal(config["instance.inherited_fields"]["factorio.version"], true); + assert.equal((await getInstances()).get(4400).isTemplate, true); + }); + it("propagates template changes per property", async function() { + await execCtl("instance config set inheritTemplate factorio.version 2.1"); + await execCtl("instance config set-prop inheritChild factorio.settings max_players 3"); + await execCtl("instance config set-prop inheritTemplate factorio.settings max_players 7"); + await execCtl("instance config set-prop inheritTemplate factorio.settings afk_autokick_interval 9"); + const config = await getControl().send(new lib.InstanceConfigGetRequest(4401)); + assert.equal(config["factorio.version"], "2.1"); + assert.equal(config["factorio.settings"].max_players, 3); + assert.equal(config["factorio.settings"].afk_autokick_interval, 9); + }); + it("creates a template from an existing instance", async function() { + await execCtl("instance create inheritCopy --id 4403 --as-template --from 4401"); + const config = await getControl().send(new lib.InstanceConfigGetRequest(4403)); + assert.equal(config["instance.is_template"], true); + assert.equal(config["factorio.settings"].max_players, 3); + }); + it("rejects inheriting from an instance that is not a template", async function() { + await assert.rejects( + execCtl("instance create inheritBad --id 4402 --template inheritChild"), + new lib.ValidationError("Instance inheritChild is not a template"), + ); + }); + it("rejects assigning a template to a host", async function() { + await assert.rejects( + execCtl(`instance assign inheritTemplate ${hostId}`), + new lib.ValidationError("Templates cannot be assigned to a host"), + ); + }); + describe("instance list --templates", function() { + it("runs", async function() { + await execCtl("instance list --templates"); + }); + }); + describe("instance config list", function() { + it("runs on an inheriting instance", async function() { + await execCtl("instance config list inheritChild"); + }); + }); + describe("instance config inherit", function() { + it("runs", async function() { + await execCtl("instance config set inheritChild factorio.version 2.0"); + let config = await getControl().send(new lib.InstanceConfigGetRequest(4401)); + assert.equal(config["instance.inherited_fields"]["factorio.version"], undefined); + + await execCtl("instance config inherit inheritChild factorio.version"); + await execCtl("instance config inherit inheritChild factorio.settings max_players"); + config = await getControl().send(new lib.InstanceConfigGetRequest(4401)); + assert.equal(config["factorio.version"], "2.1"); + assert.equal(config["instance.inherited_fields"]["factorio.version"], true); + assert.equal(config["factorio.settings"].max_players, 7); + }); + }); + }); + describe("instance assign", function() { it("creates the instance files", async function() { await execCtl(`instance assign test ${hostId}`); diff --git a/test/integration/routing.js b/test/integration/routing.js index 19c73dfa..4bfc7834 100644 --- a/test/integration/routing.js +++ b/test/integration/routing.js @@ -41,7 +41,8 @@ function connectInstance(controller, host, instanceId) { instanceConfig.set("instance.id", instanceId); instanceConfig.set("instance.assigned_host", host.config.get("host.id")); host.assignedInstances.set(instanceId, { path: "invalid", config: instanceConfig }); - controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped")); + const controllerConfig = new lib.InstanceConfig("controller", instanceConfig.toRemote("controller")); + controller.instances.records.set(new InstanceRecord(controllerConfig, "stopped")); const instance = new Instance(host, instanceSide, "invalid", instanceConfig, new MockServer()); const instanceConnection = new InstanceConnection(hostSide, host, instance); host.instanceConnections.set(instanceId, instanceConnection); diff --git a/test/lib/config/definitions.js b/test/lib/config/definitions.js index a406f579..a20f4354 100644 --- a/test/lib/config/definitions.js +++ b/test/lib/config/definitions.js @@ -4,6 +4,59 @@ import * as lib from "@clusterio/lib"; describe("lib/config/definitions", function() { + describe("InstanceConfig", function() { + describe("isInheritable()", function() { + it("should allow regular fields", function() { + assert.equal(lib.InstanceConfig.isInheritable("factorio.version"), true); + assert.equal(lib.InstanceConfig.isInheritable("instance.auto_start"), true); + }); + it("should reject identity, hidden, host only and unknown fields", function() { + for (const name of [ + "instance.name", "instance.id", "instance.assigned_host", "instance.is_template", + "instance.template_id", "instance.inherited_fields", "factorio.host_assigned_game_port", + "factorio.game_port", "factorio.rcon_port", "factorio.rcon_password", "factorio.lua_udp_port", + "no.field", + ]) { + assert.equal(lib.InstanceConfig.isInheritable(name), false, name); + } + }); + }); + describe(".isInherited()", function() { + it("should only report fields as inherited when a template is set", function() { + const config = new lib.InstanceConfig("controller", { + "instance.inherited_fields": { "factorio.version": true }, + }); + assert.equal(config.isInherited("factorio.version"), false); + config.set("instance.template_id", 1); + assert.equal(config.isInherited("factorio.version"), true); + assert.equal(config.isInherited("factorio.game_port"), false); + }); + it("should report object properties not overridden as inherited", function() { + const config = new lib.InstanceConfig("controller", { + "instance.template_id": 1, + "instance.inherited_fields": { "factorio.settings": ["name"] }, + }); + assert.equal(config.isInherited("factorio.settings"), true); + assert.equal(config.isInherited("factorio.settings", "max_players"), true); + assert.equal(config.isInherited("factorio.settings", "name"), false); + }); + }); + it("should not send inheritance fields to the host", function() { + const config = new lib.InstanceConfig("controller", { "instance.template_id": 1 }); + const remote = config.toRemote("host"); + for (const name of ["instance.is_template", "instance.template_id", "instance.inherited_fields"]) { + assert(!Object.hasOwn(remote, name), name); + } + }); + it("should not let control write inherited_fields", function() { + const config = new lib.InstanceConfig("controller"); + assert.throws( + () => config.set("instance.inherited_fields", { "factorio.version": true }, "control"), + new lib.InvalidAccessError("Field 'instance.inherited_fields' is not accessible from control"), + ); + }); + }); + describe("addPluginConfigFields()", function() { after(function() { delete lib.CtlConfig.fieldDefinitions["ctl_test.field"]; diff --git a/test/lib/data/InstanceDetails.test.js b/test/lib/data/InstanceDetails.test.js index 95058653..2cec180c 100644 --- a/test/lib/data/InstanceDetails.test.js +++ b/test/lib/data/InstanceDetails.test.js @@ -16,6 +16,8 @@ describe("lib/data/InstanceDetails", function () { [undefined, 1234], // startedAtMs [undefined, 9999], // updatedAtMs [undefined, true], // excludeFromStartAll + [undefined, true], // isTemplate + [undefined, 7], // templateId )); });