Add base instances for instance config inheritance

Instances can be marked as a base with instance.is_base and other
instances inherit config values from it through instance.base_id.
The controller copies base values into inheriting configs and keeps
track of inherited fields in the hidden instance.inherited_fields, so
hosts receive resolved configs and need no changes. Setting a field
stops inheriting it, `instance config inherit` undoes that.

Bases cannot be assigned to a host, are hidden from `instance list`
unless --bases is given, and cannot be deleted or unmarked while
other instances inherit from them. Chains of bases are allowed and
cycles are rejected.

Part of #200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-11-02 11:54:21 +00:00
1 parent 024d7b427a
commit 01cf789f5d
16 files changed
+769 -26

No files matched your search

+22
View File
@@ -521,6 +521,28 @@ This allows you to prevent certain instances from being started when using the b
Defaults to false.
### instance.is_base
If enabled this instance is a base other instances can inherit config values from.
Base instances cannot be assigned to a host and therefore never start.
Hidden from `instance list` in clusterioctl unless `--bases` is passed.
An instance cannot stop being a base while other instances inherit from it.
Defaults to false.
### instance.base_id
ID of the base instance to inherit config values from.
Inherited fields take their value from the base and follow it when it changes, setting a field on this instance stops inheriting it.
When set on an existing instance only fields with the same value as the base become inherited.
`instance.name`, `instance.is_base`, `instance.base_id` and hidden fields are never inherited.
A base may itself inherit from another base, but not from itself through a chain of bases.
The fields currently inherited are stored in the hidden `instance.inherited_fields` field.
Defaults to null meaning no base.
### factorio.version
Version of Factorio to use for this instance.
+12 -5
View File
@@ -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 [--bases]
List instances.
Base instances are only listed if `--bases` is passed.
### Create instance
ctl> instance create <name> [--id <instance id>]
ctl> instance create <name> [--id <instance id>] [--from <instance id>] [--base <instance>] [--is-base]
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. `--base` makes the new instance inherit all config values from the given base instance. `--is-base` creates the instance as a base, see `instance.is_base` in the [configuration docs](configuration.md).
### Kill instance
@@ -120,7 +121,7 @@ Most notably Clusterio records wall clock time while the save time is recorded i
ctl> instance config list <instance>
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 base instance are marked with `(inherited)`.
#### 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 <instance> <field>
Make a field in the instance config take its value from the base instance again, undoing a previous `set`. `instance` is the instance to update, `field` is the field to inherit.
#### Edit instance config
ctl> instance config edit <instance> [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
+36 -2
View File
@@ -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");
if (Object.hasOwn(request.config, "instance.base_id")) {
this._controller.instances.checkInheritance(
instanceConfig, instanceConfig.get("instance.is_base"), instanceConfig.get("instance.base_id")
);
this._controller.instances.applyBase(instanceConfig, Object.keys(request.config));
}
await this._controller.instances.createInstance(instanceConfig);
}
@@ -355,17 +363,36 @@ export default class ControlConnection extends BaseConnection {
}
}
this.checkStagedInheritance(instance);
instance.config.commitStaging();
this._controller.instances.overrideFields(instance, Object.keys(request.fields));
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_base"), staged.get("instance.base_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_base", "instance.base_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.overrideFields(instance, [field]);
await this._controller.instanceConfigUpdated(instance);
}
@@ -374,9 +401,16 @@ 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");
this._controller.instances.overrideFields(instance, [field]);
await this._controller.instanceConfigUpdated(instance);
}
async handleInstanceConfigInheritFieldRequest(request: lib.InstanceConfigInheritFieldRequest) {
ControlConnection.validateInstanceConfigSetField(request.field);
this._controller.instances.inheritField(request.instanceId, request.field);
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);
}
+200 -7
View File
@@ -1,4 +1,6 @@
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";
@@ -24,6 +26,9 @@ export default class InstanceManager {
non_blocking_saving: false,
} as const;
/** Non-zero while base values are being copied into inheriting instances */
private _inheriting = 0;
constructor(
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
private readonly _controller: Controller,
@@ -106,11 +111,190 @@ export default class InstanceManager {
*/
addInstanceHooks(instance: InstanceRecord): void {
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
if (field === "instance.base_id") {
this._rebase(instance);
} else if (!this._inheriting && instance.config.isInherited(field)) {
this._setInherited(instance, [field], false);
}
this.records.set(instance);
this._notifyConfigFieldChanged(instance, field, curr, prev);
this._propagate(instance, field, curr);
});
}
/* ------------------------------------------------------------------------- */
/* Inheritance */
/* ------------------------------------------------------------------------- */
/**
* Get the instances that inherit directly from the given base.
*
* @param baseId - ID of the base instance.
* @returns Instances with `instance.base_id` set to baseId.
*/
inheritorsOf(baseId: number): InstanceRecord[] {
return [...this.records.values()].filter(
instance => instance.config.get("instance.base_id") === baseId
);
}
/**
* Check that new values for `instance.is_base` and `instance.base_id` are allowed.
*
* @param config - Current config of the instance.
* @param isBase - New value for `instance.is_base`.
* @param baseId - New value for `instance.base_id`.
* @throws {module:lib.ValidationError} if the base is not a base or would create a cycle.
* @throws {module:lib.ConflictError} if the instance is assigned or still has inheritors.
*/
checkInheritance(config: lib.InstanceConfig, isBase: boolean, baseId: number | null): void {
const instanceId = config.get("instance.id");
if (isBase && config.get("instance.assigned_host") !== null) {
throw new lib.ConflictError("Instance must be unassigned before it can be made a base");
}
if (!isBase && config.get("instance.is_base")) {
const count = this.inheritorsOf(instanceId).length;
if (count) {
throw new lib.ConflictError(`Instance is the base of ${count} other instance(s)`);
}
}
if (baseId === null) {
return;
}
const base = this.records.get(baseId);
if (!base) {
throw new lib.NotFoundError(`Instance with ID ${baseId} does not exist`);
}
if (!base.config.get("instance.is_base")) {
throw new lib.ValidationError(`Instance ${base.config.get("instance.name")} is not a base`);
}
const visited = new Set<number>();
for (let id: number | null = baseId; id !== null && !visited.has(id);) {
if (id === instanceId) {
throw new lib.ValidationError("Base instances cannot inherit from themselves");
}
visited.add(id);
id = this.records.get(id)?.config.get("instance.base_id") ?? null;
}
}
/**
* Copy all inheritable values from the base set in `instance.base_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 keep - Fields to keep the current value of.
*/
applyBase(config: lib.InstanceConfig, keep: string[] = []): void {
const base = this.records.get(config.get("instance.base_id")!);
if (!base) {
return;
}
const values: Record<string, any> = {};
const inherited: Record<string, true> = {};
for (const name of Object.keys(lib.InstanceConfig.fieldDefinitions)) {
if (lib.InstanceConfig.isInheritable(name) && !keep.includes(name)) {
values[name] = structuredClone(base.config.get(name as keyof lib.InstanceConfigFields));
inherited[name] = true;
}
}
config.update(values, false);
config.set("instance.inherited_fields", inherited);
}
/**
* Make a field take its value from the base instance again.
*
* @param instanceId - ID of the instance to update.
* @param field - Name of the field to inherit.
* @throws {module:lib.ValidationError} if the instance has no base or the field cannot be inherited.
*/
inheritField(instanceId: number, field: string): void {
const instance = this.getForRequest(instanceId);
const baseId = instance.config.get("instance.base_id");
const base = baseId !== null ? this.records.get(baseId) : undefined;
if (!base) {
throw new lib.ValidationError("Instance does not have a base");
}
if (!lib.InstanceConfig.isInheritable(field)) {
throw new lib.ValidationError(`Field ${field} cannot be inherited`);
}
this._setInherited(instance, [field], true);
this._inheriting += 1;
try {
instance.config.update({
[field]: structuredClone(base.config.get(field as keyof lib.InstanceConfigFields)),
}, true);
} finally {
this._inheriting -= 1;
}
}
/**
* Stop inheriting the given fields, used when a value is explicitly set.
*
* @param instance - Instance the fields were set on.
* @param fields - Names of fields set.
*/
overrideFields(instance: InstanceRecord, fields: string[]): void {
const inherited = fields.filter(field => instance.config.isInherited(field));
if (inherited.length) {
this._setInherited(instance, inherited, false);
}
}
private _setInherited(instance: InstanceRecord, fields: string[], inherited: boolean): void {
const value = { ...instance.config.get("instance.inherited_fields") };
for (const field of fields) {
if (inherited) {
value[field] = true;
} else {
delete value[field];
}
}
instance.config.set("instance.inherited_fields", value);
}
/** Recompute inherited fields after base_id changed, values equal to the base are inherited */
private _rebase(instance: InstanceRecord): void {
const baseId = instance.config.get("instance.base_id");
const base = baseId !== null ? this.records.get(baseId) : undefined;
const inherited: Record<string, true> = {};
if (base) {
for (const name of Object.keys(lib.InstanceConfig.fieldDefinitions)) {
const field = name as keyof lib.InstanceConfigFields;
if (
lib.InstanceConfig.isInheritable(name)
&& isDeepStrictEqual(instance.config.get(field), base.config.get(field))
) {
inherited[name] = true;
}
}
}
instance.config.set("instance.inherited_fields", inherited);
}
/** Copy a changed value from a base to the instances inheriting it */
private _propagate(base: InstanceRecord, field: string, value: unknown): void {
if (!lib.InstanceConfig.isInheritable(field)) {
return;
}
for (const instance of this.inheritorsOf(base.id)) {
if (!instance.config.isInherited(field)) {
continue;
}
this._inheriting += 1;
try {
instance.config.update({ [field]: structuredClone(value) as any }, true);
} finally {
this._inheriting -= 1;
}
this._controller.instanceConfigUpdated(instance).catch(err => {
logger.error(`Failed to send config of instance ${instance.id} to its host:\n${err.stack}`);
});
}
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
@@ -136,13 +320,15 @@ export default class InstanceManager {
throw new lib.ConflictError(`Instance with ID ${instanceId} already exists`);
}
const controllerName = this._controller.config.get("controller.name");
instanceConfig.set("factorio.settings", {
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
description: `Clusterio instance for ${controllerName}`,
...InstanceManager.DefaultFactorioSettings,
...instanceConfig.get("factorio.settings"),
});
if (!instanceConfig.isInherited("factorio.settings")) {
const controllerName = this._controller.config.get("controller.name");
instanceConfig.set("factorio.settings", {
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
description: `Clusterio instance for ${controllerName}`,
...InstanceManager.DefaultFactorioSettings,
...instanceConfig.get("factorio.settings"),
});
}
const instance = new InstanceRecord(instanceConfig, "unassigned");
this.addInstanceHooks(instance);
@@ -186,6 +372,9 @@ export default class InstanceManager {
if (currentAssignedHost === hostId) {
return;
}
if (hostId !== null && instance.config.get("instance.is_base")) {
throw new lib.ValidationError("Base instances cannot be assigned to a host");
}
let newHostConnection: HostConnection | undefined;
if (hostId !== null) {
@@ -252,6 +441,10 @@ export default class InstanceManager {
async deleteInstance(instanceId: number): Promise<void> {
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 base of ${inheritors} other instance(s)`);
}
if (hostId !== null) {
await this._controller.sendTo(
@@ -83,6 +83,7 @@ export default class InstanceRecord {
this.startedAtMs,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
this.config.get("instance.is_base"),
);
}
+44 -9
View File
@@ -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({
"bases": { describe: "Include base instances", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { bases: boolean }, ctl: Ctl) {
let list = await ctl.send(new lib.InstanceDetailsListRequest());
print(asTable(list));
print(asTable(list.filter(instance => args.bases || !instance.isBase)));
},
}));
@@ -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" },
"base": { type: "string", nargs: 1, describe: "Base instance to inherit config from" },
"is-base": { type: "boolean", nargs: 0, describe: "Create the instance as a base" },
});
}],
handler: async function(args: { name: string, id?: number, from?: number, }, ctl: Ctl) {
handler: async function(
args: { name: string, id?: number, from?: number, base?: string, isBase?: 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.base !== undefined) {
instanceConfig.set("instance.base_id", await lib.resolveInstance(ctl, args.base));
fields.push("instance.base_id");
}
if (args.isBase) {
instanceConfig.set("instance.is_base", true);
fields.push("instance.is_base");
}
await ctl.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
instanceConfig.toRemote("controller", fields),
args.from
));
},
@@ -61,13 +77,30 @@ 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.base_id"] !== null
? config["instance.inherited_fields"] as Record<string, true> ?? {}
: {};
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
if (name === "instance.inherited_fields") {
continue;
}
print(`${name} ${JSON.stringify(value)}${inherited[name] ? " (inherited)" : ""}`);
}
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["inherit <instance> <field>", "Make field in instance config use the value of its base", (yargs) => {
yargs.positional("instance", { describe: "Instance to update config on", type: "string" });
yargs.positional("field", { describe: "Field to inherit", type: "string" });
}],
handler: async function(args: { instance: string, field: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceConfigInheritFieldRequest(instanceId, args.field));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
@@ -170,8 +203,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 base
if (index in final && final[index] !== original[index]) {
try {
await ctl.send(new lib.InstanceConfigSetFieldRequest(
instanceId,
+44
View File
@@ -488,6 +488,9 @@ export interface InstanceConfigFields {
"instance.assigned_host": number | null;
"instance.auto_start": boolean;
"instance.exclude_from_start_all": boolean;
"instance.is_base": boolean;
"instance.base_id": number | null;
"instance.inherited_fields": Record<string, true>;
[key: `${string}.load_plugin`]: boolean;
[key: `${string}.version`]: string;
@@ -539,6 +542,23 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
return config;
}
/** Fields that are never inherited from a base instance */
static uninheritableFields = ["instance.name", "instance.is_base", "instance.base_id"];
/** True if the field can take its value from a base instance */
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 currently takes its value from the base instance */
isInherited(name: string) {
return this.get("instance.base_id") !== null && Boolean(this.get("instance.inherited_fields")[name]);
}
static fieldDefinitions: classes.ConfigDefs<InstanceConfigFields> = {
"instance.name": {
type: "string",
@@ -566,6 +586,30 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "boolean",
initialValue: false,
},
"instance.is_base": {
description:
"Mark this instance as a base other instances can inherit config from. " +
"Bases cannot be assigned to a host or started.",
access: ["controller", "control"],
type: "boolean",
initialValue: false,
},
"instance.base_id": {
title: "Base Instance",
description: "ID of the base instance to inherit config values from",
access: ["controller", "control"],
type: "number",
optional: true,
// Checked by the controller
},
"instance.inherited_fields": {
description: "Fields whose value comes from the base 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 " +
+8 -2
View File
@@ -41,7 +41,8 @@ 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 isBase = false,
) {}
get isDeleted() {
@@ -61,6 +62,7 @@ export default class InstanceDetails {
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"excludeFromStartAll": Type.Optional(Type.Boolean()),
"isBase": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -73,7 +75,8 @@ export default class InstanceDetails {
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
json.excludeFromStartAll
json.excludeFromStartAll,
json.isBase,
);
}
@@ -102,6 +105,9 @@ export default class InstanceDetails {
if (this.excludeFromStartAll !== false) {
json.excludeFromStartAll = this.excludeFromStartAll;
}
if (this.isBase !== false) {
json.isBase = this.isBase;
}
return json;
}
@@ -176,6 +176,28 @@ 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,
) { }
static jsonSchema = Type.Object({
"instanceId": Type.Integer(),
"field": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.instanceId, json.field);
}
}
export class InstanceAssignRequest {
declare ["constructor"]: typeof InstanceAssignRequest;
static type = "request" as const;
+1
View File
@@ -71,6 +71,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
instance.InstanceConfigSetRequest,
instance.InstanceConfigSetFieldRequest,
instance.InstanceConfigSetPropRequest,
instance.InstanceConfigInheritFieldRequest,
instance.InstanceAssignRequest,
instance.InstanceMetricsRequest,
instance.InstanceStartRequest,
+99
View File
@@ -156,6 +156,105 @@ 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 base", async function() {
await create({ "instance.id": 4001, "instance.name": "base", "instance.is_base": true });
mockController.instances.getMutable(4001).config.set("factorio.version", "2.0");
await create({ "instance.id": 4002, "instance.name": "child", "instance.base_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 base that is not a base", async function() {
await create({ "instance.id": 4001, "instance.name": "plain" });
await assert.rejects(
create({ "instance.id": 4002, "instance.name": "child", "instance.base_id": 4001 }),
new lib.ValidationError("Instance plain is not a base"),
);
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_base": true, "factorio.version": "2.0" }],
[2, { "instance.base_id": 1 }],
]) {
const config = new lib.InstanceConfig("controller", {
"instance.id": id, "instance.name": `i${id}`, ...fields,
});
if (fields["instance.base_id"]) {
mockController.instances.applyBase(config, ["instance.id", "instance.name", "instance.base_id"]);
}
await mockController.instances.createInstance(config);
}
});
function call(handler, request) {
return ControlConnection.prototype[handler].call(connection, request);
}
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 field changed through set-prop", async function() {
await call(
"handleInstanceConfigSetPropRequest",
new lib.InstanceConfigSetPropRequest(2, "factorio.settings", "max_players", 0),
);
assert.equal(mockController.instances.get(2).config.isInherited("factorio.settings"), false);
});
it("rejects base_id pointing at a non-base", async function() {
await assert.rejects(
call("handleInstanceConfigSetFieldRequest", setField(1, "instance.base_id", "2")),
new lib.ValidationError("Instance i2 is not a base"),
);
assert.equal(mockController.instances.get(1).config.get("instance.base_id"), null);
});
it("rejects unmarking a base with inheritors through set request", async function() {
await assert.rejects(
call("handleInstanceConfigSetRequest", setFields(1, { "instance.is_base": "false" })),
new lib.ConflictError("Instance is the base of 1 other instance(s)"),
);
assert.equal(mockController.instances.get(1).config.get("instance.is_base"), true);
});
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() {
+4
View File
@@ -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() {
+175
View File
@@ -393,4 +393,179 @@ describe("controller/InstanceManager", function () {
assert.equal(sentRequest.instanceId, 1);
});
});
describe("inheritance", function () {
/** @type {InstanceRecord} */
let base;
/** @type {InstanceRecord} */
let child;
let updated;
async function createInheritor(id, baseId, fields = {}) {
const config = new lib.InstanceConfig("controller", {
"instance.id": id, "instance.name": `Instance ${id}`, "instance.base_id": baseId, ...fields,
});
instances.applyBase(config, ["instance.id", "instance.name", "instance.base_id", ...Object.keys(fields)]);
return await instances.createInstance(config);
}
beforeEach(async function () {
base = await instances.createInstance(new lib.InstanceConfig("controller", {
"instance.id": 10,
"instance.name": "Base",
"instance.is_base": true,
"factorio.version": "2.0",
"factorio.settings": { name: "Base server" },
}));
child = await createInheritor(11, 10, { "factorio.game_port": 34197 });
updated = [];
controller.instanceConfigUpdated = async (inst) => { updated.push(inst.id); };
});
describe(".applyBase()", function () {
it("should copy base 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 keep inherited factorio.settings in createInstance", function () {
assert.deepEqual(child.config.get("factorio.settings"), base.config.get("factorio.settings"));
assert.notEqual(child.config.get("factorio.settings"), base.config.get("factorio.settings"));
});
});
describe("base changes", function () {
it("should propagate to inherited fields and send the config to the host", function () {
base.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;
base.config.set("factorio.version", "2.1");
assert.equal(child.config.restartRequired, true);
});
it("should not touch fields the inheritor has set", function () {
base.config.set("factorio.game_port", 1234);
assert.equal(child.config.get("factorio.game_port"), 34197);
assert.deepEqual(updated, []);
});
it("should propagate through several levels", async function () {
child.config.set("instance.is_base", true);
const grandchild = await createInheritor(12, 11);
base.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);
base.config.set("factorio.version", "2.0.1");
assert.equal(child.config.get("factorio.version"), "2.1");
});
it("should stop inheriting with overrideFields() even if the value is unchanged", function () {
instances.overrideFields(child, ["factorio.version", "factorio.game_port"]);
assert.equal(child.config.isInherited("factorio.version"), false);
});
});
describe(".inheritField()", function () {
it("should restore the base 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 throw if the instance has no base", function () {
assert.throws(
() => instances.inheritField(1, "factorio.version"),
new lib.ValidationError("Instance does not have a base"),
);
});
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"),
);
});
});
describe("base_id changes", function () {
it("should inherit fields equal to the new base", function () {
instance.config.set("factorio.version", "2.0");
instance.config.set("instance.base_id", 10);
assert.equal(instance.config.isInherited("factorio.version"), true);
assert.equal(instance.config.isInherited("factorio.settings"), false);
});
it("should clear inherited fields when the base is removed", function () {
child.config.set("instance.base_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 base", function () {
instances.checkInheritance(instance.config, false, 10);
});
it("should reject a missing base", 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 base", function () {
assert.throws(
() => instances.checkInheritance(instance.config, false, 11),
new lib.ValidationError("Instance Instance 11 is not a base"),
);
});
it("should reject cycles", function () {
child.config.set("instance.is_base", true);
assert.throws(
() => instances.checkInheritance(base.config, true, 11),
new lib.ValidationError("Base instances cannot inherit from themselves"),
);
});
it("should reject making an assigned instance a base", 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 base"),
);
});
it("should reject unmarking a base that has inheritors", function () {
assert.throws(
() => instances.checkInheritance(base.config, false, null),
new lib.ConflictError("Instance is the base of 1 other instance(s)"),
);
});
});
it("should not assign a base 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("Base instances cannot be assigned to a host"),
);
});
it("should not delete a base that has inheritors", async function () {
await assert.rejects(
instances.deleteInstance(10),
new lib.ConflictError("Instance is the base of 1 other instance(s)"),
);
});
it("should report bases in instance details", function () {
assert.equal(base.toInstanceDetails().isBase, true);
assert.equal(child.toInstanceDetails().isBase, false);
});
});
});
+57
View File
@@ -971,6 +971,63 @@ describe("Integration of Clusterio", function() {
});
});
describe("instance base inheritance", function() {
after(async function() {
for (const id of [4402, 4401, 4400]) {
await getControl().send(new lib.InstanceDeleteRequest(id)).catch(() => {});
}
});
it("creates a base and an instance inheriting from it", async function() {
await execCtl("instance create inheritBase --id 4400 --is-base");
await execCtl("instance config set inheritBase factorio.version 2.0");
await execCtl("instance create inheritChild --id 4401 --base inheritBase");
const config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
assert.equal(config["instance.base_id"], 4400);
assert.equal(config["factorio.version"], "2.0");
assert.equal(config["instance.inherited_fields"]["factorio.version"], true);
assert.equal((await getInstances()).get(4400).isBase, true);
});
it("propagates base changes", async function() {
await execCtl("instance config set inheritBase factorio.version 2.1");
const config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
assert.equal(config["factorio.version"], "2.1");
});
it("rejects inheriting from an instance that is not a base", async function() {
await assert.rejects(
execCtl("instance create inheritBad --id 4402 --base inheritChild"),
new lib.ValidationError("Instance inheritChild is not a base"),
);
});
it("rejects assigning a base to a host", async function() {
await assert.rejects(
execCtl("instance assign inheritBase 4"),
new lib.ValidationError("Base instances cannot be assigned to a host"),
);
});
describe("instance list --bases", function() {
it("runs", async function() {
await execCtl("instance list --bases");
});
});
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");
config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
assert.equal(config["factorio.version"], "2.1");
assert.equal(config["instance.inherited_fields"]["factorio.version"], true);
});
});
});
describe("instance assign", function() {
it("creates the instance files", async function() {
await execCtl(`instance assign test ${hostId}`);
+2 -1
View File
@@ -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);
+42
View File
@@ -4,6 +4,48 @@ 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_base",
"instance.base_id", "instance.inherited_fields", "factorio.host_assigned_game_port", "no.field",
]) {
assert.equal(lib.InstanceConfig.isInheritable(name), false, name);
}
});
});
describe(".isInherited()", function() {
it("should only report fields as inherited when a base 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.base_id", 1);
assert.equal(config.isInherited("factorio.version"), true);
assert.equal(config.isInherited("factorio.game_port"), false);
});
});
it("should not send inheritance fields to the host", function() {
const config = new lib.InstanceConfig("controller", { "instance.base_id": 1 });
const remote = config.toRemote("host");
for (const name of ["instance.is_base", "instance.base_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"];