Merge pull request #1010 from bbassie/feature/plugin-browser

Plugin management through web interface
This commit is contained in:
Bastiaan authored and GitHub committed 2026-10-27 21:32:51 +00:00
1 parent e36a76acc5
commit ffe82f81d8
48 files changed
+4275 -536

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { ExclusiveRunner } from "../../packages/web_ui/src/util/exclusive.ts";
describe("web_ui/util/exclusive", function() {
describe("class ExclusiveRunner", function() {
it("should refuse a restart while an install is running", async function() {
const states = [];
const runner = new ExclusiveRunner(state => states.push(state));
let finishInstall;
const install = runner.run("installing", () => new Promise(resolve => { finishInstall = resolve; }));
let restarted = false;
assert.equal(await runner.run("restarting", async () => { restarted = true; }), false);
assert.equal(restarted, false);
finishInstall();
assert.equal(await install, true);
assert.deepEqual(states, ["installing", undefined]);
});
it("should refuse an install while a restart is running", async function() {
const runner = new ExclusiveRunner(() => {});
let finishRestart;
const restart = runner.run("restarting", () => new Promise(resolve => { finishRestart = resolve; }));
assert.equal(await runner.run("installing", async () => {}), false);
finishRestart();
await restart;
assert.equal(await runner.run("installing", async () => {}), true);
});
it("should accept a task after one that failed", async function() {
const runner = new ExclusiveRunner(() => {});
await assert.rejects(runner.run("installing", async () => { throw new Error("failed"); }), /failed/);
assert.equal(runner.current, undefined);
assert.equal(await runner.run("restarting", async () => {}), true);
});
});
});
+171
View File
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import {
computePluginStatuses, machineStatusLabels, planMachineUpdate, planUpdate,
} from "../../packages/web_ui/src/util/plugin_status.ts";
describe("web_ui/util/plugin_status", function() {
describe("planUpdate()", function() {
const access = { canInstall: () => true, canUpdate: () => true };
const account = { hasPermission: () => true };
function machine(name, plugins) {
return { name, isController: false, address: { hostId: name.length }, plugins };
}
function loaded(npmPackage, version) {
return new lib.PluginDetails("plugin", "Plugin", version, true, true, undefined, npmPackage, version);
}
function pending(packageName, npmPackage, version) {
return new lib.PluginDetails(
packageName, packageName, version, false, false, undefined, npmPackage, version, true,
);
}
function skippedText(result) {
return result.skipped.map(skip => ({ kind: skip.kind, text: `${skip.target.name} (${skip.reason})` }));
}
function plan(machines) {
const statuses = computePluginStatuses(machines);
assert.equal(statuses.size, 1);
const status = [...statuses.values()][0];
return planUpdate(status, "2.0.0", access, account);
}
it("should install on machines where the plugin is missing", function() {
const result = plan([machine("a", [loaded("plugin-aliased", "1.0.0")]), machine("b", [])]);
assert.deepEqual(result.operations.map(op => [op.kind, op.target.name, op.packageSpec]), [
["update", "a", "plugin-aliased@2.0.0"],
["install", "b", "plugin-aliased@2.0.0"],
]);
});
it("should not install over a pending alias with another machine's package", function() {
const result = plan([
machine("a", [loaded("plugin-aliased", "2.0.0")]),
machine("b", [pending("plugin-aliased", undefined, "1.0.0")]),
]);
assert.deepEqual(result.operations, []);
assert.deepEqual(skippedText(result), [{ kind: "install", text: "b (not installed from npm)" }]);
});
it("should not update a loaded package that isn't from npm", function() {
const result = plan([
machine("a", [loaded("plugin-aliased", "1.0.0")]),
machine("b", [new lib.PluginDetails("plugin-aliased", "Plugin", "1.0.0", true, true)]),
]);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [{ kind: "update", text: "b (not installed from npm)" }]);
});
it("should not send a specific version to hosts that can't parse it", function() {
// Hosts from before installedVersion was reported take name@version as a package name
const old = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true, undefined, "plugin-aliased");
const result = plan([
machine("a", [loaded("plugin-aliased", "1.0.0")]),
machine("old", [old]),
]);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [
{ kind: "update", text: "old (host too old to install a specific version)" },
]);
});
it("should not install over a loaded alias that has the package name", function() {
const alias = new lib.PluginDetails("real_plugin", "Real", "1.0.0", true, true);
alias.dependencyName = "plugin-aliased";
const statuses = computePluginStatuses([
machine("a", [loaded("plugin-aliased", "1.0.0")]),
machine("b", [alias]),
]);
const result = planUpdate(statuses.get("plugin-aliased"), "2.0.0", access, account);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [
{ kind: "install", text: "b (another package is installed as plugin-aliased)" },
]);
});
it("should install over a pending npm package", function() {
const result = plan([
machine("a", [loaded("plugin-aliased", "2.0.0")]),
machine("b", [pending("plugin-aliased", "plugin-aliased", "1.0.0")]),
]);
assert.deepEqual(result.operations.map(op => [op.kind, op.target.name]), [["install", "b"]]);
});
it("should skip offline machines and machines that haven't reported their plugins", function() {
const offline = { ...machine("off", [loaded("plugin-aliased", "1.0.0")]), offline: true };
const statuses = computePluginStatuses(
[machine("a", [loaded("plugin-aliased", "1.0.0")]), offline, { ...machine("gone", []), offline: true }],
[{ name: "old", isController: false, address: { hostId: 9 } }],
);
const result = planUpdate(statuses.get("plugin-aliased"), "2.0.0", access, account);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [
{ kind: "update", text: "off (offline)" },
{ kind: "install", text: "gone (offline)" },
{ kind: "update", text: "old (plugins not reported)" },
]);
});
});
describe("planMachineUpdate()", function() {
it("should plan for the given machine only", function() {
const loaded = new lib.PluginDetails(
"plugin", "Plugin", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0",
);
const statuses = computePluginStatuses([
{ name: "a", isController: false, address: { hostId: 1 }, plugins: [loaded] },
{ name: "b", isController: false, address: { hostId: 2 }, plugins: [] },
], [{ name: "old", isController: false, address: { hostId: 9 } }]);
const status = statuses.get("plugin-aliased");
const plan = planMachineUpdate(
status, status.machines[1], "2.0.0",
{ canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true },
);
assert.deepEqual(plan.operations.map(op => [op.kind, op.target.name]), [["install", "b"]]);
assert.deepEqual(plan.skipped, []);
});
});
describe("machineStatusLabels()", function() {
function labels(plugins, extra = {}) {
const [status] = computePluginStatuses([
{ name: "a", isController: false, address: { hostId: 1 }, plugins: [loadedPlugin()] },
{ name: "b", isController: false, address: { hostId: 2 }, plugins, ...extra },
]).values();
return machineStatusLabels(status.machines[1]);
}
function loadedPlugin(version = "1.0.0", installedVersion = version) {
return new lib.PluginDetails(
"plugin", "Plugin", version, true, true, undefined, "plugin", installedVersion,
);
}
it("should describe the state of a plugin on a machine", function() {
assert.deepEqual(labels([loadedPlugin()]), []);
assert.deepEqual(labels([loadedPlugin("1.0.0", "2.0.0")]), ["Restart pending"]);
assert.deepEqual(labels([]), ["Not installed"]);
assert.deepEqual(labels([loadedPlugin()], { offline: true }), ["Offline"]);
const disabled = new lib.PluginDetails(
"plugin", "Plugin", "1.0.0", false, false, undefined, "plugin", "1.0.0",
);
assert.deepEqual(labels([disabled]), ["Disabled"]);
const local = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true);
assert.deepEqual(labels([local]), ["Not installed from npm"]);
});
});
describe("computePluginStatuses()", function() {
it("should not expect a plugin without an entrypoint for the machine to run after restart", function() {
const plugin = new lib.PluginDetails("controller_only", "Plugin", "1.0.0", false, false);
plugin.noEntrypoint = true;
const [status] = computePluginStatuses([
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
]).values();
const [entry] = status.machines;
assert.equal(entry.noEntrypoint, true);
assert.equal(entry.disabled, false);
assert.equal(entry.running, undefined);
assert.equal(entry.afterRestart, undefined);
});
});
});
+196
View File
@@ -0,0 +1,196 @@
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) {
const plugin = new lib.PluginDetails(
fields.name, fields.name, fields.version, fields.loaded, fields.enabled, undefined, fields.npmPackage,
fields.installedVersion, fields.pendingInstall, fields.removed, fields.dependencyName, fields.noEntrypoint,
fields.nameTaken,
);
return plugin;
}
function* states() {
yield { label: "absent", plugins: [] };
for (const version of versions) {
for (const installed of [version, ...versions.filter(other => other !== version)]) {
for (const enabled of [true, false]) {
for (const loaded of enabled ? [true] : [true, false]) {
const state = `${enabled ? "enabled" : "disabled"}${loaded ? " loaded" : ""}`;
yield {
label: `npm ${version} on disk ${installed} ${state}`,
entry: { source: "npm", version, installed, enabled, loaded },
plugins: [details({
name: "plugin", version, loaded, enabled, npmPackage: "pkg",
installedVersion: installed, dependencyName: "pkg",
})],
};
}
}
}
yield {
label: `npm ${version} removed`,
entry: { source: "npm", version, removed: true },
plugins: [details({
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
removed: true, dependencyName: "pkg",
})],
};
yield {
label: `npm ${version} no entrypoint`,
entry: { source: "npm", version, installed: version, noEntrypoint: true },
plugins: [details({
name: "plugin", version, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg", noEntrypoint: true,
})],
};
yield {
label: `npm ${version} pending`,
entry: { source: "npm", version, installed: version, pending: true },
plugins: [details({
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: version, pendingInstall: true, dependencyName: "pkg",
})],
};
yield {
label: `alias ${version} pending`,
entry: { source: "alias", version, installed: version, pending: true },
plugins: [details({
name: "pkg", version, loaded: false, enabled: false,
installedVersion: version, pendingInstall: true, dependencyName: "pkg",
})],
};
yield {
label: `npm ${version} removed and reinstalled at ${target}`,
entry: { source: "npm", version, installed: target, replaced: true },
plugins: [
details({
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
removed: true, dependencyName: "pkg",
}),
details({
name: "pkg", version: target, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: target, pendingInstall: true, dependencyName: "pkg",
}),
],
};
}
yield {
label: "alias loaded as pkg",
taken: "dependency",
plugins: [details({
name: "real_plugin", version: "1.0.0", loaded: true, enabled: true,
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", loaded: true, enabled: true, installedVersion: "1.0.0" }),
details({
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg", nameTaken: true,
}),
],
entry: { source: "npm", version, installed: version },
};
}
yield {
label: "local plugin with the same name",
taken: "name",
plugins: [details({
name: "plugin", version: "1.0.0", loaded: true, enabled: true, 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", loaded: true, enabled: true, installedVersion: "1.0.0" }),
details({
name: "pkg", version: "1.0.0", loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: "1.0.0", pendingInstall: true, 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?.removed || 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", loaded: true, enabled: true, 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);
}
});
}
});
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { setTimeout as sleep } from "node:timers/promises";
import { poll } from "../../packages/web_ui/src/util/poll.ts";
describe("web_ui/util/poll", function() {
describe("poll()", function() {
it("should keep every result of a task slower than the interval", async function() {
let running = 0;
let maxRunning = 0;
const applied = [];
let count = 0;
const stop = poll(async () => {
count += 1;
const run = count;
running += 1;
maxRunning = Math.max(maxRunning, running);
await sleep(20);
running -= 1;
applied.push(run);
}, 1);
await sleep(150);
stop();
await sleep(40);
assert.equal(maxRunning, 1);
assert.ok(applied.length >= 3, `only ${applied.length} runs finished`);
assert.deepEqual(applied, applied.slice().sort((a, b) => a - b));
assert.equal(applied.length, count);
});
it("should not run again once stopped", async function() {
let count = 0;
const stop = poll(async () => { count += 1; }, 1);
stop();
await sleep(20);
assert.equal(count, 1);
});
it("should not run again when stopped while a run is in progress", async function() {
let count = 0;
const stop = poll(async () => { count += 1; await sleep(10); }, 1);
await sleep(2);
stop();
await sleep(30);
assert.equal(count, 1);
});
});
});