Implement dependencies specification for modules

Implement dependency ordering and version checking for modules.
This commit is contained in:
Hornwitser committed 2020-01-04 17:34:16 +01:00
1 parent 80bd75ba16
commit e89e54ee27
7 files changed
+255 -3

No files matched your search

+7 -2
View File
@@ -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");
+17
View File
@@ -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
+3 -1
View File
@@ -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
+97
View File
@@ -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,
}
+1
View File
@@ -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",
+4
View File
@@ -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: [],
}]);
+126
View File
@@ -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();