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

+1
View File
@@ -35,6 +35,7 @@ node_modules
/plugin-list.json
/config-*.json
/config-*.json.lock
/npm-operation.lock*
pnpm-lock.yaml
mod_cache.json
.luarc.json
+31 -6
View File
@@ -283,10 +283,15 @@ This command lists all plugins currently known to the remote. Newly installed pl
### Update
ctl> controller plugin update <name> [--restart]
ctl> host plugin update <host> <name> [--restart]
ctl> controller plugin update <name>[@<version>] [--restart]
ctl> host plugin update <host> <name>[@<version>] [--restart]
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`, which stays within the version range saved in the remote's `package.json`.
To update to a specific version, including one outside that range, pass it as `<name>@<version>`, for example `@clusterio/plugin-example@2.1.0`.
The version must be published on npm, and the remote then executes `npm install --save <name>@<version>`.
The plugin must already be installed on the remote, and only remote plugin updates need to be enabled.
Changes will not be applied until the remote is restarted.
If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
### Update All
@@ -297,10 +302,30 @@ Updates the Clusterio package and all plugins installed from npm in a single `np
### Install
ctl> controller plugin install <name> [--restart]
ctl> host plugin install <host> <name> [--restart]
ctl> controller plugin install <name>[@<version>] [--restart]
ctl> host plugin install <host> <name>[@<version>] [--restart]
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`.
Pass `<name>@<version>` to install a specific version, for example to match the version used by the rest of the cluster.
The package, and the version if given, must be published on npm.
Changes will not be applied until the remote is restarted.
If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
### Search
ctl> controller plugin search [query] [--page <n>] [--page-size <n>]
Searches the npm registry for packages tagged with the clusterio-plugin keyword.
The search is done by the controller and does not require remote installs to be enabled.
The web UI has a plugin browser on the plugins page that uses the same search.
### Concurrent Operations
Only one npm operation runs at a time on a machine.
A standalone controller and host share their working directory, so the operation holds the `npm-operation.lock` file there while npm runs.
Other installs and updates on the same machine or installation are rejected with "Another package operation is already in progress on this machine" until it finishes.
A lock file left behind by a process that is no longer running is removed automatically.
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
### Enable/Disable Updates
+3 -2
View File
@@ -400,7 +400,8 @@ async function initialize(): Promise<InitializeParameters> {
}
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = await lib.loadPluginList(args.pluginList);
const skippedNpmPlugins = new Map<string, string>();
let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins);
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
@@ -415,7 +416,7 @@ async function initialize(): Promise<InitializeParameters> {
}
logger.info("Loading Plugin info");
const pluginInfos = await lib.loadPluginInfos(pluginList);
const pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
+20 -8
View File
@@ -135,6 +135,8 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.PluginSearchRequest, this.handlePluginSearchRequest.bind(this));
this.handle(lib.PluginLatestVersionsRequest, this.handlePluginLatestVersionsRequest.bind(this));
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
}
@@ -197,6 +199,9 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.canRestart) {
throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it.");
}
if (await lib.packageOperationRunning()) {
throw new lib.RequestError("Cannot restart while a plugin install or update is running on this machine.");
}
const downgrade = await this._controller.checkRestartDowngrade();
if (downgrade) {
const { installedVersion, runningVersion } = downgrade;
@@ -1351,9 +1356,10 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.config.get("controller.allow_plugin_install")) {
throw new lib.RequestError("Plugin installs are disabled on this machine");
}
await lib.handlePluginInstall(request.pluginPackage);
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
const npmPackage = await lib.handlePluginInstall(request.pluginPackage);
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version
// changes to loaded plugins are noticed there.
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) {
this._controller.config.restartRequired = true;
}
}
@@ -1368,14 +1374,20 @@ export default class ControlConnection extends BaseConnection {
return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this._controller.loadedPlugins.has(pluginInfo),
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
async handlePluginSearchRequest(request: lib.PluginSearchRequest) {
return await lib.searchPlugins(request.query, request.page, request.pageSize);
}
async handlePluginLatestVersionsRequest(request: lib.PluginLatestVersionsRequest) {
return new lib.PluginLatestVersionsRequest.Response(await lib.fetchLatestVersions(
request.packages, this._controller.pluginLatestVersionCache,
));
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return await this._controller.listPlugins();
}
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
if (this.ws_dumper) {
return; // Already dumping
+14 -1
View File
@@ -75,6 +75,8 @@ export default class Controller {
hooks = new ControllerHooks(logger);
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
/** Latest versions of plugins on npm, as shown on the plugins page */
pluginLatestVersionCache: lib.LatestVersionCache = new Map();
/** WebSocket server */
wsServer: WsServer;
@@ -646,7 +648,9 @@ export default class Controller {
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
}
const restartRequired = await this.checkRestartRequired();
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired, this.startedAtMs));
requests.push(lib.gatherSystemInfo(
"controller", this.canRestart, restartRequired, this.startedAtMs, await this.listPlugins(),
));
const newMetrics = await Promise.all(requests);
for (const metric of newMetrics) {
this.systems.set(metric);
@@ -656,6 +660,15 @@ export default class Controller {
}
}
/** Plugins on the controller, including those installed or updated since it started */
async listPlugins() {
return await lib.listPluginDetails(
this.pluginInfos,
pluginInfo => this.loadedPlugins.has(pluginInfo),
pluginInfo => this.config.get(`${pluginInfo.name}.load_plugin`),
);
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
+21 -2
View File
@@ -180,7 +180,7 @@ controllerPluginCommands.add(new lib.Command({
}));
controllerPluginCommands.add(new lib.Command({
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.positional("plugin", { describe: "Plugin to update, optionally as name@version", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
@@ -192,9 +192,28 @@ controllerPluginCommands.add(new lib.Command({
}
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["search [query]", "Search npm for plugins", (yargs) => {
yargs.positional("query", { describe: "Text to search for", type: "string", default: "" });
yargs.option("page", { type: "number", description: "Page of results to show", default: 1 });
yargs.option("page-size", { type: "number", description: "Results per page", default: 20 });
}],
handler: async function(args: { query: string, page: number, pageSize: number }, ctl: Ctl) {
const response = await ctl.sendTo(
"controller", new lib.PluginSearchRequest(args.query, args.page, args.pageSize)
);
print(asTable(response.results.map(plugin => ({
name: plugin.name,
version: plugin.version,
publisher: plugin.publisher ?? "",
description: plugin.description ?? "",
}))));
print(`Showing ${response.results.length} of ${response.total} results`);
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.positional("plugin", { describe: "Plugin to install, optionally as name@version", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
+2 -2
View File
@@ -278,7 +278,7 @@ hostPluginCommands.add(new lib.Command({
hostPluginCommands.add(new lib.Command({
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to update on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.positional("plugin", { describe: "Plugin to update, optionally as name@version", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
@@ -294,7 +294,7 @@ hostPluginCommands.add(new lib.Command({
hostPluginCommands.add(new lib.Command({
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to install on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.positional("plugin", { describe: "Plugin to install, optionally as name@version", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
+3 -2
View File
@@ -164,7 +164,8 @@ async function startHost() {
}
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = await lib.loadPluginList(args.pluginList);
const skippedNpmPlugins = new Map<string, string>();
let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins);
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
@@ -173,7 +174,7 @@ async function startHost() {
}
logger.info("Loading Plugin info");
let pluginInfos = await lib.loadPluginInfos(pluginList);
let pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
+21 -9
View File
@@ -568,6 +568,9 @@ export default class Host extends lib.Link {
if (!this.canRestart) {
throw new lib.RequestError("Cannot restart, host does not have a process monitor to restart it.");
}
if (await lib.packageOperationRunning()) {
throw new lib.RequestError("Cannot restart while a plugin install or update is running on this machine.");
}
const downgrade = await this.checkRestartDowngrade();
if (downgrade) {
const { installedVersion, runningVersion } = downgrade;
@@ -952,7 +955,9 @@ export default class Host extends lib.Link {
async handleSystemInfoRequest() {
const restartRequired = await this.checkRestartRequired();
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs);
return lib.gatherSystemInfo(
this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs, await this.listPlugins(),
);
}
async checkRestartRequired() {
@@ -1264,9 +1269,10 @@ export default class Host extends lib.Link {
if (!this.config.get("host.allow_plugin_install")) {
throw new lib.RequestError("Plugin installs are disabled on this machine");
}
await lib.handlePluginInstall(request.pluginPackage);
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
const npmPackage = await lib.handlePluginInstall(request.pluginPackage);
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version
// changes to loaded plugins are noticed there.
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) {
this.config.restartRequired = true;
}
}
@@ -1282,11 +1288,17 @@ export default class Host extends lib.Link {
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this.loadedPlugins.has(pluginInfo),
this.config.get(`${pluginInfo.name}.load_plugin`),
));
return await this.listPlugins();
}
/** Plugins on this host, including those installed or updated since it started */
async listPlugins() {
return await lib.listPluginDetails(
this.pluginInfos,
pluginInfo => this.loadedPlugins.has(pluginInfo),
pluginInfo => this.config.get(`${pluginInfo.name}.load_plugin`),
pluginInfo => Boolean(pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint),
);
}
sendHostUpdate() {
+1
View File
@@ -35,6 +35,7 @@ export * from "./src/subscriptions.js";
export * from "./src/datastore.js";
export * from "./src/load_plugin_list.js";
export * from "./src/rce_ops.js";
export * from "./src/npm_registry.js";
export * from "./src/LockFile.js";
export * from "./src/AsyncHook.js";
export * from "./src/SyncHook.js";
+142 -31
View File
@@ -1,5 +1,5 @@
import { FileHandle, open, unlink, readFile } from "node:fs/promises";
import { unlinkSync } from "node:fs";
import fs, { type FileHandle } from "node:fs/promises";
import { readFileSync, unlinkSync } from "node:fs";
import assert from "assert";
/**
@@ -24,23 +24,99 @@ export class LockFileBusyError extends Error {
}
}
/**
* Error thrown when a process exited while clearing a stale lock, which needs the recovery file removed by hand.
*/
export class LockFileRecoveryError extends Error {
constructor(
readonly recoveryPath: string
) {
super(`Lock file recovery was interrupted, remove ${recoveryPath} to allow the lock to be acquired`);
this.name = "LockFileRecoveryError";
}
}
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
type LockSnapshot = { content: string, ino: bigint, mtimeNs: bigint };
/**
* Cross-platform lock file utility for Node.js.
*/
export class LockFile {
/**
* Content written to the lock file. The start time tells this process apart from an earlier one with
* the same pid, and is the same for every copy of lib loaded in this process.
*/
private static readonly content = `${process.pid} ${performance.timeOrigin}\n`;
// A lock file without a valid pid is left alone for this long, as it may still be being written
private static readonly writeGraceMs = 10e3;
private handle: FileHandle | null = null;
private state: LockState = "idle";
/**
* @param filePath - Path of the lock file
* @param handleSignals -
* Release the lock on SIGINT, SIGTERM, SIGHUP and uncaught exceptions. Listening to these
* changes how the process exits, so without it the lock is only removed on exit.
*/
constructor(
private readonly filePath: string
private readonly filePath: string,
private readonly handleSignals = true,
) {}
/** Content and identity of a lock file, read through one handle so both belong to the same file */
private static async readLock(filePath: string): Promise<LockSnapshot | undefined> {
let handle;
try {
handle = await fs.open(filePath, "r");
} catch (err: any) {
if (err.code === "ENOENT") {
return undefined;
}
throw err;
}
try {
const stat = await handle.stat({ bigint: true });
return { content: await handle.readFile("utf8"), ino: stat.ino, mtimeNs: stat.mtimeNs };
} finally {
await handle.close();
}
}
private static isSameLock(a: LockSnapshot, b: LockSnapshot) {
return a.content === b.content && a.ino === b.ino && a.mtimeNs === b.mtimeNs;
}
private static lockPid(lock: LockSnapshot) {
const pid = Number(lock.content.split(" ")[0]);
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
}
/** True if the lock names a process that is still running, or may still be written to */
private static lockIsLive(lock: LockSnapshot) {
const pid = LockFile.lockPid(lock);
if (pid === undefined) {
return Date.now() - Number(lock.mtimeNs / 1000000n) < LockFile.writeGraceMs;
}
if (pid === process.pid) {
// Held by this process, or left by an earlier process with the same pid
return lock.content === LockFile.content;
}
try {
// Signal 0 only checks that the process exists
process.kill(pid, 0);
return true;
} catch (err: any) {
return err.code !== "ESRCH";
}
}
/**
* Acquire the lock file.
* Throws LockFileExistsError if already locked.
* Throws LockFileBusyError if not in idle state.
* Throws LockFileRecoveryError if clearing a stale lock was interrupted.
*/
async acquire(): Promise<void> {
if (this.state !== "idle") {
@@ -49,13 +125,10 @@ export class LockFile {
this.state = "acquiring";
if (await this.isStale()) {
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
}
try {
this.handle = await open(this.filePath, "wx");
await this.handle.write(`${process.pid}\n`);
await this.removeStale();
this.handle = await fs.open(this.filePath, "wx");
await this.handle.write(LockFile.content);
this.attachExitHandlers();
this.state = "acquired";
} catch (err: any) {
@@ -81,7 +154,9 @@ export class LockFile {
// Ignored: file may have already been removed by another process
try { await this.handle?.close(); } catch {}
try { await unlink(this.filePath); } catch {}
if (await this.isHeldFile()) {
await fs.unlink(this.filePath).catch(() => {});
}
this.handle = null;
this.state = "idle";
@@ -89,19 +164,20 @@ export class LockFile {
/**
* Check whether the lock is currently held by this process.
* True only if the lock is acquired and the PID in the file
* matches the current process.
* True only if the lock is acquired and the lock file was written by this process.
*/
async isHeld(): Promise<boolean> {
if (this.state !== "acquired") {
return false;
}
return await this.isHeldFile();
}
private async isHeldFile() {
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
return pidInLock === process.pid;
return (await LockFile.readLock(this.filePath))?.content === LockFile.content;
} catch {
// Ignored: file may not exist or be unreadable, so lock is not held
// Ignored: file may be unreadable, so lock is not held
return false;
}
}
@@ -110,14 +186,45 @@ export class LockFile {
* Determine whether an existing lock is stale.
*/
async isStale(): Promise<boolean> {
const lock = await LockFile.readLock(this.filePath);
return lock === undefined || !LockFile.lockIsLive(lock);
}
/** Pid of the process holding the lock, if it has written it */
async holderPid(): Promise<number | undefined> {
const lock = await LockFile.readLock(this.filePath);
return lock && LockFile.lockPid(lock);
}
/**
* Remove a stale lock file if it is still the same file. Clearing is done while holding a separate
* recovery lock, so a lock another process took after the stale check is never removed.
*/
private async removeStale(): Promise<void> {
const stale = await LockFile.readLock(this.filePath);
if (!stale || LockFile.lockIsLive(stale)) {
return;
}
const recoveryPath = `${this.filePath}.recovery`;
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
if (!Number.isFinite(pidInLock)) { return true; }
// signal 0 is a special case to test if a process exists
process.kill(pidInLock, 0);
return false;
} catch {
return true;
await fs.writeFile(recoveryPath, LockFile.content, { flag: "wx" });
} catch (err: any) {
if (err.code !== "EEXIST") {
throw err;
}
const holder = await LockFile.readLock(recoveryPath);
if (holder && !LockFile.lockIsLive(holder)) {
throw new LockFileRecoveryError(recoveryPath);
}
return; // Another process is clearing it
}
try {
const current = await LockFile.readLock(this.filePath);
if (current && LockFile.isSameLock(current, stale)) {
await fs.unlink(this.filePath);
}
} finally {
await fs.unlink(recoveryPath).catch(() => {});
}
}
@@ -126,10 +233,12 @@ export class LockFile {
*/
private attachExitHandlers(): void {
assert(this.state === "acquiring");
process.on("SIGINT", this.handleExit);
process.on("SIGTERM", this.handleExit);
process.on("SIGHUP", this.handleExit);
process.on("uncaughtException", this.handleExit);
if (this.handleSignals) {
process.on("SIGINT", this.handleExit);
process.on("SIGTERM", this.handleExit);
process.on("SIGHUP", this.handleExit);
process.on("uncaughtException", this.handleExit);
}
// eslint-disable-next-line node/no-sync
process.on("exit", this.handleExitSync);
}
@@ -137,7 +246,7 @@ export class LockFile {
/**
* Detach process handlers, called when transitioning to "idle"
*/
private async detachExitHandlers(): Promise<void> {
private detachExitHandlers(): void {
assert(this.state === "releasing");
process.off("SIGINT", this.handleExit);
process.off("SIGTERM", this.handleExit);
@@ -147,17 +256,19 @@ export class LockFile {
process.off("exit", this.handleExitSync);
}
// Event handler for async signals / exceptions
// Event handler for async signals / exceptions
private handleExit = async (): Promise<void> => {
await this.release();
};
// Event handler for synchronous process exit
// Event handler for synchronous process exit, readLock can't be used as it is async
private handleExitSync = (): void => {
try {
unlinkSync(this.filePath);
if (readFileSync(this.filePath, "utf8") === LockFile.content) {
unlinkSync(this.filePath);
}
} catch (err) {
// Ignored: file may have already been removed by another process
// Ignored: file may have already been removed by another process
}
};
}
@@ -3,6 +3,7 @@ import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js";
import { type LogEntry, levels } from "../logging.js";
import { ControllerConfig, HostConfig } from "../config/index.js";
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js";
import { PluginDetails } from "./messages_plugin.js";
const LogEntrySchema = Type.Object({
"level": StringKey(levels),
@@ -298,6 +299,8 @@ export class SystemInfo {
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs: number,
public isDeleted: boolean,
/** Plugins on the machine when last collected, kept while it is offline */
public plugins?: PluginDetails[],
) { }
static jsonSchema = Type.Object({
@@ -318,6 +321,7 @@ export class SystemInfo {
"processStartedAtMs": Type.Number(),
"updatedAtMs": Type.Number(),
"isDeleted": Type.Boolean(),
"plugins": Type.Optional(Type.Array(PluginDetails.jsonSchema)),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -339,6 +343,7 @@ export class SystemInfo {
json.processStartedAtMs,
json.updatedAtMs,
json.isDeleted,
json.plugins?.map(plugin => PluginDetails.fromJSON(plugin)),
);
}
+137 -1
View File
@@ -14,6 +14,18 @@ export class PluginDetails {
public enabled: boolean,
public description?: string,
public npmPackage?: string,
/** Version installed on disk, differs from version when an update is waiting for a restart */
public installedVersion?: string,
/** Installed after the machine started, will be loaded on the next restart */
public pendingInstall?: boolean,
/** Uninstalled after the machine started, will not be loaded on the next restart */
public removed?: boolean,
/** Name in the root package.json dependencies, differs from npmPackage for aliases */
public dependencyName?: string,
/** Has no entrypoint for this machine, so it is never loaded here */
public noEntrypoint?: boolean,
/** Not loaded as another package provides a plugin with the same name */
public nameTaken?: boolean,
) {}
static jsonSchema = Type.Object({
@@ -24,12 +36,19 @@ export class PluginDetails {
enabled: Type.Boolean(),
description: Type.Optional(Type.String()),
npmPackage: Type.Optional(Type.String()),
installedVersion: Type.Optional(Type.String()),
pendingInstall: Type.Optional(Type.Boolean()),
removed: Type.Optional(Type.Boolean()),
dependencyName: Type.Optional(Type.String()),
noEntrypoint: Type.Optional(Type.Boolean()),
nameTaken: Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name, json.title, json.version, json.loaded, json.enabled,
json.description, json.npmPackage
json.description, json.npmPackage, json.installedVersion, json.pendingInstall, json.removed,
json.dependencyName, json.noEntrypoint, json.nameTaken,
);
}
@@ -39,6 +58,15 @@ export class PluginDetails {
pluginInfo.description, pluginInfo.npmPackage,
);
}
/** Plugin package installed since startup, it is loaded on the next restart */
static fromPackageJson(packageName: string, packageJson: any, npmPackage?: string, dependencyName?: string) {
const description = typeof packageJson.description === "string" ? packageJson.description : undefined;
return new this(
packageName, packageName, packageJson.version, false, false,
description, npmPackage, packageJson.version, true, undefined, dependencyName,
);
}
}
export class PluginListRequest {
@@ -94,6 +122,114 @@ export class PluginInstallRequest {
}
}
/* Plugin package listed on the npm registry */
export class PluginSearchResult {
constructor(
public name: string,
public version: string,
public description?: string,
public publisher?: string,
public date?: string,
public homepage?: string,
public repository?: string,
public npm?: string,
) {}
static jsonSchema = Type.Object({
name: Type.String(),
version: Type.String(),
description: Type.Optional(Type.String()),
publisher: Type.Optional(Type.String()),
date: Type.Optional(Type.String()),
homepage: Type.Optional(Type.String()),
repository: Type.Optional(Type.String()),
npm: Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name, json.version, json.description, json.publisher,
json.date, json.homepage, json.repository, json.npm,
);
}
}
export class PluginSearchRequest {
declare ["constructor"]: typeof PluginSearchRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.plugin.search";
constructor(
public query: string = "",
public page: number = 1,
public pageSize: number = 20,
) {}
static jsonSchema = Type.Object({
query: Type.String(),
page: Type.Integer(),
pageSize: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.query, json.page, json.pageSize);
}
static Response = class Response {
constructor(
public total: number,
public results: PluginSearchResult[],
) {}
static jsonSchema = Type.Object({
total: Type.Integer(),
results: Type.Array(PluginSearchResult.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.total, json.results.map(r => PluginSearchResult.fromJSON(r)));
}
};
}
/* Latest version of each package published on npm, used to offer plugin updates */
export class PluginLatestVersionsRequest {
declare ["constructor"]: typeof PluginLatestVersionsRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.plugin.list";
constructor(
public packages: string[],
) {}
static jsonSchema = Type.Object({
packages: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.packages);
}
static Response = class Response {
constructor(
/** Package name to its latest version, packages not found on npm are left out */
public versions: Record<string, string>,
) {}
static jsonSchema = Type.Object({
versions: Type.Record(Type.String(), Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.versions);
}
};
}
export class UpdateAllRequest {
declare ["constructor"]: typeof UpdateAllRequest;
static type = "request" as const;
+51
View File
@@ -321,6 +321,57 @@ export function redactUrl(url: URL | string) {
return isPath ? parsed.pathname + parsed.search + parsed.hash : parsed.toString();
}
const gitProtocols = new Set(["git:", "git+http:", "git+https:", "git+ssh:", "ssh:"]);
const repositoryShorthands = new Map([
["github:", "https://github.com/"],
["gitlab:", "https://gitlab.com/"],
["bitbucket:", "https://bitbucket.org/"],
]);
/**
* Convert a link from untrusted metadata, like a package's homepage or repository, into a URL
* that is safe to use as the href of a link
*
* Only http and https URLs are returned, so links like `javascript:` can't run code when
* clicked. Accepts the formats npm uses for repositories: a string or an object with a `url`
* property, `git+https://`, `git://` and `git+ssh://` URLs, `git@host:path` and shorthands
* like `github:owner/repo`, which are converted to https URLs. Credentials are removed.
*
* @param link - URL string or object with a `url` property.
* @returns http(s) URL, or undefined if the link can't be converted to one.
*/
export function safeExternalUrl(link: unknown): string | undefined {
const value = typeof link === "object" && link !== null ? (link as { url?: unknown }).url : link;
if (typeof value !== "string") {
return undefined;
}
let url = value.trim();
const scpLike = /^[\w.-]+@([\w.-]+):(?!\/)(.+)$/.exec(url);
if (scpLike) {
url = `ssh://${scpLike[1]}/${scpLike[2]}`;
}
let parsed: URL;
try {
parsed = new URL(url);
const shorthand = repositoryShorthands.get(parsed.protocol);
if (shorthand) {
parsed = new URL(url.slice(parsed.protocol.length).replace(/^\/+/, ""), shorthand);
} else if (gitProtocols.has(parsed.protocol)) {
// The protocol setter can't change a URL into an https URL, so parse it again
parsed = new URL(`https://${parsed.host}${parsed.pathname.replace(/\.git$/, "")}`);
}
} catch {
return undefined;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
parsed.username = "";
parsed.password = "";
return parsed.href;
}
/**
* Copy of parsed query parameters with credentials replaced by `REDACTED`
*
+2
View File
@@ -147,5 +147,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
plugin.PluginListRequest,
plugin.PluginUpdateRequest,
plugin.PluginInstallRequest,
plugin.PluginSearchRequest,
plugin.PluginLatestVersionsRequest,
plugin.UpdateAllRequest,
];
+206 -103
View File
@@ -5,6 +5,96 @@ import { logger } from "./logging.js";
import * as libFileOps from "./file_ops.js";
import vm from "vm";
const corePackages = new Set([
"@clusterio/controller",
"@clusterio/ctl",
"@clusterio/host",
"@clusterio/lib",
"@clusterio/web_ui",
]);
function isPluginPackageJson(packageJson: any): boolean {
return Boolean(packageJson?.keywords?.includes?.("clusterio-plugin"));
}
/**
* Read the package.json in a package directory.
* @param packagePath - Directory of the package
* @param tolerant - Return undefined instead of throwing if the file can't be parsed, as npm may be writing it
* @returns The parsed package.json, or undefined if it does not exist
*/
export async function readPackageJson(packagePath: string, tolerant: boolean): Promise<any> {
let content;
try {
content = await fs.readFile(path.join(packagePath, "package.json"), "utf8");
} catch (err: any) {
return undefined;
}
try {
return JSON.parse(content);
} catch (err: any) {
if (tolerant) {
return undefined;
}
throw err;
}
}
/** Dependencies npm installs, an optionalDependencies entry wins over a dependencies entry of the same name */
export function installedDependencies(packageJson: any): Record<string, unknown> {
return { ...packageJson?.dependencies, ...packageJson?.optionalDependencies };
}
/** Whether a dependency spec installs from the registry, as opposed to a path, git, url, tarball or alias */
export function isRegistrySpec(spec: unknown) {
return typeof spec === "string" && !/[:/\\]/.test(spec) && !spec.startsWith(".")
&& !/\.(?:tgz|tar\.gz|tar)$/i.test(spec);
}
/** Real path of a directory, used to compare packages as npm and pnpm install them behind symlinks */
export async function realDir(dir: string) {
try {
return await fs.realpath(dir);
} catch {
// Removed since, nothing else can be at this path
return path.resolve(dir);
}
}
/** Directory of a package as require resolves it from fromPath, or undefined if not found */
export function resolvePackagePath(packageName: string, fromPath: string, tolerant: boolean): string | undefined {
const packageRequire = createRequire(path.join(fromPath, "package.json"));
try {
return path.dirname(packageRequire.resolve(`${packageName}/package.json`));
} catch (err: any) {
// A package.json npm is still writing fails with ERR_INVALID_PACKAGE_CONFIG
if (tolerant || err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
return undefined;
}
throw err;
}
}
async function pluginPathExists(pluginPath: string) {
if (!path.isAbsolute(pluginPath)) {
return resolvePackagePath(pluginPath, process.cwd(), true) !== undefined;
}
try {
await fs.access(path.join(pluginPath, "package.json"));
return true;
} catch {
return false;
}
}
const require = createRequire(import.meta.url);
function getPluginName(requireSpec: string) {
const context = vm.createContext({ require: require, pluginInfo: null });
const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`;
vm.runInContext(code, context);
return context.pluginInfo.name;
}
/**
* Searches for local plugins in the plugins/ and external_plugins/ directories
* and adds them to the plugin list if they contain a package.json file.
@@ -49,7 +139,7 @@ async function findLocalPlugins(pluginList: Map<string, string>): Promise<boolea
const stats = await fs.stat(pluginPath);
if (stats.isDirectory()) {
// Check for package.json and plugin keyword
if (await checkPackageJson(pluginPath)) {
if (isPluginPackageJson(await readPackageJson(pluginPath, false))) {
pluginList.set(pluginName, path.resolve(pluginPath));
logger.info(`Added ${pluginName} from ${pluginFolder}`);
hasChanged += 1;
@@ -67,24 +157,85 @@ async function findLocalPlugins(pluginList: Map<string, string>): Promise<boolea
return hasChanged > 0;
}
const require = createRequire(import.meta.url);
function getPluginName(requireSpec: string) {
const context = vm.createContext({ require: require, pluginInfo: null });
const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`;
vm.runInContext(code, context);
return context.pluginInfo.name;
type NpmPluginPackage = {
packageName: string,
packageVersion: unknown,
packagePath: string,
packageJson: any,
};
/**
* Walk the dependencies of the root package.json and of every plugin package found, without
* traversing packages that aren't plugins. Does not execute any plugin code.
*
* @param dependencies - Dependencies of the root package.json
* @param tolerant - Skip packages with a package.json that can't be parsed instead of throwing,
* for use while npm may be rewriting them
* @returns Plugin packages found, and root dependencies that are not plugins
*/
export async function discoverNpmPluginPackages(dependencies: Record<string, unknown>, tolerant = false) {
const plugins: NpmPluginPackage[] = [];
const nonPluginRoots: { packageName: string, packageVersion: unknown }[] = [];
const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({
packageName,
packageVersion,
parentPath: process.cwd(),
isRootDependency: true,
}));
const visitedPackages = new Set<string>();
// Directories some dependent installs from a path, git, url, tarball or alias
const nonRegistryDirs = new Set<string>();
while (pendingPackages.length) {
const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!;
const packagePath = resolvePackagePath(packageName, parentPath, tolerant);
if (!packagePath) {
continue;
}
if (!isRegistrySpec(packageVersion)) {
nonRegistryDirs.add(await realDir(packagePath));
}
if (visitedPackages.has(packageName)) {
continue;
}
visitedPackages.add(packageName);
const packageJson = await readPackageJson(packagePath, tolerant);
if (packageJson && isPluginPackageJson(packageJson)) {
plugins.push({ packageName, packageVersion, packagePath, packageJson });
for (const [dependencyName, dependencyVersion] of Object.entries(installedDependencies(packageJson))) {
pendingPackages.push({
packageName: dependencyName,
packageVersion: dependencyVersion,
parentPath: packagePath,
isRootDependency: false,
});
}
} else if (isRootDependency) {
nonPluginRoots.push({ packageName, packageVersion });
}
}
return { plugins, nonPluginRoots, nonRegistryDirs };
}
function resolvePackagePath(packageName: string, fromPath: string): string | undefined {
const packageRequire = createRequire(path.join(fromPath, "package.json"));
try {
return path.dirname(packageRequire.resolve(`${packageName}/package.json`));
} catch (err: any) {
if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
return undefined;
}
throw err;
/** Real paths of plugin packages some dependent installs from a path, git, url, tarball or alias */
export async function findNonRegistryDirs() {
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
return (await discoverNpmPluginPackages(dependencies, true)).nonRegistryDirs;
}
/** Whether npm install name would replace a plugin package not installed from the registry */
export async function isNonRegistryPackage(name: string) {
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
if (Object.hasOwn(dependencies, name) && !isRegistrySpec(dependencies[name])) {
return true;
}
const packagePath = resolvePackagePath(name, process.cwd(), true);
if (!packagePath) {
return false;
}
return (await findNonRegistryDirs()).has(await realDir(packagePath));
}
/**
@@ -92,79 +243,46 @@ function resolvePackagePath(packageName: string, fromPath: string): string | und
* Adds discovered plugins to the plugin list.
*
* @param pluginList - Map of plugin names to their file paths
* @param skippedNpmPlugins - Filled with the package directory by plugin name for packages
* skipped because their plugin name was already in the list
* @returns Promise that resolves to true if any plugins were added to the list, false otherwise
*/
async function findNpmPlugins(pluginList: Map<string, string>): Promise<boolean> {
let dependencies;
try {
const rootPackageJson = JSON.parse(await fs.readFile(path.resolve("package.json"), { encoding: "utf8" }));
dependencies = rootPackageJson.dependencies;
} catch (err: any) {
if (err.code === "ENOENT") {
// Skip if package.json doesn't exist
return false;
}
throw err;
}
if (!dependencies) {
async function findNpmPlugins(
pluginList: Map<string, string>,
skippedNpmPlugins?: Map<string, string>,
): Promise<boolean> {
const rootPackageJson = await readPackageJson(process.cwd(), false);
if (!rootPackageJson) {
return false;
}
const dependencies = installedDependencies(rootPackageJson);
let hasChanged = 0;
const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({
packageName,
packageVersion,
parentPath: process.cwd(),
isRootDependency: true,
}));
const corePackages = new Set([
"@clusterio/controller",
"@clusterio/ctl",
"@clusterio/host",
"@clusterio/lib",
"@clusterio/web_ui",
]);
const visitedPackages = new Set<string>();
while (pendingPackages.length) {
const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!;
if (visitedPackages.has(packageName)) {
const { plugins, nonPluginRoots } = await discoverNpmPluginPackages(dependencies);
for (const { packageName, packageVersion, packagePath } of plugins) {
const pluginName = getPluginName(packagePath);
const listedPath = pluginList.get(pluginName);
if (listedPath === packageName) {
continue;
}
const npmPackagePath = resolvePackagePath(packageName, parentPath);
if (!npmPackagePath) {
continue;
}
visitedPackages.add(packageName);
if (await checkPackageJson(npmPackagePath)) {
const pluginName = getPluginName(npmPackagePath);
if (pluginList.has(pluginName)) {
logger.warn(
`${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` +
`but with a different path - using ${pluginList.get(pluginName)}`
);
} else {
pluginList.set(pluginName, packageName);
logger.info(`Added ${pluginName} from node modules`);
hasChanged += 1;
}
const packageJson = JSON.parse(
await fs.readFile(path.join(npmPackagePath, "package.json"), { encoding: "utf8" })
if (listedPath !== undefined && !await pluginPathExists(listedPath)) {
logger.warn(`${pluginName} at ${listedPath} no longer exists, using ${packageName}`);
pluginList.set(pluginName, packageName);
hasChanged += 1;
} else if (pluginList.has(pluginName)) {
logger.warn(
`${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` +
`but with a different path - using ${pluginList.get(pluginName)}`
);
for (const [dependencyName, dependencyVersion] of Object.entries(packageJson.dependencies ?? {})) {
pendingPackages.push({
packageName: dependencyName,
packageVersion: dependencyVersion,
parentPath: npmPackagePath,
isRootDependency: false,
});
}
} else if (isRootDependency && !corePackages.has(packageName)) {
skippedNpmPlugins?.set(pluginName, await realDir(packagePath));
} else {
pluginList.set(pluginName, packageName);
logger.info(`Added ${pluginName} from node modules`);
hasChanged += 1;
}
}
for (const { packageName, packageVersion } of nonPluginRoots) {
if (!corePackages.has(packageName)) {
logger.warn(`${packageName}@${packageVersion} is not a clusterio-plugin`);
}
}
@@ -176,9 +294,15 @@ async function findNpmPlugins(pluginList: Map<string, string>): Promise<boolean>
*
* @param pluginListPath - Path to the plugin list JSON file
* @param findLocal - Whether to look for local plugins in plugins/ and external_plugins/ directories
* @param skippedNpmPlugins - Filled with the package directory by plugin name for npm packages
* skipped because another plugin has the same name, for loadPluginInfos
* @returns Map of plugin name to plugin path
*/
export async function loadPluginList(pluginListPath: string, findLocal = true): Promise<Map<string, string>> {
export async function loadPluginList(
pluginListPath: string,
findLocal = true,
skippedNpmPlugins?: Map<string, string>,
): Promise<Map<string, string>> {
let pluginList = new Map<string, string>();
// Try to load existing plugin list
@@ -201,7 +325,7 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
}
// Discover NPM plugins
hasChanges = await findNpmPlugins(pluginList) || hasChanges;
hasChanges = await findNpmPlugins(pluginList, skippedNpmPlugins) || hasChanges;
if (hasChanges) {
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
@@ -209,24 +333,3 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
return pluginList;
}
/**
* Helper function to check if a package.json file exists and contains the clusterio-plugin keyword
* @param pluginPath - Path to the plugin directory
* @returns Promise that resolves to true if package.json exists and has clusterio-plugin keyword
*/
async function checkPackageJson(pluginPath: string): Promise<boolean> {
const packageJsonPath = path.join(pluginPath, "package.json");
// Read and parse package.json
let content;
try {
content = await fs.readFile(packageJsonPath, { encoding: "utf8" });
} catch (err: any) {
return false;
}
const packageJson = JSON.parse(content);
// Check if package has the clusterio-plugin keyword
return packageJson.keywords?.includes("clusterio-plugin");
}
+159
View File
@@ -0,0 +1,159 @@
import { logger } from "./logging.js";
import { RequestError } from "./errors.js";
import { PluginSearchRequest, PluginSearchResult } from "./data/messages_plugin.js";
const registryTimeoutMs = 10e3;
// One name segment, optionally after an @scope/ segment. Not starting with . or - keeps npm from
// reading the spec as a path, a git repository or a command line option.
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
const packageNamePattern = /^(?:@[a-zA-Z0-9][a-zA-Z0-9\-._~]*\/)?[a-zA-Z0-9][a-zA-Z0-9\-._~]*$/;
/**
* Split a package spec into its name and optional version, as in name@version. The name itself
* may start with @ for scoped packages.
*/
export function parsePackageSpec(spec: string) {
const versionSeparator = spec.lastIndexOf("@");
const [name, version] = versionSeparator > 0
? [spec.slice(0, versionSeparator), spec.slice(versionSeparator + 1)]
: [spec, undefined];
if (name.length > 214 || !packageNamePattern.test(name)) {
throw new RequestError(`Invalid plugin name: ${spec}`);
}
if (version !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9\-.+]*$/.test(version)) {
throw new RequestError(`Invalid plugin version: ${version}`);
}
return { name, version };
}
/** Throws unless the package, and version if given, is published on npm, returns the spec to install */
export async function checkRegistry(name: string, version?: string) {
const packageName = encodeURI(name);
const spec = version !== undefined ? `${packageName}@${version}` : packageName;
const registryPath = version !== undefined ? `${packageName}/${encodeURIComponent(version)}` : packageName;
const npmRequest = await fetch(`https://registry.npmjs.com/${registryPath}`, {
method: "HEAD",
signal: AbortSignal.timeout(registryTimeoutMs),
});
if (!npmRequest.ok) {
throw new RequestError(`Unknown plugin: ${spec}`);
}
return spec;
}
const latestVersionCacheMs = 5 * 60e3;
const latestVersionCacheMaxSize = 1000;
const latestVersionConcurrency = 8;
/** Latest version of each package by name, as found on npm, with when it was looked up */
export type LatestVersionCache = Map<string, { version?: string, fetchedAt: number }>;
/**
* Look up the latest published version of each package on npm, packages that can't be
* found are left out. Lookups are kept in the given cache for a few minutes.
*/
export async function fetchLatestVersions(packages: string[], cache: LatestVersionCache) {
if (packages.length > 500) {
throw new RequestError("Too many packages");
}
const names = [...new Set(packages.map(packageName => {
const { name, version: pinned } = parsePackageSpec(packageName);
if (pinned !== undefined) {
throw new RequestError(`Invalid plugin name: ${packageName}`);
}
return name;
}))];
const now = Date.now();
for (const [name, cached] of cache) {
if (now - cached.fetchedAt > latestVersionCacheMs) {
cache.delete(name);
}
}
const versions: Record<string, string> = {};
const queue = [...names];
async function lookUp(name: string) {
let cached = cache.get(name);
if (!cached) {
let response;
try {
response = await fetch(`https://registry.npmjs.com/${name}/latest`, {
signal: AbortSignal.timeout(registryTimeoutMs),
});
} catch (err: any) {
logger.warn(`Failed to look up latest version of ${name}: ${err.message}`);
return;
}
if (!response.ok && response.status !== 404) {
// Rate limits and outages are retried on the next lookup instead of cached
logger.warn(`Failed to look up latest version of ${name}: ${response.status}`);
return;
}
const version = response.ok ? (await response.json() as { version?: string }).version : undefined;
cached = { version, fetchedAt: now };
for (const oldest of cache.keys()) {
if (cache.size < latestVersionCacheMaxSize) {
break;
}
cache.delete(oldest);
}
cache.set(name, cached);
}
if (cached.version) {
versions[name] = cached.version;
}
}
await Promise.all(Array.from({ length: latestVersionConcurrency }, async () => {
for (let name = queue.shift(); name !== undefined; name = queue.shift()) {
await lookUp(name);
}
}));
return versions;
}
const npmSearchMaxSize = 250; // Limit imposed by the registry
export async function searchPlugins(query: string, page: number, pageSize: number) {
if (query.length > 214) {
throw new RequestError("Search query too long");
}
if (page < 1 || pageSize < 1 || pageSize > npmSearchMaxSize) {
throw new RequestError("Invalid page or page size");
}
const url = new URL("https://registry.npmjs.com/-/v1/search");
url.searchParams.set("text", `keywords:clusterio-plugin ${query}`.trim());
url.searchParams.set("size", String(pageSize));
url.searchParams.set("from", String((page - 1) * pageSize));
const response = await fetch(url, { signal: AbortSignal.timeout(registryTimeoutMs) });
if (!response.ok) {
throw new RequestError(`npm registry search failed: ${response.status} ${response.statusText}`);
}
const json = await response.json() as {
total: number,
objects: {
package: {
name: string,
version: string,
description?: string | null,
date?: string | null,
publisher?: { username?: string | null } | null,
links?: { npm?: string | null, homepage?: string | null, repository?: string | null } | null,
},
}[],
};
return new PluginSearchRequest.Response(
json.total,
json.objects.map(({ package: pkg }) => new PluginSearchResult(
// The registry sends null for some missing fields
pkg.name, pkg.version, pkg.description ?? undefined, pkg.publisher?.username ?? undefined,
pkg.date ?? undefined, pkg.links?.homepage ?? undefined, pkg.links?.repository ?? undefined,
pkg.links?.npm ?? undefined,
)),
);
}
+5
View File
@@ -583,6 +583,11 @@ export const corePermissions = [
title: "Install plugin",
description: "Remotely install a plugin if the target allows remote installs of plugins.",
},
{
name: "core.plugin.search",
title: "Search plugins",
description: "Search the npm registry for plugins via the controller.",
},
{
name: "core.debug.dump_ws",
+5
View File
@@ -82,6 +82,11 @@ export type PluginNodeEnvInfo = PluginDeclaration & {
* package.
*/
npmPackage?: string;
/**
* Directory of an npm package providing a plugin with the same name
* that was skipped on startup as this plugin was already in the list.
*/
skippedNpmPackage?: string;
};
export type PluginWebpackEnvInfo = PluginDeclaration & {
+103 -2
View File
@@ -13,6 +13,10 @@ import { loadPluginEntrypoint, loadPluginClass } from "./loadPlugin.js";
import { Config } from "./config/classes.js";
import { ControllerConfig, HostConfig, InstanceConfig } from "./config/definitions.js";
import Role from "./data/Role.js";
import { PluginDetails } from "./data/messages_plugin.js";
import {
discoverNpmPluginPackages, findNonRegistryDirs, installedDependencies, readPackageJson, realDir, resolvePackagePath,
} from "./load_plugin_list.js";
/**
@@ -24,10 +28,13 @@ import Role from "./data/Role.js";
*
* @param pluginList -
* Mapping of plugin name to require path for the plugins to load.
* @param skippedNpmPlugins -
* Directory of the npm package skipped by plugin name, from loadPluginList.
* @returns Array of plugin info modules.
*/
export async function loadPluginInfos(pluginList: Map<string, string>) {
export async function loadPluginInfos(pluginList: Map<string, string>, skippedNpmPlugins?: Map<string, string>) {
let plugins: libPlugin.PluginNodeEnvInfo[] = [];
const nonRegistryDirs = await findNonRegistryDirs();
for (let [pluginName, pluginPath] of pluginList) {
let pluginInfo: libPlugin.PluginNodeEnvInfo;
let pluginPackage: { name?: string, version: string, main?: string, private?: boolean };
@@ -87,12 +94,106 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
pluginInfo.packagePath = packagePath;
pluginInfo.requirePath = pluginPath;
pluginInfo.version = pluginPackage.version;
pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name ? pluginPath : undefined;
pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name
&& !nonRegistryDirs.has(await fs.realpath(path.dirname(packagePath))) ? pluginPath : undefined;
pluginInfo.skippedNpmPackage = skippedNpmPlugins?.get(pluginName);
plugins.push(pluginInfo);
}
return plugins;
}
/**
* Lists the plugins on this machine along with the versions currently installed on disk,
* including npm plugins installed since startup that will only be loaded after a restart.
*
* @param pluginInfos - Plugins discovered when this machine started
* @param isLoaded - Whether the plugin is loaded
* @param isEnabled - Whether the plugin is enabled
* @param hasEntrypoint - Whether the plugin has an entrypoint this machine loads
* @returns Details of every plugin on this machine
*/
export async function listPluginDetails(
pluginInfos: libPlugin.PluginNodeEnvInfo[],
isLoaded: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean,
isEnabled: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean,
hasEntrypoint: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean = () => true,
): Promise<PluginDetails[]> {
const plugins: PluginDetails[] = [];
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
// By directory, as a plugin loaded from a path may also be an npm dependency
const knownDirs = new Set<string>();
// Packages skipped on startup by directory, for plugins still installed
const takenDirs = new Set<string>();
const { plugins: npmPlugins, nonRegistryDirs } = await discoverNpmPluginPackages(dependencies, true);
for (const pluginInfo of pluginInfos) {
const dir = await realDir(path.dirname(pluginInfo.packagePath));
knownDirs.add(dir);
// Without an entrypoint for this machine the plugin has no load_plugin field here
const loadable = hasEntrypoint(pluginInfo);
const plugin = PluginDetails.fromNodeEnvInfo(
pluginInfo, isLoaded(pluginInfo), loadable && isEnabled(pluginInfo),
);
if (!loadable) {
plugin.noEntrypoint = true;
}
if (Object.hasOwn(dependencies, pluginInfo.requirePath)) {
plugin.dependencyName = pluginInfo.requirePath;
}
if (nonRegistryDirs.has(dir)) {
plugin.npmPackage = undefined;
}
let packageJson;
try {
packageJson = await readPackageJson(path.dirname(pluginInfo.packagePath), false);
if (packageJson === undefined) {
// Uninstalled since startup, it won't be found on the next restart
plugin.removed = true;
}
} catch {
// Ignored: npm is still writing it
}
if (packageJson) {
plugin.installedVersion = packageJson.version;
if (packageJson.name !== plugin.npmPackage) {
// Replaced by an alias since startup
plugin.npmPackage = undefined;
}
}
if (!plugin.removed && pluginInfo.skippedNpmPackage !== undefined) {
takenDirs.add(pluginInfo.skippedNpmPackage);
}
plugins.push(plugin);
}
for (const { packageName, packagePath, packageJson } of npmPlugins) {
const dir = await realDir(packagePath);
if (knownDirs.has(dir)) {
continue;
}
// Startup loads it by package name from the root, which doesn't reach nested packages
const rootPath = resolvePackagePath(packageName, process.cwd(), true);
if (!rootPath || await realDir(rootPath) !== dir) {
continue;
}
if (typeof packageJson.version !== "string") {
continue;
}
// Same rule as loadPluginInfos, aliases, private and non-registry packages can't be installed by this name
const npmPackage = !packageJson.private && packageJson.name === packageName && !nonRegistryDirs.has(dir)
? packageName : undefined;
const dependencyName = Object.hasOwn(dependencies, packageName) ? packageName : undefined;
const plugin = PluginDetails.fromPackageJson(packageName, packageJson, npmPackage, dependencyName);
if (takenDirs.has(dir)) {
// Skipped on startup as its plugin name is taken, will be again
plugin.pendingInstall = undefined;
plugin.nameTaken = true;
}
plugins.push(plugin);
}
return plugins;
}
/**
* Load the migration entrypoints of plugins
*
+79 -24
View File
@@ -4,6 +4,9 @@ import { exec } from "child_process";
import { logger } from "./logging.js";
import { RequestError } from "./errors.js";
import type { PluginNodeEnvInfo } from "./plugin.js";
import { isNonRegistryPackage } from "./load_plugin_list.js";
import { LockFile, LockFileExistsError, LockFileRecoveryError } from "./LockFile.js";
import { checkRegistry, parsePackageSpec } from "./npm_registry.js";
const execAsync = util.promisify(exec);
function isDev() {
@@ -12,10 +15,55 @@ function isDev() {
return import.meta.dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
}
async function logExec(cmd: string) {
logger.audit(`RCE | ${cmd}`);
if (!isDev()) {
await execAsync(cmd);
const operationInProgressMessage = "Another package operation is already in progress on this machine";
let operationInProgress = false;
/**
* Run an npm command, at most one at a time per installation. npm runs in the working directory,
* which a standalone controller and host share, so a lock file there guards against operations
* from other processes and other copies of lib. The flag makes concurrent calls through this copy
* reject without racing a release of the lock.
*
* @param cmd - Command to run
* @param check - Called while holding the lock before running the command, may throw to cancel it
*/
async function logExec(cmd: string, check?: () => Promise<void>) {
// Checked and set before the first await so concurrent calls in this process can't interleave
if (operationInProgress) {
throw new RequestError(operationInProgressMessage);
}
operationInProgress = true;
const lockPath = path.resolve("npm-operation.lock");
// Signal listeners would change how the process exits. If only this process is killed npm keeps
// running unlocked, Ctrl+C, systemd and containers stop it too.
const lock = new LockFile(lockPath, false);
try {
try {
await lock.acquire();
} catch (err) {
if (err instanceof LockFileRecoveryError) {
throw new RequestError(err.message);
}
if (!(err instanceof LockFileExistsError)) {
throw err;
}
const holder = await lock.holderPid();
throw new RequestError(
`${operationInProgressMessage}${holder ? ` (${lockPath} is held by process ${holder})` : ""}`
);
}
try {
await check?.();
logger.audit(`RCE | ${cmd}`);
if (!isDev()) {
await execAsync(cmd);
}
} finally {
await lock.release();
}
} finally {
operationInProgress = false;
}
}
@@ -23,16 +71,13 @@ export async function updatePackage(...names: string[]) {
return logExec(`npm update --save ${names.join(" ")}`);
}
export async function installPackage(name: string) {
return logExec(`npm install --save ${name}`);
export async function installPackage(name: string, check?: () => Promise<void>) {
return logExec(`npm install --save ${name}`, check);
}
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
if (!pluginInfos.some(plugin => plugin.npmPackage === pluginName)) {
throw new RequestError(`Plugin ${pluginName} is not installed on this machine`);
}
return await updatePackage(pluginName);
/** Whether an npm operation is running in this installation, from this or another process */
export async function packageOperationRunning() {
return operationInProgress || !await new LockFile(path.resolve("npm-operation.lock")).isStale();
}
export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNodeEnvInfo[]) {
@@ -42,20 +87,30 @@ export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNo
return await updatePackage(corePackage, ...pluginPackages);
}
export async function handlePluginInstall(pluginName: string) {
if (pluginName.length > 214 || /[^a-zA-Z0-9\-_.+@\/]/.test(pluginName)) {
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
// https://www.npmjs.com/package/validate-npm-package-name
throw new RequestError(`Invalid plugin name: ${pluginName}`);
/** Throws if the dependency by this name is a path, git, url or alias that installing by name would replace */
async function checkNotLocalDependency(name: string) {
if (await isNonRegistryPackage(name)) {
throw new RequestError(`${name} is not installed from npm on this machine`);
}
}
const packageName = encodeURI(pluginName);
const npmRequest = await fetch(`https://registry.npmjs.com/${packageName}`, {
method: "HEAD",
});
if (!npmRequest.ok) {
throw new RequestError(`Unknown plugin: ${packageName}`);
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
// Optionally pinned to a version as name@version to update past the range in package.json
const { name, version } = parsePackageSpec(pluginName);
if (!pluginInfos.some(plugin => plugin.npmPackage === name)) {
throw new RequestError(`Plugin ${name} is not installed on this machine`);
}
return await installPackage(packageName);
if (version === undefined) {
return await updatePackage(name);
}
// Checked under the lock as another operation may change where it's installed from
return await installPackage(await checkRegistry(name, version), () => checkNotLocalDependency(name));
}
/** Install a plugin, optionally pinned to a version as name@version, returns the package name */
export async function handlePluginInstall(pluginName: string) {
const { name, version } = parsePackageSpec(pluginName);
await installPackage(await checkRegistry(name, version), () => checkNotLocalDependency(name));
return name;
}
+4 -2
View File
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "os";
import util from "util";
import { Gauge } from "./prometheus.js";
import { SystemInfo } from "./data/index.js";
import { PluginDetails, SystemInfo } from "./data/index.js";
function filterCpuModel() {
const model = os.cpus()[0].model;
@@ -125,7 +125,8 @@ export async function gatherSystemInfo(
id: SystemInfo["id"],
canRestart: boolean,
restartRequired: boolean,
startedAtMs: number
startedAtMs: number,
plugins?: PluginDetails[],
) {
const cpus = os.cpus();
const currentTotalCpuMs = cpus.map(({ times }) => times.user + times.idle + times.irq + times.nice + times.sys);
@@ -160,5 +161,6 @@ export async function gatherSystemInfo(
startedAtMs,
now,
false,
plugins,
);
}
+3 -1
View File
@@ -54,6 +54,7 @@
"react-dom": "catalog:",
"react-markdown": "catalog:",
"react-router-dom": "catalog:",
"semver": "catalog:",
"stream-browserify": "^3.0.0",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
@@ -67,6 +68,7 @@
"devDependencies": {
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "^18.3.7"
"@types/react-dom": "^18.3.7",
"@types/semver": "catalog:"
}
}
+36 -187
View File
@@ -1,5 +1,5 @@
import { useContext, useEffect, useState } from "react";
import { Button, Checkbox, Form, FormInstance, Input, Modal, Radio, Select } from "antd";
import { Button, Checkbox, Modal, Tooltip } from "antd";
import * as lib from "@clusterio/lib";
@@ -7,92 +7,7 @@ import { useAccount } from "../model/account";
import { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
type NpmActionProps = {
setApplyAction(func: () => Promise<void>): void,
form: FormInstance,
target: lib.AddressShorthand,
}
function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) {
const control = useContext(ControlContext);
setApplyAction(async () => {
if (target === "controller") {
return await control.send(new lib.ControllerUpdateRequest());
}
return await control.sendTo(target, new lib.HostUpdateRequest());
});
return <>
</>;
}
function NpmUpdateAll({ setApplyAction, form, target }: NpmActionProps) {
const control = useContext(ControlContext);
setApplyAction(async () => {
await control.sendTo(target, new lib.UpdateAllRequest());
});
return <>
</>;
}
function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) {
const control = useContext(ControlContext);
const [plugins, setPlugins] = useState<lib.PluginDetails[]>([]);
setApplyAction(async () => {
const values = form.getFieldsValue();
if (!values.plugin || values.plugin === "") {
throw new Error("Plugin not provided");
}
await control.sendTo(target, new lib.PluginUpdateRequest(values.plugin));
});
useEffect(() => {
control.sendTo(target, new lib.PluginListRequest()).then(newPlugins => {
setPlugins(newPlugins);
}).catch(notifyErrorHandler("Error fetching role list"));
}, [target]);
return <>
<Form.Item label="Plugin" name="plugin">
<Select
showSearch
placeholder="Select a plugin"
optionLabelProp="label"
options={plugins
.filter(p => p.npmPackage !== undefined)
.map(plugin => ({
value: plugin.npmPackage,
label: `${plugin.title} (v${plugin.version})`,
}))
}
/>
</Form.Item>
</>;
}
function NpmPluginInstall({ setApplyAction, form, target }: NpmActionProps) {
const control = useContext(ControlContext);
setApplyAction(async () => {
const values = form.getFieldsValue();
if (!values.newPlugin || values.newPlugin === "") {
throw new Error("Plugin not provided");
}
await control.sendTo(target, new lib.PluginInstallRequest(values.newPlugin));
});
// TODO custom npm search based on tags
return <>
<Form.Item label="Plugin" name="newPlugin">
<Input />
</Form.Item>
</>;
}
/** Updates Clusterio on a machine, plugins are installed and updated from the plugins page */
export function NpmButton(props: {
target: "controller" | { hostId: number },
canRestart?: boolean,
@@ -101,138 +16,72 @@ export function NpmButton(props: {
const control = useContext(ControlContext);
const account = useAccount();
const [open, setOpen] = useState(false);
const [formAction, setFormAction] = useState<string | undefined>(undefined);
const [form] = Form.useForm();
const [allows, setAllows] = useState({
remoteUpdates: true, pluginUpdates: true, pluginInstall: true,
});
const [restart, setRestart] = useState(false);
const [allowed, setAllowed] = useState(true);
const isController = props.target === "controller";
const hostId = props.target === "controller" ? undefined : props.target.hostId;
useEffect(() => {
// Without config permission assume it's allowed so the request can still be sent
if (!account.hasPermission(isController ? "core.controller.get_config" : "core.host.get_config")) {
setAllowed(true);
return;
}
if (props.target === "controller") {
if (!account.hasPermission("core.controller.get_config")) {
setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true });
return; // If a user doesn't have config perms, then assume true to allow the sending the request
}
control.send(new lib.ControllerConfigGetRequest()).then(async (serializedConfig) => {
control.send(new lib.ControllerConfigGetRequest()).then(serializedConfig => {
const config = lib.ControllerConfig.fromJSON(serializedConfig, "control");
setAllows({
remoteUpdates: config.get("controller.allow_remote_updates"),
pluginUpdates: config.get("controller.allow_plugin_updates"),
pluginInstall: config.get("controller.allow_plugin_install"),
});
setAllowed(config.get("controller.allow_remote_updates"));
}).catch(notifyErrorHandler("Failed to fetch controller config for remote updates"));
} else {
if (!account.hasPermission("core.host.get_config")) {
setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true });
return; // If a user doesn't have config perms, then assume true to allow the sending the request
}
control.sendTo(props.target, new lib.HostConfigGetRequest()).then(async (serializedConfig) => {
control.sendTo(props.target, new lib.HostConfigGetRequest()).then(serializedConfig => {
const config = lib.HostConfig.fromJSON(serializedConfig, "control");
setAllows({
remoteUpdates: config.get("host.allow_remote_updates"),
pluginUpdates: config.get("host.allow_plugin_updates"),
pluginInstall: config.get("host.allow_plugin_install"),
});
setAllowed(config.get("host.allow_remote_updates"));
}).catch(notifyErrorHandler("Failed to fetch host config for remote updates"));
}
}, [hostId]);
const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update` as const;
function onValuesChange({ action } : { action?: string }) {
if (action) {
setFormAction(action);
}
}
let applyAction: () => Promise<void>;
const setApplyAction = (func: () => Promise<void>) => { applyAction = func; };
const canRestart = account.hasPermission(isController ? "core.controller.restart" : "core.host.restart");
async function onOk() {
if (!applyAction) {
form.setFields([{ name: "action", errors: ["Action is required"] }]);
return;
if (props.target === "controller") {
await control.send(new lib.ControllerUpdateRequest());
} else {
await control.sendTo(props.target, new lib.HostUpdateRequest());
}
await applyAction();
const values = form.getFieldsValue();
if (values.restart) {
// Ternary not used because TS disliked it
if (restart) {
if (props.target === "controller") {
await control.sendTo(props.target, new lib.ControllerRestartRequest());
await control.send(new lib.ControllerRestartRequest());
} else {
await control.sendTo(props.target, new lib.HostRestartRequest());
}
}
setFormAction(undefined);
setOpen(false);
}
return <>
<Button
type="default"
disabled={props.disabled}
onClick={() => { setOpen(true); }}
>Updates</Button>
<Tooltip title={allowed ? undefined : "Remote updates are disabled on this machine"}>
<Button disabled={props.disabled || !allowed} onClick={() => { setOpen(true); }}>
Update Clusterio
</Button>
</Tooltip>
<Modal
title="Updates"
okText="Apply"
title="Update Clusterio"
okText="Update"
open={open}
okButtonProps={{disabled: formAction === undefined}}
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
onOk={() => { onOk().catch(notifyErrorHandler("Error updating Clusterio")); }}
onCancel={() => { setOpen(false); }}
destroyOnHidden
>
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
<Form.Item label="Action" name="action">
<Radio.Group value={formAction}>
{account.hasPermission(coreUpdatePermission)
? <Radio.Button value="remote_update" disabled={!allows.remoteUpdates}>
Update Clusterio
</Radio.Button>: undefined}
{account.hasPermission(coreUpdatePermission) && account.hasPermission("core.plugin.update")
? <Radio.Button
value="update_all"
disabled={!allows.remoteUpdates || !allows.pluginUpdates}
>
Update All
</Radio.Button> : undefined}
{account.hasPermission("core.plugin.update")
? <Radio.Button value="plugin_update" disabled={!allows.pluginUpdates}>
Update Plugin
</Radio.Button> : undefined}
{account.hasPermission("core.plugin.install")
? <Radio.Button value="plugin_install" disabled={!allows.pluginInstall}>
Install Plugin
</Radio.Button> : undefined}
</Radio.Group>
</Form.Item>
{
formAction !== undefined
&& account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`)
&& <Form.Item label="Restart on completion" name="restart" valuePropName="checked">
<Checkbox disabled={props.canRestart === false}/>
</Form.Item>
}
{formAction === "remote_update"
? <NpmRemoteUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
{formAction === "update_all"
? <NpmUpdateAll {...{setApplyAction, form, target: props.target}}/> : undefined}
{formAction === "plugin_update"
? <NpmPluginUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
{formAction === "plugin_install"
? <NpmPluginInstall {...{setApplyAction, form, target: props.target}}/> : undefined}
</Form>
<p>Updates Clusterio within the version range in the machine's package.json.</p>
{canRestart && <Checkbox
checked={restart}
disabled={props.canRestart === false}
onChange={event => { setRestart(event.target.checked); }}
>Restart on completion</Checkbox>}
</Modal>
</>;
}
export function hasNpmButtonPermission(controller: boolean) {
const account = useAccount();
return account.hasAnyPermission(
controller ? "core.controller.update" : "core.host.update",
"core.plugin.update", "core.plugin.install"
);
return account.hasPermission(controller ? "core.controller.update" : "core.host.update");
}
@@ -0,0 +1,218 @@
import { useContext, useEffect, useState } from "react";
import { Button, Descriptions, Input, Modal, Select, Space, Table, Typography } from "antd";
import SearchOutlined from "@ant-design/icons/SearchOutlined";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import { useHosts } from "../model/host";
import { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import { InstallTarget, PluginInstaller, RemoteInstallDisabledNotice, addressKey } from "./PluginInstall";
import { PluginInstalledTag, PluginUpdateButton, PluginVersions } from "./PluginClusterStatus";
import type { PluginStatus } from "../util/plugin_status";
const { Text, Link } = Typography;
/** Statuses are undefined until the plugin lists of the machines have loaded, or without core.plugin.list */
type BrowserProps = { statuses?: Map<string, PluginStatus>, installer: PluginInstaller };
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
// Installs on the chosen machines, defaulting to every connected machine that allows installs.
function InstallControls(props: { packageSpec: string, installer: PluginInstaller }) {
const account = useAccount();
const [hosts] = useHosts();
// Tracks what was deselected so machines connecting later, or allowing installs, start selected
const [deselected, setDeselected] = useState(new Set<string>());
const { access, installing, restarting } = props.installer;
const machines: (InstallTarget & { key: string, allowed: boolean })[] = [
{ name: "Controller", address: "controller" as lib.AddressShorthand },
...[...hosts.values()]
.filter(host => host.connected)
.sort((a, b) => strcmp(a.name, b.name))
.map(host => ({ name: host.name, address: { hostId: host.id } as lib.AddressShorthand })),
].map(target => ({ ...target, key: addressKey(target.address), allowed: access.canInstall(target.address) }));
const selected = machines.filter(machine => machine.allowed && !deselected.has(machine.key));
const blocked = machines.filter(machine => !machine.allowed);
return <Space direction="vertical" style={{ width: "100%" }}>
<Space wrap>
<Text>Install on</Text>
<Select
mode="multiple"
style={{ minWidth: 260 }}
placeholder="Select machines"
optionFilterProp="label"
value={selected.map(machine => machine.key)}
onChange={keys => {
setDeselected(new Set(machines.filter(machine => !keys.includes(machine.key)).map(m => m.key)));
}}
options={machines.map(machine => ({
value: machine.key,
label: machine.allowed ? machine.name : `${machine.name} (remote installs disabled)`,
disabled: !machine.allowed,
}))}
/>
<Button
type="primary"
loading={installing}
disabled={!account.hasPermission("core.plugin.install") || !selected.length || restarting}
onClick={() => { props.installer.install(props.packageSpec, selected); }}
>Install</Button>
</Space>
{blocked.length > 0 && <RemoteInstallDisabledNotice
controller={blocked.some(machine => machine.address === "controller")}
hosts={blocked.filter(machine => machine.address !== "controller").map(machine => machine.name)}
/>}
</Space>;
}
function PluginDetails(props: { plugin: lib.PluginSearchResult } & BrowserProps) {
const account = useAccount();
const { plugin } = props;
const status = props.statuses?.get(plugin.name);
// Without list permission the statuses never load, so offer installs without them
const statusesKnown = props.statuses !== undefined || !account.hasPermission("core.plugin.list");
// Links come from package metadata on npm, only http(s) links are shown so they can't run scripts
const links = [
{ label: "npm", url: lib.safeExternalUrl(plugin.npm) },
{ label: "Homepage", url: lib.safeExternalUrl(plugin.homepage) },
{ label: "Repository", url: lib.safeExternalUrl(plugin.repository) },
].filter(link => link.url !== undefined);
return <>
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Package">{plugin.name}</Descriptions.Item>
<Descriptions.Item label="Version">{plugin.version}</Descriptions.Item>
{status && <Descriptions.Item label="Installed">
<PluginVersions status={status} latest={plugin.version} />
<div style={{ marginTop: 4 }}>
<PluginUpdateButton status={status} latest={plugin.version} installer={props.installer} />
</div>
</Descriptions.Item>}
{plugin.description && <Descriptions.Item label="Description">{plugin.description}</Descriptions.Item>}
{plugin.publisher && <Descriptions.Item label="Publisher">{plugin.publisher}</Descriptions.Item>}
{plugin.date && <Descriptions.Item label="Published">
{new Date(plugin.date).toLocaleString()}
</Descriptions.Item>}
{links.length > 0 && <Descriptions.Item label="Links">
<Space wrap>
{links.map(link => <Link key={link.label} href={link.url} target="_blank" rel="noopener noreferrer">
{link.label}
</Link>)}
</Space>
</Descriptions.Item>}
</Descriptions>
{/* Installed plugins are brought in line by the update action above instead */}
{statusesKnown && !status && <InstallControls packageSpec={plugin.name} installer={props.installer} />}
</>;
}
export function PluginBrowserButton(props: BrowserProps) {
const control = useContext(ControlContext);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [results, setResults] = useState<lib.PluginSearchResult[]>([]);
const [selected, setSelected] = useState<lib.PluginSearchResult | undefined>();
// Package entered by name, for plugins the search doesn't find
const [manual, setManual] = useState<string>();
useEffect(() => {
if (!open) {
return;
}
setLoading(true);
let canceled = false;
control.send(new lib.PluginSearchRequest(query, page, pageSize)).then(response => {
if (canceled) { return; }
setTotal(response.total);
setResults(response.results);
setSelected(current => response.results.find(r => r.name === current?.name));
}).catch(notifyErrorHandler("Error searching for plugins")).finally(() => {
if (!canceled) { setLoading(false); }
});
// eslint-disable-next-line consistent-return
return () => { canceled = true; };
}, [open, query, page, pageSize]);
return <>
<Button icon={<SearchOutlined />} onClick={() => { setOpen(true); }}>Browse plugins</Button>
<Modal
title="Browse plugins"
open={open}
onCancel={() => { setOpen(false); }}
width={1000}
footer={<Button onClick={() => { setOpen(false); }}>Close</Button>}
>
<Input.Search
placeholder="Search npm for plugins"
allowClear
enterButton
style={{ marginBottom: 16 }}
onSearch={value => { setQuery(value); setPage(1); }}
/>
<Input.Search
placeholder="Or install a package by name, as name or name@version"
allowClear
enterButton="Select"
style={{ marginBottom: 16 }}
onSearch={value => { setManual(value.trim() || undefined); setSelected(undefined); }}
/>
<Table
size="small"
loading={loading}
dataSource={results}
rowKey="name"
rowClassName={plugin => (plugin.name === selected?.name ? "ant-table-row-selected" : "")}
onRow={plugin => ({
onClick: () => { setSelected(plugin); setManual(undefined); },
style: { cursor: "pointer" },
})}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (newPage, newPageSize) => { setPage(newPage); setPageSize(newPageSize); },
}}
columns={[
{
title: "Name",
key: "name",
render: (_, plugin) => {
const status = props.statuses?.get(plugin.name);
return <>
{plugin.name}
{status && <span style={{ marginLeft: 8 }}>
<PluginInstalledTag status={status} latest={plugin.version} />
</span>}
</>;
},
},
{ title: "Version", dataIndex: "version", key: "version", width: 120 },
{ title: "Publisher", dataIndex: "publisher", key: "publisher", width: 160, responsive: ["md"] },
{
title: "Description",
dataIndex: "description",
key: "description",
ellipsis: true,
responsive: ["lg"],
},
]}
/>
{selected && <PluginDetails plugin={selected} statuses={props.statuses} installer={props.installer} />}
{manual && <>
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Package">{manual}</Descriptions.Item>
</Descriptions>
<InstallControls packageSpec={manual} installer={props.installer} />
</>}
{!selected && !manual && <Text type="secondary">Select a plugin to see details and install it.</Text>}
</Modal>
</>;
}
@@ -0,0 +1,593 @@
import { ReactNode, useContext, useEffect, useState } from "react";
import { Button, Checkbox, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from "antd";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import { useHosts } from "../model/host";
import { useInstances } from "../model/instance";
import { useSystems } from "../model/system";
import notify, { notifyErrorHandler } from "../util/notify";
import { poll } from "../util/poll";
import ControlContext from "./ControlContext";
import Link from "./Link";
import { PackageOperation, PluginInstaller, addressKey } from "./PluginInstall";
import {
Machine, MachinePlugin, MachinePlugins, PluginStatus, SkippedOperation, UpdatePlan,
compareVersions, machineStatusLabels, planMachineUpdate, planUpdate,
} from "../util/plugin_status";
const { Text } = Typography;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
const latestVersionsRefreshMs = 60e3;
function plural(count: number, word: string) {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
function machinePath(machine: Machine) {
return machine.isController ? "/controller" : `/hosts/${(machine.address as { hostId: number }).hostId}/view`;
}
type ListedPlugins = { plugins: lib.PluginDetails[], systemUpdatedAtMs?: number };
type MachinePluginsState = {
machines?: MachinePlugins[],
unknown: Machine[],
/** Fetch the plugin lists of connected machines now, instead of waiting for the next system metrics */
refresh: (addresses?: lib.AddressShorthand[]) => Promise<void>,
};
/**
* Plugins of the controller and every host, as last collected with the system metrics. Offline
* hosts keep the plugins they had when last seen.
*/
export function useMachinePlugins(): MachinePluginsState {
const control = useContext(ControlContext);
const account = useAccount();
const [hosts, hostsSynced] = useHosts();
const [systems, systemsSynced] = useSystems();
// Lists fetched directly, used until the system metrics of the machine update
const [listed, setListed] = useState(new Map<string, ListedPlugins>());
const canList = account.hasPermission("core.plugin.list");
const allMachines: Machine[] = [
{ name: "Controller", isController: true, address: "controller" },
...[...hosts.values()]
.filter(host => !host.isDeleted)
.sort((a, b) => strcmp(a.name, b.name))
.map(host => ({
name: host.name, isController: false, address: { hostId: host.id }, offline: !host.connected,
})),
];
const systemOf = (machine: Machine) => systems.get(
machine.isController ? "controller" : (machine.address as { hostId: number }).hostId
);
async function refresh(addresses?: lib.AddressShorthand[]) {
const keys = addresses && new Set(addresses.map(addressKey));
const targets = allMachines.filter(
machine => !machine.offline && (!keys || keys.has(addressKey(machine.address)))
);
const results = await Promise.all(targets.map(async machine => {
try {
const plugins = await control.sendTo(machine.address, new lib.PluginListRequest());
const systemUpdatedAtMs = systemOf(machine)?.updatedAtMs;
return [addressKey(machine.address), { plugins, systemUpdatedAtMs }] as const;
} catch (err) {
return undefined;
}
}));
setListed(current => new Map([...current, ...results.filter(result => result !== undefined)]));
}
// Covers machines that don't report plugins in their metrics, and metrics being turned off
const connectedKey = allMachines.filter(machine => !machine.offline).map(m => addressKey(m.address)).join(",");
useEffect(() => {
if (canList && hostsSynced) {
refresh();
}
}, [canList, hostsSynced, connectedKey]);
if (!canList || !hostsSynced || (!systemsSynced && !listed.size)) {
return { machines: undefined, unknown: [], refresh };
}
const machines: MachinePlugins[] = [];
const unknown: Machine[] = [];
for (const machine of allMachines) {
const system = systemOf(machine);
const fresh = listed.get(addressKey(machine.address));
const plugins = fresh && (!system?.plugins || fresh.systemUpdatedAtMs === system.updatedAtMs)
? fresh.plugins
: system?.plugins;
if (plugins) {
machines.push({ ...machine, plugins });
} else {
unknown.push(machine);
}
}
return { machines, unknown, refresh };
}
/** Latest version published on npm of each package */
export function useLatestVersions(packages: string[]) {
const control = useContext(ControlContext);
const account = useAccount();
const [versions, setVersions] = useState<Record<string, string>>({});
const packagesKey = [...new Set(packages)].sort().join(",");
const canList = account.hasPermission("core.plugin.list");
useEffect(() => {
if (!packagesKey || !canList) {
return undefined;
}
let canceled = false;
let notified = false;
// Packages the registry couldn't be reached for are left out, so look them up again later.
// The controller caches the ones it found.
const stop = poll(async () => {
try {
const response = await control.send(new lib.PluginLatestVersionsRequest(packagesKey.split(",")));
if (!canceled) {
// Merged so a lookup failing on a later refresh doesn't hide what was found before
setVersions(current => ({ ...current, ...response.versions }));
}
} catch (err: any) {
if (!canceled && !notified) {
notified = true;
notifyErrorHandler("Failed to look up latest plugin versions")(err);
}
}
}, latestVersionsRefreshMs);
return () => { canceled = true; stop(); };
}, [packagesKey, canList]);
return versions;
}
function isPending(machine: MachinePlugin) {
return machine.running !== machine.afterRestart;
}
function CountLine(props: { label: string, count: number, type?: "warning" | "secondary" }) {
return <div><Text type={props.type}>{props.label} {props.count}</Text></div>;
}
/** One line per version running now or after a restart, plus restart pending, disabled and missing counts */
export function PluginVersions(props: { status: PluginStatus, latest?: string }) {
const { machines, unknown } = props.status;
const versions = [...new Set(machines.flatMap(machine => [machine.running, machine.afterRestart]))]
.filter((version): version is string => version !== undefined)
.sort(compareVersions)
.reverse();
const count = (filter: (machine: MachinePlugin) => boolean) => machines.filter(filter).length;
const missing = count(machine => (!machine.plugin || Boolean(machine.plugin.removed)) && !machine.nameTaken);
return <>
{versions.map(version => {
const now = count(machine => machine.running === version);
const after = count(machine => machine.afterRestart === version);
return <div key={version}>
<Text>{version}</Text>
{props.latest === version && <Tag color="success" style={{ marginLeft: 6 }}>latest</Tag>}
<Text type="secondary" style={{ marginLeft: 12 }}>
{plural(now, "machine")}{now !== after && ` → ${after} after restart`}
</Text>
</div>;
})}
{count(isPending) > 0 && <CountLine label="Restart pending on" count={count(isPending)} />}
{count(machine => machine.disabled) > 0 && <CountLine
label="Disabled on" count={count(machine => machine.disabled)} type="secondary"
/>}
{count(machine => machine.noEntrypoint) > 0 && <CountLine
label="No entrypoint on" count={count(machine => machine.noEntrypoint)} type="secondary"
/>}
{count(machine => machine.nameTaken) > 0 && <CountLine
label="From another package on" count={count(machine => machine.nameTaken)} type="secondary"
/>}
{missing > 0 && <CountLine label="Not installed on" count={missing} type="warning" />}
{unknown.length > 0 && <CountLine label="Plugins not reported by" count={unknown.length} type="warning" />}
</>;
}
type ChecklistItem = { key: string, label: ReactNode, disabledReason?: string };
type ChecklistModalProps = {
open: boolean,
title: string,
okText: string,
danger?: boolean,
items: ChecklistItem[],
extra?: ReactNode,
onCancel: () => void,
onOk: (keys: string[]) => void,
};
/** Confirms a list of actions, all ticked by default. Actions that can't be done are disabled with the reason. */
function ChecklistModal(props: ChecklistModalProps) {
const [unchecked, setUnchecked] = useState(new Set<string>());
useEffect(() => {
if (props.open) {
setUnchecked(new Set());
}
}, [props.open]);
const possible = props.items.filter(item => !item.disabledReason);
const selected = possible.filter(item => !unchecked.has(item.key));
const toggle = (key: string, checked: boolean) => setUnchecked(current => {
const next = new Set(current);
if (checked) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
return <Modal
title={props.title}
open={props.open}
okText={props.okText}
okButtonProps={{ danger: props.danger, disabled: !selected.length }}
onOk={() => props.onOk(selected.map(item => item.key))}
onCancel={props.onCancel}
destroyOnHidden
>
{props.extra}
{possible.length > 1 && <Checkbox
style={{ margin: "8px 0" }}
checked={selected.length === possible.length}
indeterminate={selected.length > 0 && selected.length < possible.length}
onChange={event => setUnchecked(new Set(event.target.checked ? [] : possible.map(item => item.key)))}
>All ({selected.length}/{possible.length})</Checkbox>}
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{props.items.map(item => <div key={item.key}>
<Checkbox
checked={!item.disabledReason && !unchecked.has(item.key)}
disabled={Boolean(item.disabledReason)}
onChange={event => toggle(item.key, event.target.checked)}
>
{item.label}
{item.disabledReason && <Text type="secondary"> ({item.disabledReason})</Text>}
</Checkbox>
</div>)}
</div>
</Modal>;
}
type Account = ReturnType<typeof useAccount>;
function canChangePlugins(account: Account) {
return account.hasPermission("core.plugin.install") || account.hasPermission("core.plugin.update");
}
function operationKey(change: PackageOperation | SkippedOperation, title: string) {
return `${title}:${change.kind}:${addressKey(change.target.address)}`;
}
type PlannedUpdate = { title: string, latest: string, plan: UpdatePlan };
/** One checklist item per operation and skipped operation */
function planItems(updates: PlannedUpdate[], withTitle: boolean) {
const operations = new Map<string, PackageOperation>();
const items: ChecklistItem[] = [];
for (const { title, latest, plan } of updates) {
const label = (change: PackageOperation | SkippedOperation) => <>
{withTitle && <Text strong>{title}: </Text>}
{change.kind === "install" ? "Install" : "Update to"} {latest} on {change.target.name}
</>;
for (const operation of plan.operations) {
const key = operationKey(operation, title);
operations.set(key, operation);
items.push({ key, label: label(operation) });
}
for (const skipped of plan.skipped) {
items.push({ key: operationKey(skipped, title), label: label(skipped), disabledReason: skipped.reason });
}
}
return { items, operations };
}
type UpdateButtonProps = { status: PluginStatus, title?: string, latest?: string, installer: PluginInstaller };
/** Updates a plugin to the latest version on every machine */
export function PluginUpdateButton(props: UpdateButtonProps) {
const account = useAccount();
const [open, setOpen] = useState(false);
const { status, latest, installer } = props;
if (!latest || !canChangePlugins(account)) {
return null;
}
const plan = planUpdate(status, latest, installer.access, account);
const changes = [...plan.operations, ...plan.skipped];
if (!changes.length) {
return <Tooltip title="Up to date on every machine">
<Button size="small" disabled>Update to {latest}</Button>
</Tooltip>;
}
const onlyInstalls = changes.every(change => change.kind === "install");
const label = onlyInstalls ? `Install on missing (${changes.length})` : `Update to ${latest}`;
const title = props.title ?? status.title;
const { items, operations } = planItems([{ title, latest, plan }], false);
return <>
<Button
size="small"
loading={installer.installing}
disabled={installer.restarting}
onClick={() => setOpen(true)}
>{label}</Button>
<ChecklistModal
open={open}
title={`${onlyInstalls ? "Install" : "Update"} ${title} ${latest}`}
okText={onlyInstalls ? "Install" : "Update"}
items={items}
extra={<p>Machines need a restart afterwards.</p>}
onCancel={() => setOpen(false)}
onOk={keys => {
setOpen(false);
installer.run(keys.map(key => operations.get(key)!));
}}
/>
</>;
}
type UpdateAllProps = {
statuses: PluginStatus[],
/** Display names of plugins by status key, falling back to the title reported by the machines */
titles?: Map<string, string>,
latestVersions: Record<string, string>,
installer: PluginInstaller,
};
/** Updates every plugin with a newer version to the latest version on every machine */
export function UpdateAllButton(props: UpdateAllProps) {
const account = useAccount();
const [open, setOpen] = useState(false);
if (!canChangePlugins(account)) {
return null;
}
const updates = props.statuses.flatMap(status => {
const latest = status.npmPackage && props.latestVersions[status.npmPackage];
if (!latest) {
return [];
}
const plan = planUpdate(status, latest, props.installer.access, account);
const title = props.titles?.get(status.key) ?? status.title;
return plan.operations.length || plan.skipped.length ? [{ title, latest, plan }] : [];
});
const pluginCount = updates.filter(update => update.plan.operations.length).length;
const { items, operations } = planItems(updates, true);
return <>
<Tooltip title={pluginCount ? undefined : "No plugin updates can be done"}>
<Button
loading={props.installer.installing}
disabled={!pluginCount || props.installer.restarting}
onClick={() => setOpen(true)}
>Update all ({pluginCount})</Button>
</Tooltip>
<ChecklistModal
open={open}
title={`Update ${plural(pluginCount, "plugin")} to the latest version`}
okText="Update"
items={items}
extra={<p>Machines need a restart afterwards.</p>}
onCancel={() => setOpen(false)}
onOk={keys => {
setOpen(false);
props.installer.run(keys.map(key => operations.get(key)!));
}}
/>
</>;
}
type RestartPendingProps = {
statuses: PluginStatus[],
machines: MachinePlugins[],
installer: PluginInstaller,
};
/** Restarts the chosen machines with a pending restart, hosts first and the controller last */
export function RestartPendingButton(props: RestartPendingProps) {
const control = useContext(ControlContext);
const account = useAccount();
const [systems] = useSystems();
const [instances] = useInstances();
const [open, setOpen] = useState(false);
const { installer } = props;
const permitted = (machine: Machine) => account.hasPermission(
machine.isController ? "core.controller.restart" : "core.host.restart"
);
if (!account.hasPermission("core.controller.restart") && !account.hasPermission("core.host.restart")) {
return null;
}
const pendingKeys = new Set(props.statuses.flatMap(
status => status.machines.filter(isPending).map(machine => addressKey(machine.address))
));
const systemOf = (machine: Machine) => systems.get(
machine.isController ? "controller" : (machine.address as { hostId: number }).hostId
);
const pending = props.machines.filter(
machine => pendingKeys.has(addressKey(machine.address)) || systemOf(machine)?.restartRequired
);
const cannotRestart = (machine: Machine) => {
if (machine.offline) {
return "offline";
}
if (!permitted(machine)) {
return "no restart permission";
}
if (!systemOf(machine)?.canRestart) {
return "not running under a process monitor";
}
return undefined;
};
const runningInstances = (machine: Machine) => [...instances.values()].filter(instance => (
!machine.isController
&& instance.assignedHost === (machine.address as { hostId: number }).hostId
&& !["unknown", "unassigned", "stopped", "deleted"].includes(instance.status)
)).length;
const items = pending.map(machine => {
const count = runningInstances(machine);
return {
key: addressKey(machine.address),
label: <>
{machine.name}
{count > 0 && <Text type="warning"> stops {plural(count, "running instance")}</Text>}
</>,
disabledReason: cannotRestart(machine),
};
});
const blocked = items.filter(item => item.disabledReason).length;
async function restartMachines(restart: MachinePlugins[]) {
const hosts = restart.filter(machine => !machine.isController);
const results = await Promise.allSettled(hosts.map(
machine => control.sendTo(machine.address, new lib.HostRestartRequest())
));
const started: Machine[] = [];
results.forEach((result, i) => {
if (result.status === "rejected") {
notifyErrorHandler(`Failed to restart ${hosts[i].name}`)(result.reason);
} else {
started.push(hosts[i]);
}
});
// The controller goes last as restarting it disconnects this page until it's back
const controller = restart.find(machine => machine.isController);
if (controller) {
try {
await control.send(new lib.ControllerRestartRequest());
started.push(controller);
} catch (err: any) {
notifyErrorHandler("Failed to restart the controller")(err);
}
}
if (started.length) {
notify(`Restarting ${plural(started.length, "machine")}`, "info");
}
}
return <>
<Tooltip title={pending.length
? `${plural(pending.length, "machine")} need a restart`
: "No machine needs a restart"
}>
<Button
loading={installer.restarting}
disabled={!pending.length || installer.installing}
onClick={() => setOpen(true)}
>Restart pending ({pending.length})</Button>
</Tooltip>
<ChecklistModal
open={open}
title="Restart machines with a pending restart"
okText="Restart"
danger
items={items}
extra={blocked > 0 && <p>
{plural(blocked, "machine")} can't be restarted from here and must be restarted by hand.
The <Link to="/hosts">hosts list</Link> will show which need to be restarted.
</p>}
onCancel={() => setOpen(false)}
onOk={keys => {
setOpen(false);
const restart = pending.filter(machine => keys.includes(addressKey(machine.address)));
installer.restart(() => restartMachines(restart));
}}
/>
</>;
}
type MachineRow = MachinePlugin | (Machine & { unknown: true });
type MachineActionProps = { status: PluginStatus, machine: MachinePlugin, latest: string, installer: PluginInstaller };
/** Install or update action for one machine, disabled with the reason when it can't be done */
function MachineAction(props: MachineActionProps) {
const account = useAccount();
const { status, machine, latest, installer } = props;
const plan = planMachineUpdate(status, machine, latest, installer.access, account);
const [operation] = plan.operations;
const [skipped] = plan.skipped;
const change = operation ?? skipped;
if (!change) {
return null;
}
const label = `${change.kind === "install" ? "Install" : "Update to"} ${latest}`;
if (!operation) {
return <Tooltip title={skipped.reason}><Button size="small" disabled>{label}</Button></Tooltip>;
}
return <Popconfirm
title={`${label} on ${machine.name}?`}
description="The machine needs a restart afterwards."
onConfirm={() => installer.run([operation])}
>
<Button size="small" loading={installer.installing} disabled={installer.restarting}>{label}</Button>
</Popconfirm>;
}
/** Version and status of a plugin on each machine */
export function PluginMachinesTable(props: { status: PluginStatus, latest?: string, installer: PluginInstaller }) {
const account = useAccount();
const { status, latest, installer } = props;
const rows: MachineRow[] = [
...status.machines,
...status.unknown.map(machine => ({ ...machine, unknown: true as const })),
];
return <Table
size="small"
dataSource={rows}
rowKey={machine => addressKey(machine.address)}
pagination={{ defaultPageSize: 20, hideOnSinglePage: true }}
columns={[
{
title: "Machine",
key: "name",
render: (_, machine) => <Link to={machinePath(machine)}>{machine.name}</Link>,
sorter: (a, b) => strcmp(a.name, b.name),
},
{
title: "Running",
key: "running",
render: (_, machine) => ("unknown" in machine ? null : <>
{machine.running ?? <Text type="secondary">None</Text>}
{machine.running !== undefined && machine.running === latest
&& <Tag color="success" style={{ marginLeft: 6 }}>latest</Tag>}
</>),
},
{
title: "After restart",
key: "afterRestart",
render: (_, machine) => ("unknown" in machine || !isPending(machine)
? null
: machine.afterRestart ?? <Text type="secondary">None</Text>),
},
{
title: "Status",
key: "status",
render: (_, machine) => <Space size={[0, 4]} wrap>
{("unknown" in machine ? ["Plugins not reported"] : machineStatusLabels(machine))
.map(label => <Tag key={label}>{label}</Tag>)}
</Space>,
},
{
key: "action",
align: "right",
render: (_, machine) => (latest && !("unknown" in machine) && canChangePlugins(account)
? <MachineAction status={status} machine={machine} latest={latest} installer={installer} />
: null),
},
]}
/>;
}
/** Compact summary for search results: how many machines have the plugin installed */
export function PluginInstalledTag(props: { status: PluginStatus, latest?: string }) {
const { machines } = props.status;
const installed = machines.filter(machine => machine.plugin && !machine.plugin.removed);
const current = machines.filter(machine => machine.afterRestart === props.latest);
const color = current.length === machines.length ? "success" : "warning";
return <Tooltip title={<PluginVersions status={props.status} latest={props.latest} />}>
<Tag color={color}>Installed on {installed.length}</Tag>
</Tooltip>;
}
@@ -0,0 +1,186 @@
import { useContext, useEffect, useRef, useState } from "react";
import { Alert, Typography } from "antd";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import { useHosts } from "../model/host";
import { ExclusiveRunner } from "../util/exclusive";
import notify, { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
const { Text } = Typography;
export type InstallTarget = { name: string, address: lib.AddressShorthand };
export type PackageOperation = { kind: "install" | "update", target: InstallTarget, packageSpec: string };
export type RemoteAccess = {
canInstall: (address: lib.AddressShorthand) => boolean,
canUpdate: (address: lib.AddressShorthand) => boolean,
};
export type PluginInstaller = {
access: RemoteAccess,
installing: boolean,
restarting: boolean,
/** Install a package, optionally pinned as name@version, on every target */
install: (packageSpec: string, targets: InstallTarget[]) => Promise<void>,
/** Run package operations one at a time, a standalone controller and host share one installation */
run: (operations: PackageOperation[]) => Promise<void>,
/** Run a restart, never at the same time as package operations */
restart: (restartMachines: () => Promise<void>) => Promise<void>,
};
type MachineAccess = { install: boolean, update: boolean };
const defaultAccess: MachineAccess = { install: true, update: true };
export function addressKey(address: lib.AddressShorthand) {
return address === "controller" ? "controller" : String((address as { hostId: number }).hostId);
}
// Which machines accept remote plugin installs and updates, assumed true when the config can't be read.
function useRemoteAccess(): RemoteAccess {
const control = useContext(ControlContext);
const account = useAccount();
const [hosts] = useHosts();
const [controllerAccess, setControllerAccess] = useState(defaultAccess);
const [hostsAccess, setHostsAccess] = useState(new Map<number, MachineAccess>());
const connectedHostIds = [...hosts.values()].filter(host => host.connected).map(host => host.id);
const canGetControllerConfig = account.hasPermission("core.controller.get_config");
const canGetHostConfig = account.hasPermission("core.host.get_config");
useEffect(() => {
if (!canGetControllerConfig) {
setControllerAccess(defaultAccess);
return undefined;
}
let canceled = false;
control.send(new lib.ControllerConfigGetRequest()).then(serializedConfig => {
if (canceled) {
return;
}
const config = lib.ControllerConfig.fromJSON(serializedConfig, "control");
setControllerAccess({
install: config.get("controller.allow_plugin_install"),
update: config.get("controller.allow_plugin_updates"),
});
}).catch(notifyErrorHandler("Failed to fetch controller config"));
return () => { canceled = true; };
}, [canGetControllerConfig]);
useEffect(() => {
if (!canGetHostConfig) {
setHostsAccess(new Map());
return undefined;
}
let canceled = false;
Promise.all(connectedHostIds.map(async hostId => {
try {
const serializedConfig = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
const config = lib.HostConfig.fromJSON(serializedConfig, "control");
return [hostId, {
install: config.get("host.allow_plugin_install"),
update: config.get("host.allow_plugin_updates"),
}] as const;
} catch (err) {
return [hostId, defaultAccess] as const;
}
})).then(entries => {
if (!canceled) {
setHostsAccess(new Map(entries));
}
});
return () => { canceled = true; };
}, [connectedHostIds.join(","), canGetHostConfig]);
const accessOf = (address: lib.AddressShorthand) => (address === "controller"
? controllerAccess
: hostsAccess.get((address as { hostId: number }).hostId) ?? defaultAccess
);
return {
canInstall: address => accessOf(address).install,
canUpdate: address => accessOf(address).update,
};
}
/**
* Installs and updates plugins and reports the outcome. Owned by the plugins page so the busy
* state is shared between the browser and update actions and survives browsing.
*/
export function usePluginInstaller(onDone?: (addresses: lib.AddressShorthand[]) => void): PluginInstaller {
const control = useContext(ControlContext);
const access = useRemoteAccess();
const [busy, setBusy] = useState<"installing" | "restarting">();
// A confirm opened before a run started can still be clicked while it runs
const runner = useRef(new ExclusiveRunner(setBusy)).current;
async function exclusive(state: "installing" | "restarting", action: () => Promise<void>) {
if (!await runner.run(state, action)) {
notify("Another plugin change or restart is still in progress", "warning");
}
}
async function runOperations(operations: PackageOperation[]) {
const done: string[] = [];
try {
for (const { kind, target, packageSpec } of operations) {
const request = kind === "install"
? new lib.PluginInstallRequest(packageSpec)
: new lib.PluginUpdateRequest(packageSpec);
try {
await control.sendTo(target.address, request);
done.push(`${kind === "install" ? "Installed" : "Updated"} ${packageSpec} on ${target.name}`);
} catch (err: any) {
notifyErrorHandler(`Failed to ${kind} ${packageSpec} on ${target.name}`)(err);
}
}
if (done.length) {
notify(
done.length === 1 ? done[0] : `Completed ${done.length} plugin changes`, "success",
<>
{done.length > 1 && done.map(line => <div key={line}>{line}</div>)}
<div>A restart is required to apply the changes.</div>
</>,
);
}
} finally {
onDone?.(operations.map(operation => operation.target.address));
}
}
async function run(operations: PackageOperation[]) {
await exclusive("installing", () => runOperations(operations));
}
async function restart(restartMachines: () => Promise<void>) {
await exclusive("restarting", restartMachines);
}
async function install(packageSpec: string, targets: InstallTarget[]) {
await run(targets.map(target => ({ kind: "install", target, packageSpec })));
}
return {
access, installing: busy === "installing", restarting: busy === "restarting", install, run, restart,
};
}
// Remote installs can only be enabled locally on each machine, so tell the user how.
export function RemoteInstallDisabledNotice(props: { controller: boolean, hosts: string[] }) {
const names = [...(props.controller ? ["the controller"] : []), ...props.hosts];
return <Alert
type="info"
showIcon
message={`Remote plugin installs are disabled on ${names.join(", ")}`}
description={<>
For security reasons this can't be changed from the web UI. To allow installs, stop the machine
and run the following in its installation directory, then start it again:
{props.controller && <div>
<Text type="secondary">Controller: </Text>
<Text code copyable>npx clusteriocontroller config set controller.allow_plugin_install true</Text>
</div>}
{props.hosts.length > 0 && <div>
<Text type="secondary">Host: </Text>
<Text code copyable>npx clusteriohost config set host.allow_plugin_install true</Text>
</div>}
</>}
/>;
}
+106 -46
View File
@@ -2,62 +2,68 @@ import React, { useEffect, useContext, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, Descriptions, Spin } from "antd";
import * as lib from "@clusterio/lib";
import type { PluginWebApi } from "@clusterio/lib";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PageHeader from "./PageHeader";
import SectionHeader from "./SectionHeader";
import notify from "../util/notify";
import { usePluginInstaller } from "./PluginInstall";
import { PluginMachinesTable, PluginUpdateButton, useLatestVersions, useMachinePlugins } from "./PluginClusterStatus";
import { PluginStatus, computePluginStatuses, pluginStatusKey } from "../util/plugin_status";
export default function PluginViewPage() {
let params = useParams();
const control = useContext(ControlContext);
let [pluginList, setPluginList] = useState<PluginWebApi[]>();
let pluginName = String(params.name);
type PluginMachinesProps = {
status: PluginStatus,
title: string,
refresh: (addresses: lib.AddressShorthand[]) => void,
};
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
}
})();
}, []);
function PluginMachines(props: PluginMachinesProps) {
const installer = usePluginInstaller(props.refresh);
const { npmPackage } = props.status;
const latestVersions = useLatestVersions(npmPackage ? [npmPackage] : []);
const latest = npmPackage ? latestVersions[npmPackage] : undefined;
const loadedPlugin = control.loadedPlugins.get(pluginName);
const pluginInfo = control.pluginInfos.get(pluginName);
const pluginTitle = pluginInfo ? pluginInfo.title : pluginName;
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
if (!pluginList) {
return <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle} />
<Spin size="large" />
</PageLayout>;
}
return <>
<Descriptions bordered size="small" style={{ marginTop: "1em" }}>
<Descriptions.Item label="Package" span={npmPackage ? 1 : 3}>
{npmPackage ?? "Not published to npm"}
</Descriptions.Item>
{npmPackage && <Descriptions.Item label="Latest version" span={2}>
{latest ?? "Unknown"}
</Descriptions.Item>}
</Descriptions>
<SectionHeader
title="Machines"
extra={<PluginUpdateButton
status={props.status} title={props.title} latest={latest} installer={installer}
/>}
/>
<PluginMachinesTable status={props.status} latest={latest} installer={installer} />
</>;
}
let pluginMeta = pluginList.find(p => p.name === pluginName);
if (!pluginMeta) {
return <PageLayout nav={nav}>
<PageHeader title="Plugin not found" />
<p>Plugin with name {pluginName} was not found on the controller.</p>
</PageLayout>;
}
type ControllerPluginProps = {
pluginMeta: PluginWebApi,
pluginInfo?: lib.PluginWebpackEnvInfo,
loadedPlugin?: lib.PluginWebpackEnvInfo,
title: string,
};
/** What the controller and web UI have loaded of the plugin */
function ControllerPlugin(props: ControllerPluginProps) {
const { pluginMeta, pluginInfo, loadedPlugin } = props;
const pluginError = pluginMeta.web.error ?? pluginInfo?.error;
const hasWebModule = Boolean(pluginMeta.web.main || pluginMeta.web.error);
if (!loadedPlugin) {
return <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle}>
return <>
<Descriptions bordered size="small" title={props.title}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded in Web UI" span={2}>
{hasWebModule ? "No" : "No web module"}
</Descriptions.Item>
<Descriptions.Item label="Loaded on controller">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions.Item label="Enabled on controller" span={2}>
{pluginMeta.enabled ? "Yes" : "No"}
</Descriptions.Item>
</Descriptions>
{pluginError && <Alert
style={{
@@ -73,20 +79,19 @@ export default function PluginViewPage() {
type="error"
showIcon
/>}
</PageLayout>;
</>;
}
return <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle}>
const homepageUrl = lib.safeExternalUrl(loadedPlugin.package.homepage);
return <>
<Descriptions bordered size="small" title={props.title}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded in Web UI" span={2}>Yes</Descriptions.Item>
<Descriptions.Item label="Loaded on controller">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions.Item label="Enabled on controller" span={2}>
{pluginMeta.enabled ? "Yes" : "No"}
</Descriptions.Item>
<Descriptions.Item label="Description" span={3}>{loadedPlugin.description}</Descriptions.Item>
{loadedPlugin.package.homepage ? <Descriptions.Item label="Homepage" span={3}>
<a href={loadedPlugin.package.homepage}>{loadedPlugin.package.homepage}</a>
{homepageUrl
? <a href={homepageUrl}>{loadedPlugin.package.homepage}</a>
: loadedPlugin.package.homepage}
</Descriptions.Item> : null}
{loadedPlugin.package.author ? <Descriptions.Item label="Author" span={3}>
{loadedPlugin.package.author}
@@ -107,5 +112,60 @@ export default function PluginViewPage() {
type="warning"
showIcon
/> : null}
</>;
}
export default function PluginViewPage() {
let params = useParams();
const control = useContext(ControlContext);
let [pluginList, setPluginList] = useState<PluginWebApi[]>();
let pluginName = String(params.name);
const { machines, unknown, refresh } = useMachinePlugins();
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
}
})();
}, []);
const pluginMeta = pluginList?.find(p => p.name === pluginName);
// Plugins the controller hasn't loaded are linked to by their npm package or name on the hosts
const status = machines
? computePluginStatuses(machines, unknown).get(pluginMeta ? pluginStatusKey(pluginMeta) : pluginName)
: undefined;
const pluginInfo = control.pluginInfos.get(pluginName);
const pluginTitle = pluginInfo?.title ?? status?.title ?? pluginName;
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
if (!pluginList || (!pluginMeta && !machines)) {
return <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle} />
<Spin size="large" />
</PageLayout>;
}
if (!pluginMeta && !status) {
return <PageLayout nav={nav}>
<PageHeader title="Plugin not found" />
<p>Plugin with name {pluginName} was not found on the controller or any host.</p>
</PageLayout>;
}
return <PageLayout nav={nav}>
{pluginMeta
? <ControllerPlugin
pluginMeta={pluginMeta}
pluginInfo={pluginInfo}
loadedPlugin={control.loadedPlugins.get(pluginName)}
title={pluginTitle}
/>
: <Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Loaded on controller">No</Descriptions.Item>
</Descriptions>}
{status && <PluginMachines status={status} title={pluginTitle} refresh={refresh} />}
</PageLayout>;
}
+116 -57
View File
@@ -1,12 +1,19 @@
import React, { useContext, useEffect, useState } from "react";
import { Table } from "antd";
import { Space, Table, Typography } from "antd";
import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled";
import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled";
import type { PluginWebApi, PluginWebpackEnvInfo } from "@clusterio/lib";
import notify from "../util/notify";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { PluginBrowserButton } from "./PluginBrowser";
import { usePluginInstaller } from "./PluginInstall";
import {
PluginUpdateButton, PluginVersions, RestartPendingButton, UpdateAllButton, useLatestVersions, useMachinePlugins,
} from "./PluginClusterStatus";
import { PluginStatus, computePluginStatuses, pluginStatusKey } from "../util/plugin_status";
import PageLayout from "./PageLayout";
import PageHeader from "./PageHeader";
import useTableQueryState from "../util/useTableQueryState";
@@ -17,20 +24,60 @@ import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
type PluginRow = {
meta: PluginWebApi;
key: string;
title: string;
/** Not present for plugins the controller hasn't loaded */
meta?: PluginWebApi;
info?: PluginWebpackEnvInfo;
package?: any;
status?: PluginStatus;
};
/** Plugins the controller hasn't loaded are found by their status key instead of the plugin name */
function pluginPath(plugin: PluginRow) {
return `/plugins/${encodeURIComponent(plugin.meta?.name ?? plugin.key)}/view`;
}
function WebUiStatus(props: { plugin: PluginRow }) {
const control = useContext(ControlContext);
const { meta } = props.plugin;
if (!meta) {
return <Typography.Text type="secondary">Not on controller</Typography.Text>;
}
if (!meta.enabled) {
return <><InfoCircleFilled style={{ color: "#1668dc" }} /> Disabled on controller</>;
}
if (!meta.web.main && !meta.web.error) {
return <Typography.Text type="secondary">No web module</Typography.Text>;
}
if (!meta.web.error && !control.pluginInfos.has(meta.name)) {
return <><InfoCircleFilled style={{ color: "#1668dc" }} /> Reload page to load</>;
}
if (!props.plugin.package) {
return <><CloseCircleFilled style={{ color: "#dc4446" }} /> Error loading module</>;
}
if (props.plugin.package.version !== meta.version) {
return <><CloseCircleFilled style={{ color: "#dc4446" }} /> Version mis-matched</>;
}
return <>Loaded {meta.version}</>;
}
export default function PluginsPage() {
const control = useContext(ControlContext);
const account = useAccount();
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
const tableState = useTableQueryState<PluginRow>({
namespace: "plugin", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<PluginRow>(
tableState, "name", plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins"
tableState, "name", plugin => plugin.title, "Search plugins"
);
const { machines, unknown, refresh } = useMachinePlugins();
const installer = usePluginInstaller(refresh);
const statuses = machines ? computePluginStatuses(machines, unknown) : new Map<string, PluginStatus>();
const latestVersions = useLatestVersions(
[...statuses.values()].flatMap(status => (status.npmPackage ? [status.npmPackage] : []))
);
const rowNav = useRowNavigation();
@@ -47,84 +94,96 @@ export default function PluginsPage() {
let tableContents: PluginRow[] = [];
for (let meta of pluginList) {
const info = control.loadedPlugins.get(meta.name);
if (info) {
tableContents.push({
meta, info,
package: info.package,
});
} else {
tableContents.push({
meta, info: control.pluginInfos.get(meta.name),
});
const key = pluginStatusKey(meta);
const loaded = control.loadedPlugins.get(meta.name);
const info = loaded ?? control.pluginInfos.get(meta.name);
tableContents.push({
key,
title: info ? info.title : meta.name,
meta,
info,
package: loaded?.package,
status: statuses.get(key),
});
}
// Plugins installed on hosts, or waiting for a restart, that the controller hasn't loaded
const listedKeys = new Set(tableContents.map(row => row.key));
for (const status of statuses.values()) {
if (!listedKeys.has(status.key)) {
tableContents.push({ key: status.key, title: status.title, status });
}
}
const latestOf = (plugin: PluginRow) => (
plugin.status?.npmPackage ? latestVersions[plugin.status.npmPackage] : undefined
);
return <PageLayout nav={[{ name: "Plugins" }]}>
<PageHeader title="Plugins" />
<PageHeader
title="Plugins"
extra={<Space wrap>
<RestartPendingButton
statuses={[...statuses.values()]} machines={machines ?? []} installer={installer}
/>
<UpdateAllButton
statuses={[...statuses.values()]}
titles={new Map(tableContents.map(row => [row.key, row.title]))}
latestVersions={latestVersions}
installer={installer}
/>
{account.hasPermission("core.plugin.search")
&& <PluginBrowserButton statuses={machines && statuses} installer={installer} />}
</Space>}
/>
<Table
columns={[
{
title: "Name",
key: "name",
className: "table-link-cell",
render: (_, plugin) => <Link
to={`/plugins/${plugin.meta.name}/view`}
style={{ color: "inherit" }}
>
{plugin.info ? plugin.info.title : plugin.meta.name}
render: (_, plugin) => <Link to={pluginPath(plugin)} style={{ color: "inherit" }}>
{plugin.title}
</Link>,
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
sorter: (a, b) => strcmp(a.title, b.title),
sortOrder: tableState.sortOrder("name"),
filteredValue: tableState.filteredValue("name"),
...nameSearch,
},
{
title: "Version",
key: "version",
render: (_, plugin) => {
if (!plugin.meta.enabled) {
return <><InfoCircleFilled style={{ color: "#1668dc" }} /> Disabled on controller</>;
}
if (!plugin.meta.web.main && !plugin.meta.web.error) {
return plugin.meta.version;
}
if (!plugin.meta.web.error && !control.pluginInfos.has(plugin.meta.name)) {
return <><InfoCircleFilled style={{ color: "#1668dc" }} /> Reload page to load</>;
}
if (!plugin.package) {
return <><CloseCircleFilled style={{ color: "#dc4446" }} /> Error loading module</>;
}
if (plugin.package.version !== plugin.meta.version) {
return <><CloseCircleFilled style={{ color: "#dc4446" }} /> Version mis-matched</>;
}
return plugin.package.version;
},
sorter: (a, b) => strcmp(a.meta.version, b.meta.version),
sortOrder: tableState.sortOrder("version"),
title: "Versions",
key: "versions",
render: (_, plugin) => (plugin.status
? <PluginVersions status={plugin.status} latest={latestOf(plugin)} />
: plugin.meta?.version
),
},
{
title: "Loaded",
key: "loaded",
render: (_, plugin) => {
if (plugin.package) {
return "Yes";
}
if (!plugin.meta.web.main && !plugin.meta.web.error) {
return "No web module";
}
return null;
},
sorter: (a, b) => Number(Boolean(a.package)) - Number(Boolean(b.package)),
sortOrder: tableState.sortOrder("loaded"),
title: "Web UI Status",
key: "web",
render: (_, plugin) => <WebUiStatus plugin={plugin} />,
responsive: ["sm"],
},
{
key: "actions",
align: "right",
render: (_, plugin) => (plugin.status
// Keep clicks on the update action from opening the plugin
? <span onClick={event => { event.stopPropagation(); }}>
<PluginUpdateButton
status={plugin.status}
title={plugin.title}
latest={latestOf(plugin)}
installer={installer}
/>
</span>
: null
),
},
]}
dataSource={tableContents}
rowKey={plugin => plugin.meta.name}
rowKey={plugin => plugin.key}
pagination={tableState.pagination}
onChange={tableState.onChange}
onRow={plugin => rowNav(`/plugins/${plugin.meta.name}/view`)}
onRow={plugin => rowNav(pluginPath(plugin))}
/>
</PageLayout>;
}
+25
View File
@@ -0,0 +1,25 @@
/** Runs one task at a time, refusing tasks started while another is running */
export class ExclusiveRunner<State> {
current?: State;
onChange: (state?: State) => void;
constructor(onChange: (state?: State) => void) {
this.onChange = onChange;
}
/** Run the task unless another is running, returns false if it was refused */
async run(state: State, task: () => Promise<void>) {
if (this.current !== undefined) {
return false;
}
this.current = state;
this.onChange(state);
try {
await task();
} finally {
this.current = undefined;
this.onChange(undefined);
}
return true;
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { notification } from "antd";
import { logger } from "@clusterio/lib";
@@ -5,7 +6,7 @@ import { logger } from "@clusterio/lib";
export default function notify(
message: string | Error,
type: "success"|"info"|"warning"|"error" = "info",
description?: string,
description?: ReactNode,
) {
notification[type]({
message: typeof message === "string" ? message : "ERROR: See console",
+233
View File
@@ -0,0 +1,233 @@
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[] };
/** 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,
running?: string,
afterRestart?: string,
disabled: boolean,
/** Installed but never loaded on this machine as it has no entrypoint for it */
noEntrypoint: boolean,
/** The group's npm package name is a dependency here for another plugin, such as an alias */
dependencyTaken: boolean,
/** Another package here provides the plugin by the same name, startup skips this one */
nameTaken: boolean,
/** 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);
}
/** 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 absent = !plugin || Boolean(plugin.removed);
const dependencyTaken = absent && plugins.some(
candidate => candidate.dependencyName === key && !candidate.removed
);
const nameTaken = Boolean(plugin?.nameTaken) || (absent || Boolean(plugin.pendingInstall)) && plugins.some(
candidate => (
pluginStatusKey(candidate) !== key && names.has(candidate.name)
&& !candidate.removed && !candidate.pendingInstall && !candidate.nameTaken
)
);
const noEntrypoint = Boolean(plugin?.noEntrypoint);
const oldHost = !machine.isController && plugins.length > 0 && plugins.every(
candidate => candidate.installedVersion === undefined && !candidate.removed
);
const entry: MachinePlugin = {
...rest, plugin, disabled: false, noEntrypoint, dependencyTaken, nameTaken, oldHost,
};
// What runs now follows loaded, what runs after a restart follows enabled and what is on disk
entry.running = candidates.find(candidate => candidate.loaded)?.version;
if (nameTaken) {
return entry;
}
if (plugin?.pendingInstall) {
// Plugins load by default, so a new install runs after the next restart
entry.afterRestart = plugin.installedVersion ?? plugin.version;
} else if (plugin && !plugin.removed && !noEntrypoint) {
if (plugin.enabled) {
entry.afterRestart = plugin.installedVersion ?? plugin.version;
} else {
entry.disabled = true;
}
}
return entry;
}
/** 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.pendingInstall && !plugin.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.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.nameTaken) {
skip("another package provides this plugin", "install");
} else if (machine.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.nameTaken) {
labels.push("Another package provides this plugin");
} else if (!machine.plugin || machine.plugin.removed) {
labels.push(machine.dependencyTaken ? "Another package is installed by this name" : "Not installed");
} else {
if (!machine.plugin.npmPackage) {
labels.push("Not installed from npm");
}
if (machine.noEntrypoint) {
labels.push("No entrypoint for this machine");
}
if (machine.disabled) {
labels.push("Disabled");
}
}
if (machine.running !== machine.afterRestart) {
labels.push("Restart pending");
}
return labels;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Run a task now and again intervalMs after each run finishes, so runs never overlap.
* setInterval can't be used as an async task may take longer than the interval.
* The task handles its own errors.
*
* @returns Function that stops polling, a run in progress still finishes
*/
export function poll(task: () => Promise<void>, intervalMs: number) {
let stopped = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
async function run() {
if (stopped) {
return;
}
await task();
// Stopping while the task ran must not schedule another run
if (!stopped) {
timeout = setTimeout(run, intervalMs);
}
}
run();
return () => {
stopped = true;
clearTimeout(timeout);
};
}
+43 -2
View File
@@ -10,7 +10,9 @@
* the local and published versions of the lib. A message whose serialization
* (src, dst, data schema or response schema) changed fails the test, while
* added and removed messages are only warned about since breaking
* compatibility is unavoidable there.
* compatibility is unavoidable there. Optional properties that only one
* version has are left out of the comparison, as the other version ignores
* them when received and does not need them when sent.
*
* Usage:
* node scripts/version-compat-test.js --controller local --host published
@@ -137,6 +139,35 @@ function stableStringify(value) {
));
}
/**
* Copy of a schema without the optional properties that the other schema doesn't have. Objects with
* additionalProperties false on either side keep them, as those reject unknown properties.
*/
function withoutOneSidedOptionals(schema, other) {
if (Array.isArray(schema)) {
return schema.map((item, index) => withoutOneSidedOptionals(item, other?.[index]));
}
if (!schema || typeof schema !== "object") {
return schema;
}
const result = {};
for (const [key, value] of Object.entries(schema)) {
if (key !== "properties") {
result[key] = withoutOneSidedOptionals(value, other?.[key]);
continue;
}
const closed = schema.additionalProperties === false || other?.additionalProperties === false;
result.properties = {};
for (const [name, property] of Object.entries(value)) {
const otherProperty = other?.properties?.[name];
if (otherProperty || closed || schema.required?.includes(name)) {
result.properties[name] = withoutOneSidedOptionals(property, otherProperty);
}
}
}
return result;
}
// Runs in a child process because only one copy of the lib may be loaded per process.
const dumpMessageSpecs = `
const { Link } = require(process.argv[1]);
@@ -177,9 +208,19 @@ async function checkMessageCompat(publishedVersion) {
annotate(`Link ${name} does not exist in ${publishedVersion} which cannot receive it`);
continue;
}
const fields = Object.keys(localSpec).filter(
const differs = Object.keys(localSpec).filter(
field => stableStringify(localSpec[field]) !== stableStringify(publishedSpec[field])
);
const fields = differs.filter(field => (
stableStringify(withoutOneSidedOptionals(localSpec[field], publishedSpec[field])) !==
stableStringify(withoutOneSidedOptionals(publishedSpec[field], localSpec[field]))
));
const optionalOnly = differs.filter(field => !fields.includes(field));
if (optionalOnly.length) {
console.log(
`| Link ${name} changed optional properties in ${optionalOnly.join(", ")} since ${publishedVersion}`
);
}
if (fields.length) {
changed.push({ name, fields, localSpec, publishedSpec });
}
@@ -107,6 +107,10 @@ describe("controller/src/ControlConnection", function() {
await install("restart_test");
assert.equal(mockController.config.restartRequired, false);
});
it("leaves the flag alone for a pinned version of an already loaded plugin", async function() {
await install("restart_test@1.0.0");
assert.equal(mockController.config.restartRequired, false);
});
it("rejects when installs are disabled", async function() {
mockController.config = new lib.ControllerConfig(
"controller", { "controller.allow_plugin_install": false }
+44
View File
@@ -9,6 +9,7 @@ import * as lib from "@clusterio/lib";
import Host, { _discoverInstances, HostRouter } from "@clusterio/host/dist/node/src/Host.js";
import Instance from "@clusterio/host/dist/node/src/Instance.js";
import { HostConnector } from "@clusterio/host/dist/node/host.js";
import { computePluginStatuses } from "../../packages/web_ui/src/util/plugin_status.ts";
import "../setup_logging.js";
describe("host/src/Host", function() {
@@ -285,6 +286,31 @@ describe("host/src/Host", function() {
);
});
});
describe(".handlePluginListRequest()", function() {
it("does not expect a plugin without a host or instance entrypoint to load", async function() {
lib.addPluginConfigFields([{ name: "controller_only", controllerEntrypoint: "controller.js" }]);
const packagePath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json"));
const pluginInfos = [{
name: "controller_only", title: "Controller only", version: "1.0.0",
requirePath: path.dirname(packagePath), packagePath, controllerEntrypoint: "controller.js",
}];
// Listed the same before and after a restart, as the host never loads it
for (let restart = 0; restart < 2; restart++) {
const hostConfig = new lib.HostConfig("host");
const host = new Host(new HostConnector(hostConfig, pluginInfos), hostConfig, pluginInfos);
await host.loadPlugins();
const plugins = await host.handlePluginListRequest(new lib.PluginListRequest());
const plugin = plugins.find(candidate => candidate.name === "controller_only");
assert.equal(plugin.loaded, false);
assert.equal(plugin.noEntrypoint, true);
const [entry] = computePluginStatuses([
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
]).get("controller_only").machines;
assert.equal(entry.running, entry.afterRestart, "restart pending");
}
});
});
describe(".handlePluginInstallRequest()", function() {
let host;
let _fetch;
@@ -307,6 +333,10 @@ describe("host/src/Host", function() {
await host.handlePluginInstallRequest(new lib.PluginInstallRequest("restart_test"));
assert.equal(host.config.restartRequired, false);
});
it("leaves the flag alone for a pinned version of an already loaded plugin", async function() {
await host.handlePluginInstallRequest(new lib.PluginInstallRequest("restart_test@1.0.0"));
assert.equal(host.config.restartRequired, false);
});
it("rejects when installs are disabled", async function() {
host.config.set("host.allow_plugin_install", false);
host.config.restartRequired = false; // Setting allow_plugin_install requires a restart
@@ -417,6 +447,20 @@ describe("host/src/Host", function() {
assert.equal(process.exitCode, undefined);
assert.equal(mockHost.shutdownCalled, false);
});
it("rejects while a plugin install or update is running", async function() {
// Held by another live process
await fs.writeFile("npm-operation.lock", `${process.ppid}\n`);
try {
await assert.rejects(
() => mockHost.handleHostRestartRequest(),
/Cannot restart while a plugin install or update is running/
);
} finally {
await fs.rm("npm-operation.lock", { force: true });
}
assert.equal(process.exitCode, undefined);
assert.equal(mockHost.shutdownCalled, false);
});
});
});
});
+14 -1
View File
@@ -15,7 +15,7 @@ import {
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
requiresFactorio, hasFactorio,
} from "./index.js";
import { slowTest } from "../common.js";
import { externalTest, slowTest } from "../common.js";
import { getTestRconPort } from "./rcon_port.js";
import { ControllerEcho, HostEchoReceived } from "../file/test_plugin/messages.js";
@@ -540,6 +540,19 @@ describe("Integration of Clusterio", function() {
// Install always fails, we can not test restart option
});
describe("controller plugin search", function() {
it("runs", async function() {
externalTest(this);
await execCtl("controller plugin search global_chat --page-size 5");
});
it("rejects invalid paging", async function() {
await assert.rejects(
execCtl("controller plugin search --page 0"),
/Invalid page or page size/
);
});
});
describe("controller update", function() {
it("runs", async function() {
await execCtl("controller update");
+163 -10
View File
@@ -1,17 +1,25 @@
/* eslint-disable node/no-sync */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { LockFile } from "@clusterio/lib";
const baseDir = path.join("temp", "test", "lock_file");
const ownContent = `${process.pid} ${performance.timeOrigin}\n`;
// The parent process (mocha) is alive but isn't this process
const otherContent = `${process.ppid}\n`;
const exitedContent = "999999999\n";
describe("class LockFile", function () {
/** @type {string} */
let filePath;
/** @type {string} */
let recoveryPath;
/** @type {LockFile} */
let lock;
const writeFile = fs.writeFile;
before(async function () {
await fs.mkdir(baseDir, { recursive: true });
@@ -19,17 +27,30 @@ describe("class LockFile", function () {
beforeEach(function() {
filePath = path.join(baseDir, `${Date.now()}-${Math.random()}.lock`);
recoveryPath = `${filePath}.recovery`;
lock = new LockFile(filePath);
});
afterEach(async function () {
fs.writeFile = writeFile;
if (await lock.isHeld()) {
await lock.release();
}
await fs.unlink(filePath).catch(() => {});
await fs.unlink(recoveryPath).catch(() => {});
});
async function writeOld(content) {
await fs.writeFile(filePath, content);
const old = new Date(Date.now() - 60e3);
await fs.utimes(filePath, old, old);
return old;
}
describe("acquire()", function () {
it("should create the lock file when acquiring", async function () {
await lock.acquire();
await fs.access(filePath);
assert.equal(await fs.readFile(filePath, "utf8"), ownContent);
});
it("should throw LockFileBusyError if called when not idle", async function () {
@@ -38,15 +59,89 @@ describe("class LockFile", function () {
});
it("should remove stale lock file and acquire it", async function () {
await fs.writeFile(filePath, "999999\n"); // stale lock
await fs.writeFile(filePath, exitedContent);
await lock.acquire();
await fs.access(filePath);
assert.equal(await fs.readFile(filePath, "utf8"), ownContent);
await assert.rejects(fs.access(recoveryPath));
});
it("should throw if existing lock file is not stale", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
await fs.writeFile(filePath, otherContent);
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
});
it("should throw if another lock in this process holds the file", async function () {
const other = new LockFile(filePath);
await other.acquire();
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
await other.release();
});
it("should acquire a lock left by an earlier process with the same pid", async function () {
for (const content of [`${process.pid}\n`, `${process.pid} 1234.5\n`]) {
await fs.writeFile(filePath, content);
await lock.acquire();
await lock.release();
}
});
it("should throw while the lock file may still be being written", async function () {
await fs.writeFile(filePath, "");
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
});
it("should acquire an unreadable lock that is old", async function () {
await writeOld("");
await lock.acquire();
});
// Runs takeOver right before this process starts clearing the stale lock, as another process could
function beforeRecovery(takeOver) {
fs.writeFile = async function(...args) {
if (String(args[0]).endsWith(".recovery")) {
fs.writeFile = writeFile;
await takeOver();
}
return writeFile(...args);
};
}
it("should leave a lock taken after the stale check in place", async function () {
await fs.writeFile(filePath, exitedContent);
beforeRecovery(async () => {
await fs.rm(filePath);
await fs.writeFile(filePath, otherContent);
});
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
assert.equal(await fs.readFile(filePath, "utf8"), otherContent);
await assert.rejects(fs.access(recoveryPath));
});
it("should leave a new empty lock in place of an old empty lock", async function () {
const old = await writeOld("");
// Another process clears it and creates a new lock that it hasn't written its pid to yet
beforeRecovery(async () => {
await fs.rm(filePath);
await fs.writeFile(filePath, "");
});
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
assert.equal(await fs.readFile(filePath, "utf8"), "");
assert((await fs.stat(filePath)).mtimeMs > old.getTime() + 30e3);
});
it("should leave a stale lock alone while another process clears it", async function () {
await fs.writeFile(filePath, exitedContent);
await fs.writeFile(recoveryPath, otherContent);
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
assert.equal(await fs.readFile(filePath, "utf8"), exitedContent);
assert.equal(await fs.readFile(recoveryPath, "utf8"), otherContent);
});
it("should throw LockFileRecoveryError if clearing a stale lock was interrupted", async function () {
await fs.writeFile(filePath, exitedContent);
await fs.writeFile(recoveryPath, exitedContent);
await assert.rejects(lock.acquire(), { name: "LockFileRecoveryError" });
});
});
describe("release()", function () {
@@ -59,6 +154,13 @@ describe("class LockFile", function () {
it("should throw LockFileBusyError if called when not acquired", async function () {
await assert.rejects(lock.release(), { name: "LockFileBusyError" });
});
it("should leave a lock file it does not own in place", async function () {
await lock.acquire();
await fs.writeFile(filePath, otherContent);
await lock.release();
assert.equal(await fs.readFile(filePath, "utf8"), otherContent);
});
});
describe("isHeld()", function () {
@@ -74,7 +176,7 @@ describe("class LockFile", function () {
});
it("should return false if lock file contains different PID", async function () {
await fs.writeFile(filePath, "999999\n"); // simulate another process
await fs.writeFile(filePath, otherContent);
const held = await lock.isHeld();
assert.equal(held, false);
});
@@ -93,23 +195,54 @@ describe("class LockFile", function () {
assert.equal(stale, true);
});
it("should return true for invalid PID in lock file", async function () {
await fs.writeFile(filePath, "abc\n"); // non-numeric PID
it("should return true for invalid PID in an old lock file", async function () {
await writeOld("abc\n");
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return false for invalid PID in a new lock file", async function () {
await fs.writeFile(filePath, "abc\n");
const stale = await lock.isStale();
assert.equal(stale, false);
});
it("should return true for non-existent process PID", async function () {
await fs.writeFile(filePath, "999999\n"); // assume PID does not exist
await fs.writeFile(filePath, exitedContent);
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return false for current process PID", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
it("should return false for another running process", async function () {
await fs.writeFile(filePath, otherContent);
const stale = await lock.isStale();
assert.equal(stale, false);
});
it("should return false for a lock taken by this process", async function () {
await lock.acquire();
const stale = await new LockFile(filePath).isStale();
assert.equal(stale, false);
});
it("should return true for an earlier process with the same pid", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
const stale = await lock.isStale();
assert.equal(stale, true);
});
});
describe("holderPid()", function () {
it("should return the pid in the lock file", async function () {
await fs.writeFile(filePath, `${process.ppid} 1234.5\n`);
assert.equal(await lock.holderPid(), process.ppid);
});
it("should return undefined without a valid pid", async function () {
assert.equal(await lock.holderPid(), undefined);
await fs.writeFile(filePath, "");
assert.equal(await lock.holderPid(), undefined);
});
});
describe("attach/detach exit handlers", function () {
@@ -117,10 +250,21 @@ describe("class LockFile", function () {
await lock.acquire();
const listenersBefore = process.listeners("exit").slice();
assert.ok(listenersBefore.includes(lock.handleExitSync));
assert.ok(process.listeners("SIGTERM").includes(lock.handleExit));
await lock.release();
const listenersAfter = process.listeners("exit").slice();
assert.ok(!listenersAfter.includes(lock.handleExitSync));
assert.ok(!process.listeners("SIGTERM").includes(lock.handleExit));
});
it("should only attach the exit handler without handleSignals", async function () {
lock = new LockFile(filePath, false);
await lock.acquire();
assert.ok(process.listeners("exit").includes(lock.handleExitSync));
for (const event of ["SIGINT", "SIGTERM", "SIGHUP", "uncaughtException"]) {
assert.ok(!process.listeners(event).includes(lock.handleExit), event);
}
});
});
@@ -128,5 +272,14 @@ describe("class LockFile", function () {
it("should not throw if file already removed", function () {
assert.doesNotThrow(() => lock.handleExitSync()); // simulate non-existent file
});
it("should only remove a lock file it owns", async function () {
await fs.writeFile(filePath, otherContent);
lock.handleExitSync();
assert.equal(fsSync.readFileSync(filePath, "utf8"), otherContent);
await fs.writeFile(filePath, ownContent);
lock.handleExitSync();
assert.equal(fsSync.existsSync(filePath), false);
});
});
});
+13 -1
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { SystemInfo } from "@clusterio/lib";
import { PluginDetails, SystemInfo } from "@clusterio/lib";
import { testRoundTripJsonSerialisable } from "../../common.js";
describe("lib/data/SystemInfo", function() {
@@ -32,5 +33,16 @@ describe("lib/data/SystemInfo", function() {
assert.equal(system.diskUsed, 1536);
assert.equal(system.diskRatio, 0.75);
});
it("has round trip json serialisation", function() {
const base = [
1, "host", "v22.0.0", "Linux", "x86_64", "CPU", [0.5],
1024, 768, 2048, 512, true, false, 0, 0, 0, false,
];
testRoundTripJsonSerialisable(SystemInfo, [
base,
[...base, []],
[...base, [new PluginDetails("name", "Title", "1.0.0", true, true, undefined, "name", "1.1.0")]],
]);
});
});
});
+61
View File
@@ -284,6 +284,67 @@ describe("lib/helpers", function() {
});
});
describe("safeExternalUrl()", function() {
it("should return http and https URLs", function() {
assert.equal(lib.safeExternalUrl("https://example.com/path?q=1#top"), "https://example.com/path?q=1#top");
assert.equal(lib.safeExternalUrl("http://example.com"), "http://example.com/");
assert.equal(lib.safeExternalUrl(" https://example.com/ "), "https://example.com/");
});
it("should reject links with other protocols", function() {
/* eslint-disable no-script-url */
for (const link of [
"javascript:alert(1)",
"JavaScript:alert(1)",
" javascript:alert(1)",
"java\tscript:alert(1)",
"\u0001javascript:alert(1)",
/* eslint-enable no-script-url */
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"file:///etc/passwd",
"ftp://example.com/",
]) {
assert.equal(lib.safeExternalUrl(link), undefined, link);
}
});
it("should reject values that are not URLs", function() {
for (const link of [undefined, null, 1, {}, { url: 1 }, "", "not a url", "/relative", "owner/repo"]) {
assert.equal(lib.safeExternalUrl(link), undefined, String(link));
}
});
it("should accept npm repository objects", function() {
assert.equal(
lib.safeExternalUrl({ type: "git", url: "git+https://github.com/owner/repo.git" }),
"https://github.com/owner/repo"
);
// eslint-disable-next-line no-script-url
assert.equal(lib.safeExternalUrl({ type: "git", url: "javascript:alert(1)" }), undefined);
});
it("should convert git URLs to https", function() {
for (const link of [
"git+https://github.com/owner/repo.git",
"git://github.com/owner/repo.git",
"git+ssh://git@github.com/owner/repo.git",
"git@github.com:owner/repo.git",
]) {
assert.equal(lib.safeExternalUrl(link), "https://github.com/owner/repo", link);
}
});
it("should expand repository shorthands", function() {
assert.equal(lib.safeExternalUrl("github:owner/repo"), "https://github.com/owner/repo");
assert.equal(lib.safeExternalUrl("gitlab:owner/repo"), "https://gitlab.com/owner/repo");
assert.equal(lib.safeExternalUrl("bitbucket:owner/repo"), "https://bitbucket.org/owner/repo");
assert.equal(lib.safeExternalUrl("github://evil.example/x"), "https://github.com/evil.example/x");
});
it("should remove credentials", function() {
assert.equal(lib.safeExternalUrl("https://user:secret@example.com/"), "https://example.com/");
assert.equal(
lib.safeExternalUrl("git+https://user:secret@example.com/repo.git"),
"https://example.com/repo"
);
});
});
describe("redactQuery()", function() {
it("should redact credential parameters and keep the rest", function() {
const query = { code: "secret", Token: "secret", state: "abc" };
+175
View File
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import "../setup_logging.js";
describe("lib/npm_registry", function() {
describe("fetchLatestVersions()", function() {
const _fetch = global.fetch;
let calls;
let cache;
beforeEach(function() {
calls = [];
cache = new Map();
global.fetch = async function(url, options) {
calls.push(url);
assert.ok(options.signal, "lookups have a timeout");
if (url.includes("broken")) {
throw new Error("network down");
}
if (url.includes("missing")) {
return { ok: false, status: 404 };
}
if (url.includes("limited")) {
return { ok: false, status: 429 };
}
return { ok: true, json: async () => ({ version: "1.2.3" }) };
};
});
after(function() {
global.fetch = _fetch;
});
it("returns the latest version of found packages", async function() {
const versions = await lib.fetchLatestVersions(
["latest-a", "@scope/latest-b", "missing-a", "broken-a"], cache,
);
assert.deepEqual(versions, { "latest-a": "1.2.3", "@scope/latest-b": "1.2.3" });
assert.ok(calls.includes("https://registry.npmjs.com/@scope/latest-b/latest"));
});
it("caches lookups", async function() {
await lib.fetchLatestVersions(["cached-a", "missing-b"], cache);
calls = [];
assert.deepEqual(await lib.fetchLatestVersions(["cached-a", "missing-b"], cache), { "cached-a": "1.2.3" });
assert.deepEqual(calls, []);
});
it("retries packages the registry couldn't be reached for or refused", async function() {
await lib.fetchLatestVersions(["broken-b", "limited-a"], cache);
calls = [];
await lib.fetchLatestVersions(["broken-b", "limited-a"], cache);
assert.equal(calls.length, 2);
});
it("drops expired lookups and keeps the cache bounded", async function() {
cache.set("expired", { version: "0.0.1", fetchedAt: Date.now() - 10 * 60e3 });
await lib.fetchLatestVersions(["fresh"], cache);
assert.equal(cache.has("expired"), false);
for (let i = 0; i < 1000; i++) {
cache.set(`filler-${i}`, { version: "1.0.0", fetchedAt: Date.now() });
}
await lib.fetchLatestVersions(["newest"], cache);
assert.equal(cache.size, 1000);
assert.equal(cache.get("newest").version, "1.2.3");
});
it("limits concurrent lookups", async function() {
let active = 0;
let maxActive = 0;
global.fetch = async function() {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise(resolve => setImmediate(resolve));
active -= 1;
return { ok: true, json: async () => ({ version: "1.0.0" }) };
};
const names = Array.from({ length: 50 }, (_, i) => `package-${i}`);
assert.equal(Object.keys(await lib.fetchLatestVersions(names, cache)).length, 50);
assert.ok(maxActive <= 8, `${maxActive} concurrent lookups`);
});
it("rejects invalid names", async function() {
await assert.rejects(lib.fetchLatestVersions(["foo@1.0.0"], cache), /Invalid plugin name/);
await assert.rejects(lib.fetchLatestVersions(["foo bar"], cache), /Invalid plugin name/);
});
});
describe("searchPlugins()", function() {
const _fetch = global.fetch;
const registryResponse = {
total: 1,
objects: [{
package: {
name: "@clusterio/plugin-foo",
version: "1.0.0",
description: "Foo",
date: "2026-01-01T00:00:00.000Z",
publisher: { username: "bar" },
links: {
npm: "https://www.npmjs.com/package/@clusterio/plugin-foo",
homepage: "https://example.com",
repository: "https://github.com/example/foo",
},
},
}],
};
after(function() {
global.fetch = _fetch;
});
it("queries the registry with the plugin keyword", async function() {
let calledWith;
global.fetch = function(url) {
calledWith = new URL(url);
return { ok: true, json: async () => registryResponse };
};
const response = await lib.searchPlugins("foo", 2, 10);
assert.equal(calledWith.origin + calledWith.pathname, "https://registry.npmjs.com/-/v1/search");
assert.equal(calledWith.searchParams.get("text"), "keywords:clusterio-plugin foo");
assert.equal(calledWith.searchParams.get("size"), "10");
assert.equal(calledWith.searchParams.get("from"), "10");
assert.deepEqual(response, new lib.PluginSearchRequest.Response(1, [
new lib.PluginSearchResult(
"@clusterio/plugin-foo", "1.0.0", "Foo", "bar", "2026-01-01T00:00:00.000Z",
"https://example.com", "https://github.com/example/foo",
"https://www.npmjs.com/package/@clusterio/plugin-foo",
),
]));
});
it("omits the query when empty", async function() {
let calledWith;
global.fetch = function(url) {
calledWith = new URL(url);
return { ok: true, json: async () => ({ total: 0, objects: [] }) };
};
await lib.searchPlugins("", 1, 20);
assert.equal(calledWith.searchParams.get("text"), "keywords:clusterio-plugin");
assert.equal(calledWith.searchParams.get("from"), "0");
});
it("drops optional fields the registry sends as null", async function() {
global.fetch = function() {
return { ok: true, json: async () => ({ total: 1, objects: [{ package: {
name: "foo", version: "1.0.0", description: null, date: null,
publisher: { username: null }, links: { npm: null, homepage: null, repository: null },
} }] }) };
};
const response = await lib.searchPlugins("", 1, 20);
assert.deepEqual(response, new lib.PluginSearchRequest.Response(1, [
new lib.PluginSearchResult("foo", "1.0.0"),
]));
const validate = lib.compile(lib.PluginSearchRequest.Response.jsonSchema);
assert.ok(validate(JSON.parse(JSON.stringify(response))), JSON.stringify(validate.errors));
});
it("handles missing optional fields", async function() {
global.fetch = function() {
return { ok: true, json: async () => ({ total: 1, objects: [{
package: { name: "foo", version: "1.0.0" },
}]}) };
};
const response = await lib.searchPlugins("", 1, 20);
assert.deepEqual(response.results, [new lib.PluginSearchResult("foo", "1.0.0")]);
});
it("rejects invalid paging", async function() {
await assert.rejects(lib.searchPlugins("", 0, 20), /Invalid page or page size/);
await assert.rejects(lib.searchPlugins("", 1, 0), /Invalid page or page size/);
await assert.rejects(lib.searchPlugins("", 1, 251), /Invalid page or page size/);
});
it("rejects overly long queries", async function() {
await assert.rejects(lib.searchPlugins("a".repeat(215), 1, 20), /Search query too long/);
});
it("rejects when the registry errors", async function() {
global.fetch = function() {
return { ok: false, status: 503, statusText: "Service Unavailable" };
};
await assert.rejects(lib.searchPlugins("", 1, 20), /npm registry search failed: 503/);
});
});
});
+356
View File
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
import * as mock from "../mock.js";
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
import { computePluginStatuses, planUpdate } from "../../packages/web_ui/src/util/plugin_status.ts";
describe("lib/plugin_loader", function() {
@@ -48,12 +49,20 @@ describe("lib/plugin_loader", function() {
name: "test",
version: "0.0.1",
npmPackage: undefined,
skippedNpmPackage: undefined,
packagePath: path.resolve(path.join(testPlugin, "package.json")),
requirePath: path.resolve(testPlugin),
webStaticPath: path.resolve(path.join(testPlugin, "dist", "web", "static")),
}]
);
});
it("should record the npm package skipped for a plugin", async function() {
const [pluginInfo] = await lib.loadPluginInfos(
new Map([["test", path.resolve(testPlugin)]]),
new Map([["test", "/skipped"], ["other", "/other"]]),
);
assert.equal(pluginInfo.skippedNpmPackage, "/skipped");
});
it("should reject on broken plugin", async function() {
let brokenMessage;
try {
@@ -275,6 +284,7 @@ describe("lib/plugin_loader", function() {
const nodeModules = path.join(baseDir, "node_modules");
const npmPluginPath = path.join(nodeModules, "plugin-npm");
const npmPluginPathAbs = path.resolve(npmPluginPath);
const aliasedPluginPath = path.join(nodeModules, "plugin-aliased");
const deepPluginPath = path.join(nodeModules, "plugin-deep");
const transitivePluginPath = path.join(nodeModules, "plugin-transitive");
@@ -389,6 +399,352 @@ describe("lib/plugin_loader", function() {
assert.strictEqual(pluginList.get("hidden"), undefined);
});
it("should list installed versions and plugins pending a restart", async function() {
const pluginInfos = [{
name: "npm",
title: "NPM",
version: "0.0.0",
requirePath: npmPluginPathAbs,
packagePath: path.join(npmPluginPathAbs, "package.json"),
npmPackage: "plugin-npm",
}];
const plugins = await lib.listPluginDetails(pluginInfos, () => true, () => true);
const byPackage = new Map(plugins.map(plugin => [plugin.npmPackage ?? plugin.name, plugin]));
const loaded = byPackage.get("plugin-npm");
assert.equal(loaded.version, "0.0.0");
assert.equal(loaded.installedVersion, "0.0.1");
assert.equal(loaded.pendingInstall, undefined);
// Dependencies of plugin packages are found the same way as on startup
for (const packageName of ["plugin-aliased", "plugin-deep", "plugin-transitive"]) {
const pending = byPackage.get(packageName);
assert.equal(pending.pendingInstall, true, packageName);
assert.equal(pending.installedVersion, "0.0.1", packageName);
assert.equal(pending.loaded, false, packageName);
}
// An alias installs a different package than its dependency name
assert.equal(byPackage.get("plugin-aliased").npmPackage, undefined);
assert.equal(byPackage.get("plugin-deep").npmPackage, "plugin-deep");
assert.equal(byPackage.has("not-a-plugin"), false);
assert.equal(byPackage.has("plugin-hidden"), false);
assert.equal(plugins.length, 4);
});
it("should not list a plugin loaded from a path as pending when npm links to it", async function() {
const rootPackageJson = await fs.readFile("package.json", "utf8");
const linkPath = path.join("node_modules", "plugin-linked");
try {
await fs.symlink(externalPluginPathAbs, linkPath, "junction");
await fs.writeFile("package.json", JSON.stringify({
dependencies: { "plugin-linked": "file:external_plugins/external_plugin" },
}));
const plugins = await lib.listPluginDetails([{
name: "external",
title: "External",
version: "0.0.1",
requirePath: externalPluginPathAbs,
packagePath: path.join(externalPluginPathAbs, "package.json"),
}], () => true, () => true);
assert.deepEqual(plugins.map(plugin => plugin.name), ["external"]);
} finally {
await fs.writeFile("package.json", rootPackageJson);
await fs.rm(linkPath, { force: true });
}
});
it("should record npm plugins skipped because their name is taken", async function() {
await fs.writeFile(pluginListPath, JSON.stringify([["npm", localPluginPathAbs]]));
const skipped = new Map();
const list = await lib.loadPluginList(pluginListPath, false, skipped);
assert.equal(list.get("npm"), localPluginPathAbs);
assert.deepEqual([...skipped], [["npm", await fs.realpath(npmPluginPathAbs)]]);
});
it("should not record npm plugins already in the list as skipped", async function() {
await fs.writeFile(pluginListPath, JSON.stringify([["npm", "plugin-npm"]]));
const skipped = new Map();
await lib.loadPluginList(pluginListPath, false, skipped);
assert.deepEqual([...skipped], []);
});
it("should not give plugins from a path, git or url an npm package on startup", async function() {
const rootPackageJson = await fs.readFile("package.json", "utf8");
try {
const root = JSON.parse(rootPackageJson);
root.dependencies["plugin-npm"] = "github:user/plugin-npm";
await fs.writeFile("package.json", JSON.stringify(root));
// loadPluginInfos resolves from where lib is installed, which can't reach these fixtures
const dirs = await lib.findNonRegistryDirs();
assert.equal(dirs.has(await fs.realpath(npmPluginPathAbs)), true);
assert.equal(dirs.has(await fs.realpath(path.resolve(old_cwd, deepPluginPath))), false);
} finally {
await fs.writeFile("package.json", rootPackageJson);
}
});
it("should treat tarball dependencies as not from the registry", function() {
assert.equal(lib.isRegistrySpec("plugin-x-1.0.0.tgz"), false);
assert.equal(lib.isRegistrySpec("plugin-x.tar.gz"), false);
assert.equal(lib.isRegistrySpec("^1.0.0"), true);
assert.equal(lib.isRegistrySpec("latest"), true);
});
it("should replace plugin list entries that no longer exist with npm plugins", async function() {
const gone = path.resolve("plugins", "gone");
await fs.writeFile(pluginListPath, JSON.stringify([["npm", gone], ["aliased", "plugin-gone"]]));
const skipped = new Map();
const list = await lib.loadPluginList(pluginListPath, false, skipped);
assert.equal(list.get("npm"), "plugin-npm");
assert.equal(list.get("aliased"), "plugin-aliased");
assert.deepEqual([...skipped], []);
const saved = new Map(JSON.parse(await fs.readFile(pluginListPath, "utf8")));
assert.equal(saved.get("npm"), "plugin-npm");
});
it("should list npm plugins skipped on startup as not loading while their name is taken", async function() {
const local = {
name: "npm",
title: "Local",
version: "0.0.1",
requirePath: localPluginPathAbs,
packagePath: path.join(localPluginPathAbs, "package.json"),
skippedNpmPackage: await fs.realpath(npmPluginPathAbs),
};
let plugins = await lib.listPluginDetails([local], () => true, () => true);
const taken = plugins.find(plugin => plugin.name === "plugin-npm");
assert.equal(taken.nameTaken, true);
assert.equal(taken.pendingInstall, undefined);
// Loaded on the next restart once the plugin with that name is gone
const gone = { ...local, packagePath: path.resolve("plugins", "gone", "package.json") };
plugins = await lib.listPluginDetails([gone], () => true, () => true);
assert.equal(plugins.find(plugin => plugin.name === "plugin-npm").pendingInstall, true);
});
it("should list a dependency made direct since startup as pending", async function() {
// npm leaves the hoisted package untouched when it becomes a direct dependency
const rootPackageJson = await fs.readFile("package.json", "utf8");
try {
const root = JSON.parse(rootPackageJson);
root.dependencies["plugin-hidden"] = "^0.0.1";
await fs.writeFile("package.json", JSON.stringify(root));
const plugins = await lib.listPluginDetails([], () => true, () => true);
const hidden = plugins.find(plugin => plugin.name === "plugin-hidden");
assert.equal(hidden.pendingInstall, true);
assert.equal(hidden.npmPackage, "plugin-hidden");
} finally {
await fs.writeFile("package.json", rootPackageJson);
}
});
it("should not list plugins nested under another package as pending", async function() {
// Startup loads plugins by package name from the root, which doesn't reach nested packages
const packageJsonPath = path.join("node_modules", "plugin-transitive", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
const packageJson = JSON.parse(original);
packageJson.dependencies["plugin-nested"] = "^0.0.1";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
const plugins = await lib.listPluginDetails([], () => true, () => true);
assert.equal(plugins.some(plugin => plugin.name === "plugin-nested"), false);
assert.ok(plugins.some(plugin => plugin.name === "plugin-transitive"));
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should not list packages from a path, git or url as installable from npm", async function() {
const rootPackageJson = await fs.readFile("package.json", "utf8");
try {
const root = JSON.parse(rootPackageJson);
root.dependencies["plugin-npm"] = "file:../plugin-npm";
root.dependencies["plugin-deep"] = "github:user/plugin-deep";
await fs.writeFile("package.json", JSON.stringify(root));
const plugins = await lib.listPluginDetails([{
name: "npm",
title: "NPM",
version: "0.0.1",
requirePath: "plugin-npm",
packagePath: path.join(npmPluginPathAbs, "package.json"),
npmPackage: "plugin-npm",
}], () => true, () => true);
const npm = plugins.find(plugin => plugin.name === "npm");
assert.equal(npm.npmPackage, undefined);
assert.equal(npm.dependencyName, "plugin-npm");
const deep = plugins.find(plugin => plugin.name === "plugin-deep");
assert.equal(deep.pendingInstall, true);
assert.equal(deep.npmPackage, undefined);
} finally {
await fs.writeFile("package.json", rootPackageJson);
}
});
it("should not list plugins another plugin installs from a path, git or url as installable", async function() {
const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
const packageJson = JSON.parse(original);
packageJson.dependencies["plugin-transitive"] = "github:user/plugin-transitive";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
const transitive = {
name: "transitive",
title: "Transitive",
version: "0.0.1",
requirePath: "plugin-transitive",
packagePath: path.resolve(old_cwd, transitivePluginPath, "package.json"),
npmPackage: "plugin-transitive",
};
const [loaded] = await lib.listPluginDetails([transitive], () => true, () => true);
assert.equal(loaded.npmPackage, undefined);
const plugins = await lib.listPluginDetails([], () => true, () => true);
const pending = plugins.find(plugin => plugin.name === "plugin-transitive");
assert.equal(pending.npmPackage, undefined);
assert.equal(await lib.isNonRegistryPackage("plugin-transitive"), true);
assert.equal(await lib.isNonRegistryPackage("plugin-npm"), false);
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should let optional dependencies override where a plugin is installed from", async function() {
const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
const packageJson = JSON.parse(original);
packageJson.optionalDependencies = { "plugin-transitive": "github:user/plugin-transitive" };
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
const plugins = await lib.listPluginDetails([], () => true, () => true);
const pending = plugins.find(plugin => plugin.name === "plugin-transitive");
assert.equal(pending.npmPackage, undefined);
assert.equal(await lib.isNonRegistryPackage("plugin-transitive"), true);
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should not list a plugin replaced by another package since startup as installable", async function() {
const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
await fs.writeFile(packageJsonPath, JSON.stringify({ ...JSON.parse(original), name: "other" }));
const [plugin] = await lib.listPluginDetails([{
name: "npm",
title: "NPM",
version: "0.0.1",
requirePath: "plugin-npm",
packagePath: path.join(npmPluginPathAbs, "package.json"),
npmPackage: "plugin-npm",
}], () => true, () => true);
assert.equal(plugin.npmPackage, undefined);
assert.equal(plugin.removed, undefined);
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should not list private packages as installable from npm", async function() {
const packageJsonPath = path.join("node_modules", "plugin-deep", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
await fs.writeFile(packageJsonPath, JSON.stringify({ ...JSON.parse(original), private: true }));
const plugins = await lib.listPluginDetails([], () => true, () => true);
const deep = plugins.find(plugin => plugin.name === "plugin-deep");
assert.equal(deep.pendingInstall, true);
assert.equal(deep.npmPackage, undefined);
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should skip packages whose package.json is being rewritten", async function() {
const packageJsonPath = path.join("node_modules", "plugin-deep", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
await fs.writeFile(packageJsonPath, original.slice(0, 20));
const plugins = await lib.listPluginDetails([], () => true, () => true);
assert.equal(plugins.some(plugin => plugin.name === "plugin-deep"), false);
assert.ok(plugins.some(plugin => plugin.name === "plugin-aliased"));
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should skip packages with a malformed package.json before they are first resolved", async function() {
const rootPackageJson = await fs.readFile("package.json", "utf8");
const brokenPath = path.join("node_modules", "plugin-half-written");
try {
await fs.mkdir(brokenPath, { recursive: true });
await fs.writeFile(path.join(brokenPath, "package.json"), '{ "name": "plugin-half');
const root = JSON.parse(rootPackageJson);
root.dependencies["plugin-half-written"] = "^1.0.0";
await fs.writeFile("package.json", JSON.stringify(root));
const plugins = await lib.listPluginDetails([], () => true, () => true);
assert.equal(plugins.some(plugin => plugin.name === "plugin-half-written"), false);
assert.ok(plugins.some(plugin => plugin.name === "plugin-aliased"));
} finally {
await fs.writeFile("package.json", rootPackageJson);
await fs.rm(brokenPath, { recursive: true, force: true });
}
});
it("should not plan installing over an alias loaded on another machine", async function() {
// As loadPluginInfos returns it for the aliased plugin found on startup
assert.equal(pluginList.get("aliased"), "plugin-aliased");
const aliasInfo = {
name: "aliased",
title: "Aliased",
version: "0.0.1",
requirePath: "plugin-aliased",
packagePath: path.resolve("node_modules", "plugin-aliased", "package.json"),
npmPackage: undefined,
};
const hostB = await lib.listPluginDetails([aliasInfo], () => true, () => true);
const alias = hostB.find(plugin => plugin.name === "aliased");
assert.equal(alias.dependencyName, "plugin-aliased");
assert.equal(alias.npmPackage, undefined);
const published = new lib.PluginDetails(
"published", "Published", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0",
);
const statuses = computePluginStatuses([
{ name: "A", isController: false, address: { hostId: 1 }, plugins: [published] },
{ name: "B", isController: false, address: { hostId: 2 }, plugins: hostB },
]);
const plan = planUpdate(
statuses.get("plugin-aliased"), "2.0.0",
{ canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true },
);
assert.deepEqual(plan.operations.map(operation => operation.target.name), ["A"]);
assert.deepEqual(
plan.skipped.map(skip => `${skip.target.name} (${skip.reason})`),
["B (another package is installed as plugin-aliased)"],
);
});
it("should mark plugins uninstalled since startup as removed", async function() {
const pluginInfos = [{
name: "gone",
title: "Gone",
version: "1.0.0",
requirePath: "plugin-gone",
packagePath: path.resolve("node_modules", "plugin-gone", "package.json"),
npmPackage: "plugin-gone",
}, {
name: "npm",
title: "NPM",
version: "0.0.1",
requirePath: npmPluginPathAbs,
packagePath: path.join(npmPluginPathAbs, "package.json"),
npmPackage: "plugin-npm",
}];
const plugins = await lib.listPluginDetails(pluginInfos, () => true, () => true);
const byPackage = new Map(plugins.map(plugin => [plugin.npmPackage ?? plugin.name, plugin]));
assert.equal(byPackage.get("plugin-gone").removed, true);
assert.equal(byPackage.get("plugin-gone").installedVersion, undefined);
assert.equal(byPackage.get("plugin-npm").removed, undefined);
});
it("should load existing plugin list", async function() {
const existingList = new Map([["test", "/test/path"]]);
await fs.writeFile(pluginListPath, JSON.stringify([...existingList]));
+312 -15
View File
@@ -1,35 +1,161 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import fsSync from "node:fs";
import * as lib from "@clusterio/lib";
import "../setup_logging.js";
describe("rce_ops", function() {
describe("updatePackage", function() {
// Installation with dependencies installed from a path, an alias, git and the registry. Pinned
// installs and updates must not replace the ones not from the registry.
function useLocalDependencies() {
const cwd = process.cwd();
const dir = path.resolve("temp", "test", "rce_ops_local_dependency");
const _fetch = global.fetch;
before(async function() {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ dependencies: {
"linked": "file:../linked",
"aliased": "npm:other@^1.0.0",
"from-git": "github:user/from-git",
"published": "^1.0.0",
"optional": "^1.0.0",
}, optionalDependencies: {
"optional": "github:user/optional",
} }));
process.chdir(dir);
global.fetch = function() { return { ok: true }; };
});
after(function() {
process.chdir(cwd);
global.fetch = _fetch;
});
return dir;
}
describe("lib/rce_ops", function() {
describe("updatePackage()", function() {
it("runs", async function() {
// In dev env no side effects occur, so best we can do is check it doesn't error
await lib.updatePackage("foo");
});
});
describe("installPackage", function() {
describe("installPackage()", function() {
it("runs", async function() {
// In dev env no side effects occur, so best we can do is check it doesn't error
await lib.installPackage("foo");
});
});
describe("handlePluginUpdate", function() {
it("runs", async function() {
await lib.handlePluginUpdate("foo", [{ npmPackage: "foo" }]);
});
it("rejects when plugin not installed", async function() {
await assert.rejects(
lib.handlePluginUpdate("foo", []),
/Plugin foo is not installed on this machine/
);
describe("npm-operation.lock", function() {
const lockPath = "npm-operation.lock";
const inProgress = /Another package operation is already in progress/;
const audit = lib.logger.audit;
const recoveryPath = `${lockPath}.recovery`;
afterEach(async function() {
lib.logger.audit = audit;
await fs.rm(lockPath, { force: true });
await fs.rm(recoveryPath, { force: true });
});
it("rejects while another operation is in progress", async function() {
let nested;
lib.logger.audit = function() {
lib.logger.audit = audit;
nested = lib.installPackage("bar");
};
await lib.installPackage("foo");
await assert.rejects(nested, inProgress);
// The lock is released once the operation finishes
await lib.installPackage("foo");
await assert.rejects(fs.access(lockPath));
});
it("runs only one of several operations started together", async function() {
const results = await Promise.allSettled([
lib.installPackage("foo"), lib.updatePackage("bar"), lib.installPackage("baz"),
]);
assert.deepEqual(results.map(result => result.status), ["fulfilled", "rejected", "rejected"]);
for (const result of results.slice(1)) {
assert.match(result.reason.message, inProgress);
}
});
it("holds the lock file while the operation runs", async function() {
let lockContent;
lib.logger.audit = function() {
// eslint-disable-next-line node/no-sync
lockContent = fsSync.readFileSync(lockPath, "utf8");
};
await lib.installPackage("foo");
assert.equal(lockContent, `${process.pid} ${performance.timeOrigin}\n`);
await assert.rejects(fs.access(lockPath));
});
it("releases the lock when the operation fails", async function() {
lib.logger.audit = function() {
throw new Error("operation failed");
};
await assert.rejects(lib.installPackage("foo"), /operation failed/);
lib.logger.audit = audit;
await assert.rejects(fs.access(lockPath));
await lib.installPackage("foo");
});
it("leaves a lock it does not own in place", async function() {
lib.logger.audit = function() {
// eslint-disable-next-line node/no-sync
fsSync.writeFileSync(lockPath, `${process.ppid}\n`);
};
await lib.installPackage("foo");
assert.equal(await fs.readFile(lockPath, "utf8"), `${process.ppid}\n`);
});
it("rejects while another process holds the lock", async function() {
// A standalone controller and host share the installation directory
await fs.writeFile(lockPath, `${process.ppid}\n`);
await assert.rejects(lib.installPackage("foo"), inProgress);
assert.equal(await fs.readFile(lockPath, "utf8"), `${process.ppid}\n`);
});
it("rejects while another copy of lib in this process holds the lock", async function() {
const rceOpsUrl = new URL("../../packages/lib/dist/node/src/rce_ops.js", import.meta.url);
const otherCopy = await import(`${rceOpsUrl.href}?copy`);
await lib.installPackage("foo", async () => {
await assert.rejects(otherCopy.installPackage("bar"), inProgress);
});
});
it("names the process holding the lock", async function() {
await fs.writeFile(lockPath, `${process.ppid} 1234.5\n`);
await assert.rejects(lib.installPackage("foo"), new RegExp(`held by process ${process.ppid}\\)`));
});
it("reports an interrupted recovery", async function() {
await fs.writeFile(lockPath, "999999999\n");
await fs.writeFile(recoveryPath, "999999999\n");
await assert.rejects(lib.installPackage("foo"), /remove .*npm-operation\.lock\.recovery/);
});
});
});
describe("handleUpdateAll", function() {
describe("packageOperationRunning()", function() {
const lockPath = "npm-operation.lock";
const audit = lib.logger.audit;
afterEach(async function() {
lib.logger.audit = audit;
await fs.rm(lockPath, { force: true });
});
it("reports whether an operation is running", async function() {
assert.equal(await lib.packageOperationRunning(), false);
let running;
lib.logger.audit = function() {
lib.logger.audit = audit;
running = lib.packageOperationRunning();
};
await lib.installPackage("foo");
assert.equal(await running, true);
assert.equal(await lib.packageOperationRunning(), false);
await fs.writeFile(lockPath, `${process.ppid}\n`);
assert.equal(await lib.packageOperationRunning(), true);
await fs.writeFile(lockPath, "999999999\n");
assert.equal(await lib.packageOperationRunning(), false);
});
});
describe("handleUpdateAll()", function() {
it("updates the core package and all npm plugins in one command", async function() {
const _audit = lib.logger.audit;
const messages = [];
@@ -43,7 +169,112 @@ describe("rce_ops", function() {
});
});
describe("handlePluginInstall", function() {
describe("handlePluginUpdate()", function() {
it("runs", async function() {
await lib.handlePluginUpdate("foo", [{ npmPackage: "foo" }]);
});
it("rejects when plugin not installed", async function() {
await assert.rejects(
lib.handlePluginUpdate("foo", []),
/Plugin foo is not installed on this machine/
);
await assert.rejects(
lib.handlePluginUpdate("foo@1.2.3", []),
/Plugin foo is not installed on this machine/
);
});
it("updates to a pinned version", async function() {
const _fetch = global.fetch;
let calledWith = "";
global.fetch = function(url) {
calledWith = url;
return { ok: true };
};
try {
await lib.handlePluginUpdate("@scope/foo@1.2.3", [{ npmPackage: "@scope/foo" }]);
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo/1.2.3");
global.fetch = function() { return { ok: false }; };
await assert.rejects(
lib.handlePluginUpdate("foo@9.9.9", [{ npmPackage: "foo" }]),
/Unknown plugin: foo@9.9.9/,
);
} finally {
global.fetch = _fetch;
}
});
describe("over a dependency not from the registry", function() {
const dir = useLocalDependencies();
it("rejects pinned updates of dependencies not from the registry", async function() {
for (const name of ["linked", "aliased", "from-git", "optional"]) {
const error = new RegExp(`${name} is not installed from npm on this machine`);
await assert.rejects(lib.handlePluginUpdate(`${name}@2.0.0`, [{ npmPackage: name }]), error);
}
await lib.handlePluginUpdate("published@2.0.0", [{ npmPackage: "published" }]);
});
it("rejects pinned updates of a plugin another plugin installs from git", async function() {
const modules = path.join(dir, "node_modules");
const plugin = (name, dependencies) => fs.mkdir(path.join(modules, name), { recursive: true })
.then(() => fs.writeFile(path.join(modules, name, "package.json"), JSON.stringify({
name, version: "1.0.0", keywords: ["clusterio-plugin"], dependencies,
})));
await plugin("published", { child: "github:user/child" });
await plugin("child", {});
try {
await assert.rejects(
lib.handlePluginUpdate("child@2.0.0", [{ npmPackage: "child" }]),
/child is not installed from npm on this machine/,
);
} finally {
await fs.rm(modules, { recursive: true, force: true });
}
});
it("rejects pinned updates of a plugin an optional dependency installs from git", async function() {
const modules = path.join(dir, "node_modules");
const plugin = (name, packageJson) => fs.mkdir(path.join(modules, name), { recursive: true })
.then(() => fs.writeFile(path.join(modules, name, "package.json"), JSON.stringify({
name, version: "1.0.0", keywords: ["clusterio-plugin"], ...packageJson,
})));
await plugin("published", {
dependencies: { child: "^1.0.0" },
optionalDependencies: { child: "github:user/child" },
});
await plugin("child", {});
try {
await assert.rejects(
lib.handlePluginUpdate("child@2.0.0", [{ npmPackage: "child" }]),
/child is not installed from npm on this machine/,
);
} finally {
await fs.rm(modules, { recursive: true, force: true });
}
});
it("checks the source again once it holds the lock", async function() {
const packageJsonPath = path.join(dir, "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
// Another operation switches it to git while the registry is being checked
global.fetch = async function() {
const packageJson = JSON.parse(original);
packageJson.dependencies.published = "github:user/published";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
return { ok: true };
};
try {
const error = /published is not installed from npm on this machine/;
const update = lib.handlePluginUpdate("published@2.0.0", [{ npmPackage: "published" }]);
await assert.rejects(update, error);
} finally {
global.fetch = function() { return { ok: true }; };
await fs.writeFile(packageJsonPath, original);
}
});
});
});
describe("handlePluginInstall()", function() {
const _fetch = global.fetch;
before(function() {
global.fetch = function() { return { ok: false }; };
@@ -62,6 +293,28 @@ describe("rce_ops", function() {
await lib.handlePluginInstall("foo");
assert.equal(calledWith, "https://registry.npmjs.com/foo");
});
it("installs a pinned version", async function() {
let calledWith = "";
global.fetch = function(url) {
calledWith = url;
return { ok: true };
};
assert.equal(await lib.handlePluginInstall("foo@1.2.3"), "foo");
assert.equal(calledWith, "https://registry.npmjs.com/foo/1.2.3");
assert.equal(await lib.handlePluginInstall("@scope/foo@1.0.0-alpha.1"), "@scope/foo");
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo/1.0.0-alpha.1");
await lib.handlePluginInstall("@scope/foo");
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo");
});
it("rejects unregistered versions", async function() {
global.fetch = function() { return { ok: false }; };
await assert.rejects(lib.handlePluginInstall("foo@9.9.9"), /Unknown plugin: foo@9.9.9/);
});
it("rejects invalid versions", async function() {
await assert.rejects(lib.handlePluginInstall("foo@"), /Invalid plugin version: /);
await assert.rejects(lib.handlePluginInstall("foo@-1"), /Invalid plugin version: -1/);
});
it("rejects when name too long", async function() {
const pluginName = "a".repeat(215);
await assert.rejects(
@@ -69,6 +322,20 @@ describe("rce_ops", function() {
{ message: `Invalid plugin name: ${pluginName}` }
);
});
it("rejects specs npm would read as a path, repository or option", async function() {
let fetched = false;
global.fetch = function() {
fetched = true;
return { ok: true };
};
for (const spec of [
".", "..", "./foo", "../foo", "foo/latest", "@clusterio/lib/latest", "-/ping", "-foo", ".foo",
"_foo", "@scope", "@scope/", "@/foo", "@scope/foo/bar", "foo/bar@1.0.0", "github:foo/bar",
]) {
await assert.rejects(lib.handlePluginInstall(spec), /Invalid plugin name/, spec);
}
assert.equal(fetched, false);
});
it("rejects when invalid symbol present", async function() {
const pluginName = "?";
await assert.rejects(
@@ -86,5 +353,35 @@ describe("rce_ops", function() {
await assert.rejects(lib.handlePluginInstall("foo"), /Unknown plugin: foo/);
assert.equal(calledWith, "https://registry.npmjs.com/foo");
});
describe("over a dependency not from the registry", function() {
const dir = useLocalDependencies();
it("rejects pinned installs of dependencies not from the registry", async function() {
for (const name of ["linked", "aliased", "from-git", "optional"]) {
const error = new RegExp(`${name} is not installed from npm on this machine`);
await assert.rejects(lib.handlePluginInstall(`${name}@2.0.0`), error);
}
await lib.handlePluginInstall("published@2.0.0");
});
it("checks the source again once it holds the lock", async function() {
const packageJsonPath = path.join(dir, "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
// Another operation switches it to git while the registry is being checked
global.fetch = async function() {
const packageJson = JSON.parse(original);
packageJson.dependencies.published = "github:user/published";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
return { ok: true };
};
try {
const error = /published is not installed from npm on this machine/;
await assert.rejects(lib.handlePluginInstall("published@2.0.0"), error);
} finally {
global.fetch = function() { return { ok: true }; };
await fs.writeFile(packageJsonPath, original);
}
});
});
});
});
+81 -20
View File
@@ -1,10 +1,13 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import * as lib from "@clusterio/lib";
import { Controller, ControlConnection } from "@clusterio/controller";
import { Host } from "@clusterio/host";
import "../setup_logging.js";
import { testRoundTripJsonSerialisable } from "../common.js";
describe("messages/plugin", function() {
/** @type {Host} */
@@ -34,14 +37,13 @@ describe("messages/plugin", function() {
describe("PluginDetails", function() {
it("has round trip json serialisation", function() {
const pluginDetails = new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Description", "Npm Package",
);
const json = JSON.stringify(pluginDetails);
const reconstructed = lib.PluginDetails.fromJSON(JSON.parse(json));
assert.deepEqual(reconstructed, pluginDetails);
testRoundTripJsonSerialisable(lib.PluginDetails, [
["Name", "Title", "Version", true, true],
[
"Name", "Title", "Version", false, false, "Description", "Npm Package",
"Installed Version", true, true, "Dependency Name", true, true,
],
]);
});
it("can be created from NodeEnvInfo interface", function() {
const pluginDetails = lib.PluginDetails.fromNodeEnvInfo({
@@ -53,6 +55,17 @@ describe("messages/plugin", function() {
"Description", "Npm Package",
));
});
it("can be created from the package.json of a package installed since startup", function() {
const pluginDetails = lib.PluginDetails.fromPackageJson(
"package", { version: "1.0.0", description: "Description" }, "package", "package",
);
assert.deepEqual(pluginDetails, new lib.PluginDetails(
"package", "package", "1.0.0", false, false,
"Description", "package", "1.0.0", true, undefined, "package",
));
const invalid = lib.PluginDetails.fromPackageJson("package", { version: "1.0.0", description: 1 });
assert.equal(invalid.description, undefined);
});
});
describe("PluginListRequest", function() {
@@ -66,6 +79,11 @@ describe("messages/plugin", function() {
initialValue: true,
};
});
const packagePath = path.resolve("temp", "test", "messages_plugin", "package.json");
before(async function() {
await fs.mkdir(path.dirname(packagePath), { recursive: true });
await fs.writeFile(packagePath, JSON.stringify({ name: "Npm Package", version: "Version" }));
});
after(function() {
delete lib.ControllerConfig.fieldDefinitions["Name.load_plugin"];
delete lib.HostConfig.fieldDefinitions["Name.load_plugin"];
@@ -73,7 +91,7 @@ describe("messages/plugin", function() {
it("returns correct info from the controller", async function() {
controller.pluginInfos = [{
name: "Name", title: "Title", version: "Version",
description: "Description", npmPackage: "Npm Package",
description: "Description", npmPackage: "Npm Package", packagePath,
}];
controller.loadedPlugins.add(controller.pluginInfos[0]);
@@ -82,13 +100,13 @@ describe("messages/plugin", function() {
const response = await controlConnection.handlePluginListRequest(new lib.PluginListRequest());
assert.deepEqual(response, [new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Description", "Npm Package",
"Description", "Npm Package", "Version",
)]);
});
it("returns correct info from a host", async function() {
host.pluginInfos = [{
name: "Name", title: "Title", version: "Version",
description: "Description", npmPackage: "Npm Package",
description: "Description", npmPackage: "Npm Package", packagePath, hostEntrypoint: "host.js",
}];
host.loadedPlugins.add(host.pluginInfos[0]);
@@ -97,17 +115,14 @@ describe("messages/plugin", function() {
const response = await host.handlePluginListRequest(new lib.PluginListRequest());
assert.deepEqual(response, [new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Description", "Npm Package",
"Description", "Npm Package", "Version",
)]);
});
});
describe("PluginUpdateRequest", function() {
it("has round trip json serialisation", function() {
const request = new lib.PluginUpdateRequest("foo");
const json = JSON.stringify(request);
const reconstructed = lib.PluginUpdateRequest.fromJSON(JSON.parse(json));
assert.deepEqual(reconstructed, request);
testRoundTripJsonSerialisable(lib.PluginUpdateRequest, [["foo"], ["@scope/foo@1.0.0"]]);
});
it("runs on the controller", async function() {
controller.pluginInfos = [{ npmPackage: "foo" }];
@@ -135,6 +150,33 @@ describe("messages/plugin", function() {
});
});
describe("PluginSearchRequest", function() {
const _fetch = global.fetch;
after(function() {
global.fetch = _fetch;
});
it("has round trip json serialisation", function() {
testRoundTripJsonSerialisable(lib.PluginSearchRequest, [[], ["foo", 2, 10]]);
});
it("has round trip json serialisation of the response", function() {
testRoundTripJsonSerialisable(lib.PluginSearchRequest.Response, [
[0, []],
[2, [
new lib.PluginSearchResult("foo", "1.0.0"),
new lib.PluginSearchResult("bar", "1.0.0", "Bar", "baz", "date", "home", "repo", "npm"),
]],
]);
});
it("runs on the controller", async function() {
global.fetch = function() {
return { ok: true, json: async () => ({ total: 0, objects: [] }) };
};
const response = await controlConnection.handlePluginSearchRequest(new lib.PluginSearchRequest());
assert.deepEqual(response, new lib.PluginSearchRequest.Response(0, []));
});
});
describe("UpdateAllRequest", function() {
function checkedPermissions(dst) {
const permissions = [];
@@ -201,10 +243,7 @@ describe("messages/plugin", function() {
});
it("has round trip json serialisation", function() {
const request = new lib.PluginInstallRequest("foo");
const json = JSON.stringify(request);
const reconstructed = lib.PluginInstallRequest.fromJSON(JSON.parse(json));
assert.deepEqual(reconstructed, request);
testRoundTripJsonSerialisable(lib.PluginInstallRequest, [["foo"], ["@scope/foo@1.0.0"]]);
});
it("runs on the controller", async function() {
controller.config.set("controller.allow_plugin_install", true);
@@ -229,4 +268,26 @@ describe("messages/plugin", function() {
);
});
});
describe("PluginLatestVersionsRequest", function() {
const _fetch = global.fetch;
before(function() {
global.fetch = async function() { return { ok: true, json: async () => ({ version: "4.5.6" }) }; };
});
after(function() {
global.fetch = _fetch;
});
it("has round trip json serialisation", function() {
testRoundTripJsonSerialisable(lib.PluginLatestVersionsRequest, [[[]], [["foo", "@scope/bar"]]]);
});
it("has round trip json serialisation of the response", function() {
testRoundTripJsonSerialisable(lib.PluginLatestVersionsRequest.Response, [[{}], [{ foo: "1.0.0" }]]);
});
it("runs on the controller", async function() {
const response = await controlConnection.handlePluginLatestVersionsRequest(
new lib.PluginLatestVersionsRequest(["latest-message-test"])
);
assert.deepEqual(response.versions, { "latest-message-test": "4.5.6" });
});
});
});
+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);
});
});
});