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>
250 lines
8.9 KiB
TypeScript
250 lines
8.9 KiB
TypeScript
import semver from "semver";
|
|
|
|
import type * as lib from "@clusterio/lib";
|
|
|
|
import type { UserAccount } from "../BaseWebPlugin";
|
|
import type { InstallTarget, PackageOperation, RemoteAccess } from "../components/PluginInstall";
|
|
|
|
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
|
|
|
export type Machine = {
|
|
name: string,
|
|
isController: boolean,
|
|
address: lib.AddressShorthand,
|
|
/** A host that isn't connected, its plugins are the ones it had when last seen */
|
|
offline?: boolean,
|
|
};
|
|
export type MachinePlugins = Machine & { plugins: lib.PluginDetails[] };
|
|
|
|
/**
|
|
* What happens with a plugin on one machine, in order of precedence
|
|
*
|
|
* - nameTaken: Another package here provides the plugin by the same name, startup skips this one
|
|
* - dependencyTaken: Not installed, and the group's npm package name is a dependency here for
|
|
* another plugin, such as an alias
|
|
* - missing: Not installed
|
|
* - noEntrypoint: Installed but never loaded on this machine as it has no entrypoint for it
|
|
* - disabled: Installed but not loaded after the next restart
|
|
* - enabled: Installed and loaded after the next restart
|
|
*/
|
|
export type MachinePluginState =
|
|
"nameTaken" | "dependencyTaken" | "missing" | "noEntrypoint" | "disabled" | "enabled";
|
|
|
|
/** A plugin on one machine, with the version it runs now and the version it will run after a restart */
|
|
export type MachinePlugin = Machine & {
|
|
plugin?: lib.PluginDetails,
|
|
state: MachinePluginState,
|
|
running?: string,
|
|
afterRestart?: string,
|
|
/** A host from before installedVersion was reported, it can't install a specific version */
|
|
oldHost: boolean,
|
|
};
|
|
export type PluginStatus = {
|
|
key: string,
|
|
title: string,
|
|
/** Not present for plugins that aren't published to npm */
|
|
npmPackage?: string,
|
|
machines: MachinePlugin[],
|
|
/** Machines that haven't reported their plugins, such as hosts on an older version */
|
|
unknown: Machine[],
|
|
};
|
|
|
|
/** Plugins are matched across machines by npm package, falling back to the plugin name */
|
|
export function pluginStatusKey(plugin: { name: string, npmPackage?: string }) {
|
|
return plugin.npmPackage ?? plugin.name;
|
|
}
|
|
|
|
export function compareVersions(a: string, b: string) {
|
|
if (semver.valid(a) && semver.valid(b)) {
|
|
return semver.compare(a, b);
|
|
}
|
|
return strcmp(a, b);
|
|
}
|
|
|
|
/** Not uninstalled since startup and not skipped by name, so its plugin name is in use */
|
|
function holdsName(plugin: lib.PluginDetails) {
|
|
return !plugin.removed && plugin.state !== "pendingInstall" && plugin.state !== "nameTaken";
|
|
}
|
|
|
|
function machinePluginState(
|
|
plugins: lib.PluginDetails[], plugin: lib.PluginDetails | undefined, key: string, names: Set<string>,
|
|
): MachinePluginState {
|
|
if (plugin?.state === "nameTaken") {
|
|
return "nameTaken";
|
|
}
|
|
const absent = !plugin || plugin.removed;
|
|
if ((absent || plugin.state === "pendingInstall") && plugins.some(
|
|
candidate => pluginStatusKey(candidate) !== key && names.has(candidate.name) && holdsName(candidate)
|
|
)) {
|
|
return "nameTaken";
|
|
}
|
|
if (absent) {
|
|
return plugins.some(candidate => candidate.dependencyName === key && !candidate.removed)
|
|
? "dependencyTaken" : "missing";
|
|
}
|
|
if (plugin.state === "noEntrypoint") {
|
|
return "noEntrypoint";
|
|
}
|
|
return plugin.loadsOnRestart ? "enabled" : "disabled";
|
|
}
|
|
|
|
/** The plugin with the given key on one machine, names are the plugin names known for that key */
|
|
function machinePlugin(machine: MachinePlugins, key: string, names: Set<string>): MachinePlugin {
|
|
const { plugins, ...rest } = machine;
|
|
// A package removed and installed again is listed twice, the removed one may still be running
|
|
const candidates = plugins.filter(candidate => pluginStatusKey(candidate) === key);
|
|
const plugin = candidates.find(candidate => !candidate.removed) ?? candidates[0];
|
|
const state = machinePluginState(plugins, plugin, key, names);
|
|
const oldHost = !machine.isController && plugins.length > 0 && plugins.every(
|
|
candidate => candidate.installedVersion === undefined && !candidate.removed
|
|
);
|
|
return {
|
|
...rest,
|
|
plugin,
|
|
state,
|
|
oldHost,
|
|
running: candidates.find(candidate => candidate.loaded)?.version,
|
|
afterRestart: state === "enabled" && plugin ? plugin.installedVersion ?? plugin.version : undefined,
|
|
};
|
|
}
|
|
|
|
/** Group the plugin lists of every machine by plugin */
|
|
export function computePluginStatuses(
|
|
machines: MachinePlugins[], unknown: Machine[] = [],
|
|
): Map<string, PluginStatus> {
|
|
const titles = new Map<string, string>();
|
|
// Plugin names known from startup, pending installs are listed by package name
|
|
const pluginNames = new Map<string, Set<string>>();
|
|
for (const machine of machines) {
|
|
for (const plugin of machine.plugins) {
|
|
const key = pluginStatusKey(plugin);
|
|
if (!titles.has(key) || plugin.loaded) {
|
|
titles.set(key, plugin.title);
|
|
}
|
|
if (plugin.state !== "pendingInstall" && plugin.state !== "nameTaken") {
|
|
pluginNames.set(key, (pluginNames.get(key) ?? new Set()).add(plugin.name));
|
|
}
|
|
}
|
|
}
|
|
|
|
const statuses = new Map<string, PluginStatus>();
|
|
for (const [key, title] of titles) {
|
|
const status: PluginStatus = { key, title, machines: [], unknown };
|
|
for (const machine of machines) {
|
|
const entry = machinePlugin(machine, key, pluginNames.get(key) ?? new Set());
|
|
status.npmPackage ??= entry.plugin?.npmPackage;
|
|
status.machines.push(entry);
|
|
}
|
|
statuses.set(key, status);
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
export type SkippedOperation = { kind: PackageOperation["kind"], target: InstallTarget, reason: string };
|
|
export type UpdatePlan = {
|
|
operations: PackageOperation[],
|
|
skipped: SkippedOperation[],
|
|
};
|
|
|
|
/**
|
|
* Bring every connected machine up to the given version, installing where the plugin is missing.
|
|
* Machines already on that version or a newer one are left alone.
|
|
*/
|
|
export function planUpdate(
|
|
status: PluginStatus,
|
|
version: string,
|
|
access: RemoteAccess,
|
|
account: Pick<UserAccount, "hasPermission">,
|
|
): UpdatePlan {
|
|
const plan: UpdatePlan = { operations: [], skipped: [] };
|
|
if (!status.npmPackage) {
|
|
return plan;
|
|
}
|
|
const packageSpec = `${status.npmPackage}@${version}`;
|
|
for (const machine of status.machines) {
|
|
const { plugin } = machine;
|
|
const target = { name: machine.name, address: machine.address };
|
|
const installed = plugin && !plugin.removed;
|
|
if (installed && compareVersions(plugin.installedVersion ?? plugin.version, version) >= 0) {
|
|
continue;
|
|
}
|
|
const kind = !installed || plugin.state === "pendingInstall" ? "install" : "update";
|
|
const skip = (reason: string, skipKind: PackageOperation["kind"] = kind) => {
|
|
plan.skipped.push({ kind: skipKind, target, reason });
|
|
};
|
|
if (machine.offline) {
|
|
skip("offline");
|
|
} else if (machine.state === "nameTaken") {
|
|
skip("another package provides this plugin", "install");
|
|
} else if (machine.state === "dependencyTaken") {
|
|
skip(`another package is installed as ${status.npmPackage}`, "install");
|
|
} else if (installed && !plugin.npmPackage) {
|
|
// An alias, private or local package, installing the group's npm package would replace it
|
|
skip("not installed from npm");
|
|
} else if (machine.oldHost) {
|
|
skip("host too old to install a specific version");
|
|
} else if (kind === "install") {
|
|
if (!account.hasPermission("core.plugin.install")) {
|
|
skip("no install permission");
|
|
} else if (access.canInstall(machine.address)) {
|
|
plan.operations.push({ kind, target, packageSpec });
|
|
} else {
|
|
skip("remote installs disabled");
|
|
}
|
|
} else if (!account.hasPermission("core.plugin.update")) {
|
|
skip("no update permission");
|
|
} else if (access.canUpdate(machine.address)) {
|
|
plan.operations.push({ kind, target, packageSpec });
|
|
} else {
|
|
skip("remote updates disabled");
|
|
}
|
|
}
|
|
if (plan.operations.length || plan.skipped.length) {
|
|
for (const machine of status.unknown) {
|
|
const target = { name: machine.name, address: machine.address };
|
|
plan.skipped.push({ kind: "update", target, reason: "plugins not reported" });
|
|
}
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
/** Plan for the given machine only */
|
|
export function planMachineUpdate(
|
|
status: PluginStatus,
|
|
machine: MachinePlugin,
|
|
version: string,
|
|
access: RemoteAccess,
|
|
account: Pick<UserAccount, "hasPermission">,
|
|
): UpdatePlan {
|
|
return planUpdate({ ...status, machines: [machine], unknown: [] }, version, access, account);
|
|
}
|
|
|
|
/** Short descriptions of the state of a plugin on a machine, beyond the versions it runs */
|
|
export function machineStatusLabels(machine: MachinePlugin) {
|
|
const labels: string[] = [];
|
|
if (machine.offline) {
|
|
labels.push("Offline");
|
|
}
|
|
if (machine.state === "nameTaken") {
|
|
labels.push("Another package provides this plugin");
|
|
} else if (machine.state === "dependencyTaken") {
|
|
labels.push("Another package is installed by this name");
|
|
} else if (machine.state === "missing") {
|
|
labels.push("Not installed");
|
|
} else {
|
|
if (!machine.plugin?.npmPackage) {
|
|
labels.push("Not installed from npm");
|
|
}
|
|
if (machine.state === "noEntrypoint") {
|
|
labels.push("No entrypoint for this machine");
|
|
}
|
|
if (machine.state === "disabled") {
|
|
labels.push("Disabled");
|
|
}
|
|
}
|
|
if (machine.running !== machine.afterRestart) {
|
|
labels.push("Restart pending");
|
|
}
|
|
return labels;
|
|
}
|