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

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