mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 15: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
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in new issue
Block a user