mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
loaded, enabled, pendingInstall, removed, noEntrypoint and nameTaken allowed far more combinations than can happen, and every consumer had to know which took priority. PluginDetails now has one state, with loaded, loadsOnRestart and removed as getters over it. 2.0.3 web UIs and ctl send PluginListRequest straight to hosts and require loaded and enabled, so those are still sent next to the optional state. Plugins listed by 2.0.3 hosts get their state from loaded and enabled. The web UI planner gets the same treatment: MachinePlugin has one state in place of disabled, noEntrypoint, dependencyTaken and nameTaken. ctl host plugin list shows the state. Closes #1134 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
6.5 KiB
JavaScript
199 lines
6.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { computePluginStatuses, planUpdate } from "../../packages/web_ui/src/util/plugin_status.ts";
|
|
|
|
// Every state a machine can report for the plugin "plugin" published as "pkg", checked against
|
|
// what the next startup and npm install would do, independent of the other machines.
|
|
const target = "2.0.0";
|
|
const versions = ["1.0.0", "2.0.0", "3.0.0"];
|
|
|
|
function details(fields) {
|
|
return new lib.PluginDetails(
|
|
fields.name, fields.name, fields.version, fields.state, undefined, fields.npmPackage,
|
|
fields.installedVersion, fields.dependencyName,
|
|
);
|
|
}
|
|
|
|
function* states() {
|
|
yield { label: "absent", plugins: [] };
|
|
for (const version of versions) {
|
|
for (const installed of [version, ...versions.filter(other => other !== version)]) {
|
|
for (const [state, loaded, enabled] of [
|
|
["loaded", true, true],
|
|
["unloadOnRestart", true, false],
|
|
["loadOnRestart", false, true],
|
|
["disabled", false, false],
|
|
]) {
|
|
yield {
|
|
label: `npm ${version} on disk ${installed} ${state}`,
|
|
entry: { source: "npm", version, installed, enabled, loaded },
|
|
plugins: [details({
|
|
name: "plugin", version, state, npmPackage: "pkg",
|
|
installedVersion: installed, dependencyName: "pkg",
|
|
})],
|
|
};
|
|
}
|
|
}
|
|
for (const loaded of [true, false]) {
|
|
yield {
|
|
label: `npm ${version} removed${loaded ? " while loaded" : ""}`,
|
|
entry: { source: "npm", version, removed: true, loaded },
|
|
plugins: [details({
|
|
name: "plugin", version, state: loaded ? "removedLoaded" : "removed", npmPackage: "pkg",
|
|
dependencyName: "pkg",
|
|
})],
|
|
};
|
|
}
|
|
yield {
|
|
label: `npm ${version} no entrypoint`,
|
|
entry: { source: "npm", version, installed: version, noEntrypoint: true },
|
|
plugins: [details({
|
|
name: "plugin", version, state: "noEntrypoint", npmPackage: "pkg",
|
|
installedVersion: version, dependencyName: "pkg",
|
|
})],
|
|
};
|
|
yield {
|
|
label: `npm ${version} pending`,
|
|
entry: { source: "npm", version, installed: version, pending: true },
|
|
plugins: [details({
|
|
name: "pkg", version, state: "pendingInstall", npmPackage: "pkg",
|
|
installedVersion: version, dependencyName: "pkg",
|
|
})],
|
|
};
|
|
yield {
|
|
label: `alias ${version} pending`,
|
|
entry: { source: "alias", version, installed: version, pending: true },
|
|
plugins: [details({
|
|
name: "pkg", version, state: "pendingInstall",
|
|
installedVersion: version, dependencyName: "pkg",
|
|
})],
|
|
};
|
|
yield {
|
|
label: `npm ${version} removed and reinstalled at ${target}`,
|
|
entry: { source: "npm", version, installed: target, replaced: true },
|
|
plugins: [
|
|
details({
|
|
name: "plugin", version, state: "removedLoaded", npmPackage: "pkg",
|
|
dependencyName: "pkg",
|
|
}),
|
|
details({
|
|
name: "pkg", version: target, state: "pendingInstall", npmPackage: "pkg",
|
|
installedVersion: target, dependencyName: "pkg",
|
|
}),
|
|
],
|
|
};
|
|
}
|
|
yield {
|
|
label: "alias loaded as pkg",
|
|
taken: "dependency",
|
|
plugins: [details({
|
|
name: "real_plugin", version: "1.0.0", state: "loaded",
|
|
installedVersion: "1.0.0", dependencyName: "pkg",
|
|
})],
|
|
};
|
|
for (const version of versions) {
|
|
yield {
|
|
label: `npm ${version} skipped as its name is taken`,
|
|
taken: "name",
|
|
plugins: [
|
|
details({ name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0" }),
|
|
details({
|
|
name: "pkg", version, state: "nameTaken", npmPackage: "pkg",
|
|
installedVersion: version, dependencyName: "pkg",
|
|
}),
|
|
],
|
|
entry: { source: "npm", version, installed: version },
|
|
};
|
|
}
|
|
yield {
|
|
label: "local plugin with the same name",
|
|
taken: "name",
|
|
plugins: [details({
|
|
name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0",
|
|
})],
|
|
};
|
|
yield {
|
|
label: "local plugin with the same name and pkg pending",
|
|
taken: "name",
|
|
plugins: [
|
|
details({ name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0" }),
|
|
details({
|
|
name: "pkg", version: "1.0.0", state: "pendingInstall", npmPackage: "pkg",
|
|
installedVersion: "1.0.0", dependencyName: "pkg",
|
|
}),
|
|
],
|
|
};
|
|
}
|
|
|
|
function expectedOperation(state) {
|
|
const { entry, taken } = state;
|
|
const present = entry && !entry.removed;
|
|
if (present && semverGte(entry.installed, target)) {
|
|
return "none";
|
|
}
|
|
if (taken || present && entry.source !== "npm") {
|
|
// Startup would skip it, or npm install pkg would replace the package at that dependency name
|
|
return "skip";
|
|
}
|
|
return !present || entry.pending || entry.replaced ? "install" : "update";
|
|
}
|
|
|
|
function expected(state) {
|
|
const { entry, taken } = state;
|
|
const op = expectedOperation(state);
|
|
if (taken === "name") {
|
|
return { running: undefined, afterRestart: undefined, op };
|
|
}
|
|
let running;
|
|
let afterRestart;
|
|
if (entry?.loaded || entry?.replaced) {
|
|
running = entry.version;
|
|
}
|
|
if (entry && !entry.removed && !entry.noEntrypoint && (entry.pending || entry.replaced || entry.enabled)) {
|
|
afterRestart = entry.installed;
|
|
}
|
|
return { running, afterRestart, op };
|
|
}
|
|
|
|
function semverGte(a, b) {
|
|
return versions.indexOf(a) >= versions.indexOf(b);
|
|
}
|
|
|
|
const reference = { name: "ref", isController: true, address: "controller", plugins: [details({
|
|
name: "plugin", version: "1.0.0", state: "loaded", npmPackage: "pkg",
|
|
installedVersion: "1.0.0", dependencyName: "pkg",
|
|
})] };
|
|
|
|
function actual(machines, name) {
|
|
const status = computePluginStatuses(machines).get("pkg");
|
|
const entry = status.machines.find(machine => machine.name === name);
|
|
const plan = planUpdate(
|
|
status, target, { canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true },
|
|
);
|
|
let op = "none";
|
|
const operation = plan.operations.find(candidate => candidate.target.name === name);
|
|
if (operation) {
|
|
op = operation.kind;
|
|
} else if (plan.skipped.some(skip => skip.target.name === name)) {
|
|
op = "skip";
|
|
}
|
|
return { running: entry.running, afterRestart: entry.afterRestart, op };
|
|
}
|
|
|
|
describe("web_ui/util/plugin_status matrix", function() {
|
|
const all = [...states()];
|
|
for (const state of all) {
|
|
it(`should predict ${state.label}`, function() {
|
|
const machine = { name: "m", isController: false, address: { hostId: 1 }, plugins: state.plugins };
|
|
const result = actual([reference, machine], "m");
|
|
assert.deepEqual(result, expected(state));
|
|
// Independent of what other machines report
|
|
for (const other of all) {
|
|
const otherMachine = { name: "o", isController: false, address: { hostId: 2 }, plugins: other.plugins };
|
|
assert.deepEqual(actual([reference, otherMachine, machine], "m"), result, other.label);
|
|
}
|
|
});
|
|
}
|
|
});
|