mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 07:24:52 +09:00
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:
1 parent
024d7b427a
commit
01cf789f5d
16 files changed
+769
-26
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user