mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 23:24:52 +09:00
Merge pull request #1010 from bbassie/feature/plugin-browser
Plugin management through web interface
This commit is contained in:
1 parent
e36a76acc5
commit
ffe82f81d8
48 files changed
+4275
-536
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user