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

+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"),
);
}