mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 15:14:52 +09:00
Implement dependencies specification for modules
Implement dependency ordering and version checking for modules.
This commit is contained in:
1 parent
80bd75ba16
commit
e89e54ee27
7 files changed
+255
-3
No files matched your search
@@ -157,7 +157,7 @@ class Instance extends link.Link{
|
||||
|
||||
// Find plugin modules to patch in
|
||||
let modules = new Map();
|
||||
for (let pluginName of this.plugins.keys()) {
|
||||
for (let [pluginName, plugin] of this.plugins) {
|
||||
let modulePath = path.join('plugins', pluginName, 'module');
|
||||
if (!await fs.pathExists(modulePath)) {
|
||||
continue;
|
||||
@@ -174,12 +174,16 @@ class Instance extends link.Link{
|
||||
}
|
||||
|
||||
module = Object.assign({
|
||||
version: plugin.info.version,
|
||||
dependencies: {},
|
||||
path: modulePath,
|
||||
load: [],
|
||||
}, module);
|
||||
modules.set(module.name, module);
|
||||
}
|
||||
|
||||
// Find stand alone modules to load
|
||||
// XXX for now it's assumed all available modules should be loaded.
|
||||
for (let entry of await fs.readdir('modules', { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (modules.has(entry.name)) {
|
||||
@@ -198,13 +202,14 @@ class Instance extends link.Link{
|
||||
|
||||
module = Object.assign({
|
||||
path: path.join('modules', entry.name),
|
||||
dependencies: {},
|
||||
load: [],
|
||||
}, module);
|
||||
modules.set(module.name, module);
|
||||
}
|
||||
}
|
||||
|
||||
await factorio.patch(this.path("saves", saveName), modules.values());
|
||||
await factorio.patch(this.path("saves", saveName), [...modules.values()]);
|
||||
|
||||
this.server.on('rcon-ready', () => {
|
||||
console.log("Clusterio | RCON connection established");
|
||||
|
||||
@@ -198,6 +198,10 @@ structure:
|
||||
```json
|
||||
{
|
||||
"name": "my_module",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"foo": ">=0.4.2"
|
||||
},
|
||||
"load": ["foo.lua"]
|
||||
}
|
||||
```
|
||||
@@ -208,6 +212,19 @@ The following entries are supported in the module.json file:
|
||||
Name of the module, must match the folder the module is located in
|
||||
for stand alone modules or the name of the plugin the module is
|
||||
located in.
|
||||
- `version`:
|
||||
Version of the module. Must be compatible with [Semantic Versioning
|
||||
2.0.0](https://semver.org/). In plugin modules this defaults to the
|
||||
version of the plugin otherwise it's required.
|
||||
- `dependencies`:
|
||||
Optional mapping of modules this module depends on and their
|
||||
version. In the example `>=0.4.2` means that the foo module must be
|
||||
at least version 0.4.2. See the [Ranges
|
||||
syntax](https://www.npmjs.com/package/semver#ranges) of the
|
||||
node-semver package for a full description of what operators are
|
||||
supported. The events for dependencies is invoked before the events
|
||||
of the dependent, and starting an instance will fail if the
|
||||
dependencies cannot be satisfied.
|
||||
- `load`:
|
||||
Lua files to load with the `event_handler` lib. They should be
|
||||
specified as paths that's relative to the module and uses forward
|
||||
|
||||
@@ -52,7 +52,9 @@ The following properties are recognized:
|
||||
Brief description of what the plugin does. Currently not used.
|
||||
|
||||
**version**:
|
||||
Semver compatible version string of the plugin. Currently not used.
|
||||
Semver compatible version string of the plugin. Currently only
|
||||
servers as the default for the Clusterio module embedded in the
|
||||
plugin, if any.
|
||||
|
||||
**instanceEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which
|
||||
|
||||
@@ -8,6 +8,7 @@ const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
const jszip = require("jszip");
|
||||
const path = require("path");
|
||||
const semver = require("semver");
|
||||
|
||||
const hash = require("lib/hash");
|
||||
|
||||
@@ -94,6 +95,99 @@ function generateLoader(patchInfo) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders modules to satisfy their dependencies
|
||||
*
|
||||
* Looks through and reorders the array of module definitions in order to
|
||||
* satisfy the property that dependencies are earlier in the array than
|
||||
* their dependents. Throws an error if this is not possible.
|
||||
*
|
||||
* @param {Array<Object>} modules - Array of modules to reorder
|
||||
*/
|
||||
function reorderDependencies(modules) {
|
||||
let index = 0;
|
||||
let present = new Map();
|
||||
let hold = new Map();
|
||||
reorder: while (index < modules.length) {
|
||||
let module = modules[index];
|
||||
if (semver.valid(module.version) === null) {
|
||||
throw new Error(`Invalid version '${module.version}' for module ${module.name}`);
|
||||
}
|
||||
|
||||
for (let [dependency, requirement] of Object.entries(module.dependencies)) {
|
||||
if (semver.validRange(requirement) === null) {
|
||||
throw new Error(
|
||||
`Invalid version range '${requirement}' for dependency ${dependency} on module ${module.name}`
|
||||
);
|
||||
}
|
||||
|
||||
if (present.has(dependency)) {
|
||||
if (!semver.satisfies(present.get(dependency), requirement)) {
|
||||
throw new Error(`Module ${module.name} requires ${dependency} ${requirement}`);
|
||||
}
|
||||
|
||||
// We have an unmet dependency, take it out and continue
|
||||
} else {
|
||||
if (hold.has(dependency)) {
|
||||
hold.get(dependency).push(module);
|
||||
} else {
|
||||
hold.set(dependency, [module]);
|
||||
}
|
||||
modules.splice(index, 1);
|
||||
continue reorder;
|
||||
}
|
||||
}
|
||||
|
||||
// No unmet dependencies, record and continue
|
||||
present.set(module.name, module.version);
|
||||
index++;
|
||||
|
||||
if (hold.has(module.name)) {
|
||||
modules.splice(index, 0, ...hold.get(module.name));
|
||||
hold.delete(module.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hold.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
// There are three reasons for a module to end up being held: The module depends
|
||||
// on a module that is missing, the module is part of a dependency loop, or the
|
||||
// the module depends on a module that satisfy any of these conditions.
|
||||
|
||||
let remaining = new Map();
|
||||
for (let modules of hold.values()) {
|
||||
for (let module of modules) {
|
||||
remaining.set(module.name, module);
|
||||
}
|
||||
}
|
||||
|
||||
// Start with a random module from the remaining modules
|
||||
for (let module of remaining.values()) {
|
||||
let cycle = [];
|
||||
while (true) {
|
||||
// Find an unmet dependency
|
||||
let dependency = Object.keys(module.dependencies).find(name => !present.has(name));
|
||||
|
||||
if (!remaining.has(dependency)) {
|
||||
// There's no module being held up by this dependency, the
|
||||
// dependency is missing.
|
||||
throw new Error(`Missing dependency ${dependency} for module ${module.name}`);
|
||||
}
|
||||
|
||||
if (cycle.includes(module.name)) {
|
||||
cycle.push(module.name);
|
||||
cycle.splice(0, cycle.indexOf(module.name))
|
||||
throw new Error(`Module dependency loop detected: ${cycle.join(" -> ")}`);
|
||||
}
|
||||
|
||||
cycle.push(module.name);
|
||||
module = remaining.get(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a save with the given modules
|
||||
*
|
||||
@@ -135,6 +229,8 @@ async function patch(savePath, modules) {
|
||||
zip.remove(file.name);
|
||||
}
|
||||
|
||||
reorderDependencies(modules);
|
||||
|
||||
// Add the modules to the save.
|
||||
for (let module of modules) {
|
||||
let moduleDir = root.folder(`modules/${module.name}`);
|
||||
@@ -182,4 +278,5 @@ module.exports = {
|
||||
// For testing only
|
||||
_findRoot: findRoot,
|
||||
_generateLoader: generateLoader,
|
||||
_reorderDependencies: reorderDependencies,
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
"rcon-client": "^4.2.0",
|
||||
"request": "^2.83.0",
|
||||
"sanitizer": "^0.1.3",
|
||||
"semver": "^7.1.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"simple-git": "^1.96.0",
|
||||
"single-line-log": "^1.1.2",
|
||||
|
||||
@@ -23,10 +23,14 @@ describe("Integration of lib/factorio/patch", function() {
|
||||
slowTest(this);
|
||||
await factorio.patch(savePath, [{
|
||||
name: "test",
|
||||
version: "1.0.0",
|
||||
dependencies: {},
|
||||
path: "test/file/modules/test",
|
||||
load: [],
|
||||
}, {
|
||||
name: "subdir",
|
||||
version: "1.0.0",
|
||||
dependencies: {},
|
||||
path: "test/file/modules/subdir",
|
||||
load: [],
|
||||
}]);
|
||||
|
||||
@@ -78,6 +78,132 @@ describe("lib/factorio", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderDependencies()", function() {
|
||||
it("should reorder to satisfy simple dependency", function() {
|
||||
let modules = [
|
||||
{ name: 'a', version: '1.0.0', dependencies: {'b': '*'} },
|
||||
{ name: 'b', version: '1.0.0', dependencies: {} },
|
||||
];
|
||||
factorio._reorderDependencies(modules);
|
||||
assert(modules[0].name === 'b', "Dependency was not reorederd");
|
||||
});
|
||||
it("should throw on invalid version", function() {
|
||||
let modules = [
|
||||
{ name: 'a', version: 'foo', dependencies: {} },
|
||||
];
|
||||
assert.throws(
|
||||
() => factorio._reorderDependencies(modules),
|
||||
new Error("Invalid version 'foo' for module a")
|
||||
);
|
||||
});
|
||||
it("should throw on invalid version range", function() {
|
||||
let modules = [
|
||||
{ name: 'a', version: '1.0.0', dependencies: {'b': 'invalid'} },
|
||||
];
|
||||
assert.throws(
|
||||
() => factorio._reorderDependencies(modules),
|
||||
new Error("Invalid version range 'invalid' for dependency b on module a")
|
||||
);
|
||||
});
|
||||
it("should throw on missing dependency", function() {
|
||||
let modules = [
|
||||
{ name: 'a', version: '1.0.0', dependencies: {'b': '*'} },
|
||||
];
|
||||
assert.throws(
|
||||
() => factorio._reorderDependencies(modules),
|
||||
new Error("Missing dependency b for module a")
|
||||
);
|
||||
modules = [
|
||||
{ name: 'a', version: '1.0.0', dependencies: {'b': '*'} },
|
||||
{ name: 'b', version: '1.0.0', dependencies: {'c': '*'} },
|
||||
{ name: 'c', version: '1.0.0', dependencies: {'d': '*'} },
|
||||
];
|
||||
assert.throws(
|
||||
() => factorio._reorderDependencies(modules),
|
||||
new Error("Missing dependency d for module c")
|
||||
);
|
||||
});
|
||||
it("should throw on outdated dependency", function() {
|
||||
let modules = [
|
||||
{ name: 'a', version: '1.0.0', dependencies: {} },
|
||||
{ name: 'b', version: '1.0.0', dependencies: {'a': '>=2'} },
|
||||
];
|
||||
assert.throws(
|
||||
() => factorio._reorderDependencies(modules),
|
||||
new Error("Module b requires a >=2")
|
||||
);
|
||||
});
|
||||
it("should throw on dependency loops", function() {
|
||||
let modules = [
|
||||
{ name: 'a', version: '1.0.0', dependencies: {'b': '*'} },
|
||||
{ name: 'b', version: '1.0.0', dependencies: {'a': '*'} },
|
||||
];
|
||||
assert.throws(
|
||||
() => factorio._reorderDependencies(modules),
|
||||
new Error("Module dependency loop detected: a -> b -> a")
|
||||
);
|
||||
modules = [
|
||||
{ name: 'd', version: '1.0.0', dependencies: {'b': '*'} },
|
||||
{ name: 'a', version: '1.0.0', dependencies: {'b': '*'} },
|
||||
{ name: 'b', version: '1.0.0', dependencies: {'c': '*'} },
|
||||
{ name: 'c', version: '1.0.0', dependencies: {'a': '*'} },
|
||||
];
|
||||
assert.throws(
|
||||
() => factorio._reorderDependencies(modules),
|
||||
new Error("Module dependency loop detected: b -> c -> a -> b")
|
||||
);
|
||||
});
|
||||
it("should reorder correctly for multiple dependencies", function() {
|
||||
function validate(modules) {
|
||||
let present = new Set();
|
||||
for (let module of modules) {
|
||||
for (let dependency of Object.keys(module.dependencies)) {
|
||||
if (!present.has(dependency)) {
|
||||
assert.fail(`${module.name} depends on ${dependency}, but was ordered before it`);
|
||||
}
|
||||
}
|
||||
present.add(module.name);
|
||||
}
|
||||
}
|
||||
|
||||
let names = ['a', 'b', 'c'];
|
||||
let successCount = 0;
|
||||
const bitCount = names.length * (names.length - 1);
|
||||
for (let i=0; i < 2**bitCount; i++) {
|
||||
let value = i;
|
||||
let bits = [];
|
||||
for (let j=0; j < bitCount; j++) {
|
||||
bits.push(value & 1);
|
||||
value >>= 1;
|
||||
}
|
||||
let modules = [];
|
||||
for (let j=0; j < names.length; j++) {
|
||||
let name = names[j];
|
||||
let dependencies = {};
|
||||
for (let k=0; k < names.length - 1; k++) {
|
||||
if (bits[k + j * (names.length - 1)]) {
|
||||
dependencies[names[k + (k >= j)]] = '*';
|
||||
}
|
||||
}
|
||||
modules.push({ name, version: '1.0.0', dependencies });
|
||||
}
|
||||
let success = false;
|
||||
try {
|
||||
factorio._reorderDependencies(modules);
|
||||
success = true;
|
||||
} catch (err) { }
|
||||
if (success) {
|
||||
validate(modules);
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Deriving the formula for the number of graphs without
|
||||
// dependencies loops is left as an excercise for the reader.
|
||||
assert(successCount === 25, "Incorrect number of successful orderings");
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch()", function() {
|
||||
it("should throw on unknown scenario", async function() {
|
||||
let zip = new jszip();
|
||||
|
||||
Reference in new issue
Block a user