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

+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(