mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Merge pull request #1010 from bbassie/feature/plugin-browser
Plugin management through web interface
This commit is contained in:
1 parent
e36a76acc5
commit
ffe82f81d8
48 files changed
+4275
-536
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
*
|
||||
|
||||
@@ -147,5 +147,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
plugin.PluginListRequest,
|
||||
plugin.PluginUpdateRequest,
|
||||
plugin.PluginInstallRequest,
|
||||
plugin.PluginSearchRequest,
|
||||
plugin.PluginLatestVersionsRequest,
|
||||
plugin.UpdateAllRequest,
|
||||
];
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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>}
|
||||
</>}
|
||||
/>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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")]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { ExclusiveRunner } from "../../packages/web_ui/src/util/exclusive.ts";
|
||||
|
||||
describe("web_ui/util/exclusive", function() {
|
||||
describe("class ExclusiveRunner", function() {
|
||||
it("should refuse a restart while an install is running", async function() {
|
||||
const states = [];
|
||||
const runner = new ExclusiveRunner(state => states.push(state));
|
||||
let finishInstall;
|
||||
const install = runner.run("installing", () => new Promise(resolve => { finishInstall = resolve; }));
|
||||
let restarted = false;
|
||||
assert.equal(await runner.run("restarting", async () => { restarted = true; }), false);
|
||||
assert.equal(restarted, false);
|
||||
finishInstall();
|
||||
assert.equal(await install, true);
|
||||
assert.deepEqual(states, ["installing", undefined]);
|
||||
});
|
||||
it("should refuse an install while a restart is running", async function() {
|
||||
const runner = new ExclusiveRunner(() => {});
|
||||
let finishRestart;
|
||||
const restart = runner.run("restarting", () => new Promise(resolve => { finishRestart = resolve; }));
|
||||
assert.equal(await runner.run("installing", async () => {}), false);
|
||||
finishRestart();
|
||||
await restart;
|
||||
assert.equal(await runner.run("installing", async () => {}), true);
|
||||
});
|
||||
it("should accept a task after one that failed", async function() {
|
||||
const runner = new ExclusiveRunner(() => {});
|
||||
await assert.rejects(runner.run("installing", async () => { throw new Error("failed"); }), /failed/);
|
||||
assert.equal(runner.current, undefined);
|
||||
assert.equal(await runner.run("restarting", async () => {}), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import {
|
||||
computePluginStatuses, machineStatusLabels, planMachineUpdate, planUpdate,
|
||||
} from "../../packages/web_ui/src/util/plugin_status.ts";
|
||||
|
||||
describe("web_ui/util/plugin_status", function() {
|
||||
describe("planUpdate()", function() {
|
||||
const access = { canInstall: () => true, canUpdate: () => true };
|
||||
const account = { hasPermission: () => true };
|
||||
function machine(name, plugins) {
|
||||
return { name, isController: false, address: { hostId: name.length }, plugins };
|
||||
}
|
||||
function loaded(npmPackage, version) {
|
||||
return new lib.PluginDetails("plugin", "Plugin", version, true, true, undefined, npmPackage, version);
|
||||
}
|
||||
function pending(packageName, npmPackage, version) {
|
||||
return new lib.PluginDetails(
|
||||
packageName, packageName, version, false, false, undefined, npmPackage, version, true,
|
||||
);
|
||||
}
|
||||
function skippedText(result) {
|
||||
return result.skipped.map(skip => ({ kind: skip.kind, text: `${skip.target.name} (${skip.reason})` }));
|
||||
}
|
||||
function plan(machines) {
|
||||
const statuses = computePluginStatuses(machines);
|
||||
assert.equal(statuses.size, 1);
|
||||
const status = [...statuses.values()][0];
|
||||
return planUpdate(status, "2.0.0", access, account);
|
||||
}
|
||||
|
||||
it("should install on machines where the plugin is missing", function() {
|
||||
const result = plan([machine("a", [loaded("plugin-aliased", "1.0.0")]), machine("b", [])]);
|
||||
assert.deepEqual(result.operations.map(op => [op.kind, op.target.name, op.packageSpec]), [
|
||||
["update", "a", "plugin-aliased@2.0.0"],
|
||||
["install", "b", "plugin-aliased@2.0.0"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not install over a pending alias with another machine's package", function() {
|
||||
const result = plan([
|
||||
machine("a", [loaded("plugin-aliased", "2.0.0")]),
|
||||
machine("b", [pending("plugin-aliased", undefined, "1.0.0")]),
|
||||
]);
|
||||
assert.deepEqual(result.operations, []);
|
||||
assert.deepEqual(skippedText(result), [{ kind: "install", text: "b (not installed from npm)" }]);
|
||||
});
|
||||
|
||||
it("should not update a loaded package that isn't from npm", function() {
|
||||
const result = plan([
|
||||
machine("a", [loaded("plugin-aliased", "1.0.0")]),
|
||||
machine("b", [new lib.PluginDetails("plugin-aliased", "Plugin", "1.0.0", true, true)]),
|
||||
]);
|
||||
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
|
||||
assert.deepEqual(skippedText(result), [{ kind: "update", text: "b (not installed from npm)" }]);
|
||||
});
|
||||
|
||||
it("should not send a specific version to hosts that can't parse it", function() {
|
||||
// Hosts from before installedVersion was reported take name@version as a package name
|
||||
const old = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true, undefined, "plugin-aliased");
|
||||
const result = plan([
|
||||
machine("a", [loaded("plugin-aliased", "1.0.0")]),
|
||||
machine("old", [old]),
|
||||
]);
|
||||
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
|
||||
assert.deepEqual(skippedText(result), [
|
||||
{ kind: "update", text: "old (host too old to install a specific version)" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not install over a loaded alias that has the package name", function() {
|
||||
const alias = new lib.PluginDetails("real_plugin", "Real", "1.0.0", true, true);
|
||||
alias.dependencyName = "plugin-aliased";
|
||||
const statuses = computePluginStatuses([
|
||||
machine("a", [loaded("plugin-aliased", "1.0.0")]),
|
||||
machine("b", [alias]),
|
||||
]);
|
||||
const result = planUpdate(statuses.get("plugin-aliased"), "2.0.0", access, account);
|
||||
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
|
||||
assert.deepEqual(skippedText(result), [
|
||||
{ kind: "install", text: "b (another package is installed as plugin-aliased)" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should install over a pending npm package", function() {
|
||||
const result = plan([
|
||||
machine("a", [loaded("plugin-aliased", "2.0.0")]),
|
||||
machine("b", [pending("plugin-aliased", "plugin-aliased", "1.0.0")]),
|
||||
]);
|
||||
assert.deepEqual(result.operations.map(op => [op.kind, op.target.name]), [["install", "b"]]);
|
||||
});
|
||||
|
||||
it("should skip offline machines and machines that haven't reported their plugins", function() {
|
||||
const offline = { ...machine("off", [loaded("plugin-aliased", "1.0.0")]), offline: true };
|
||||
const statuses = computePluginStatuses(
|
||||
[machine("a", [loaded("plugin-aliased", "1.0.0")]), offline, { ...machine("gone", []), offline: true }],
|
||||
[{ name: "old", isController: false, address: { hostId: 9 } }],
|
||||
);
|
||||
const result = planUpdate(statuses.get("plugin-aliased"), "2.0.0", access, account);
|
||||
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
|
||||
assert.deepEqual(skippedText(result), [
|
||||
{ kind: "update", text: "off (offline)" },
|
||||
{ kind: "install", text: "gone (offline)" },
|
||||
{ kind: "update", text: "old (plugins not reported)" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planMachineUpdate()", function() {
|
||||
it("should plan for the given machine only", function() {
|
||||
const loaded = new lib.PluginDetails(
|
||||
"plugin", "Plugin", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0",
|
||||
);
|
||||
const statuses = computePluginStatuses([
|
||||
{ name: "a", isController: false, address: { hostId: 1 }, plugins: [loaded] },
|
||||
{ name: "b", isController: false, address: { hostId: 2 }, plugins: [] },
|
||||
], [{ name: "old", isController: false, address: { hostId: 9 } }]);
|
||||
const status = statuses.get("plugin-aliased");
|
||||
const plan = planMachineUpdate(
|
||||
status, status.machines[1], "2.0.0",
|
||||
{ canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true },
|
||||
);
|
||||
assert.deepEqual(plan.operations.map(op => [op.kind, op.target.name]), [["install", "b"]]);
|
||||
assert.deepEqual(plan.skipped, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("machineStatusLabels()", function() {
|
||||
function labels(plugins, extra = {}) {
|
||||
const [status] = computePluginStatuses([
|
||||
{ name: "a", isController: false, address: { hostId: 1 }, plugins: [loadedPlugin()] },
|
||||
{ name: "b", isController: false, address: { hostId: 2 }, plugins, ...extra },
|
||||
]).values();
|
||||
return machineStatusLabels(status.machines[1]);
|
||||
}
|
||||
function loadedPlugin(version = "1.0.0", installedVersion = version) {
|
||||
return new lib.PluginDetails(
|
||||
"plugin", "Plugin", version, true, true, undefined, "plugin", installedVersion,
|
||||
);
|
||||
}
|
||||
|
||||
it("should describe the state of a plugin on a machine", function() {
|
||||
assert.deepEqual(labels([loadedPlugin()]), []);
|
||||
assert.deepEqual(labels([loadedPlugin("1.0.0", "2.0.0")]), ["Restart pending"]);
|
||||
assert.deepEqual(labels([]), ["Not installed"]);
|
||||
assert.deepEqual(labels([loadedPlugin()], { offline: true }), ["Offline"]);
|
||||
const disabled = new lib.PluginDetails(
|
||||
"plugin", "Plugin", "1.0.0", false, false, undefined, "plugin", "1.0.0",
|
||||
);
|
||||
assert.deepEqual(labels([disabled]), ["Disabled"]);
|
||||
const local = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true);
|
||||
assert.deepEqual(labels([local]), ["Not installed from npm"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePluginStatuses()", function() {
|
||||
it("should not expect a plugin without an entrypoint for the machine to run after restart", function() {
|
||||
const plugin = new lib.PluginDetails("controller_only", "Plugin", "1.0.0", false, false);
|
||||
plugin.noEntrypoint = true;
|
||||
const [status] = computePluginStatuses([
|
||||
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
|
||||
]).values();
|
||||
const [entry] = status.machines;
|
||||
assert.equal(entry.noEntrypoint, true);
|
||||
assert.equal(entry.disabled, false);
|
||||
assert.equal(entry.running, undefined);
|
||||
assert.equal(entry.afterRestart, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { computePluginStatuses, planUpdate } from "../../packages/web_ui/src/util/plugin_status.ts";
|
||||
|
||||
// Every state a machine can report for the plugin "plugin" published as "pkg", checked against
|
||||
// what the next startup and npm install would do, independent of the other machines.
|
||||
const target = "2.0.0";
|
||||
const versions = ["1.0.0", "2.0.0", "3.0.0"];
|
||||
|
||||
function details(fields) {
|
||||
const plugin = new lib.PluginDetails(
|
||||
fields.name, fields.name, fields.version, fields.loaded, fields.enabled, undefined, fields.npmPackage,
|
||||
fields.installedVersion, fields.pendingInstall, fields.removed, fields.dependencyName, fields.noEntrypoint,
|
||||
fields.nameTaken,
|
||||
);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
function* states() {
|
||||
yield { label: "absent", plugins: [] };
|
||||
for (const version of versions) {
|
||||
for (const installed of [version, ...versions.filter(other => other !== version)]) {
|
||||
for (const enabled of [true, false]) {
|
||||
for (const loaded of enabled ? [true] : [true, false]) {
|
||||
const state = `${enabled ? "enabled" : "disabled"}${loaded ? " loaded" : ""}`;
|
||||
yield {
|
||||
label: `npm ${version} on disk ${installed} ${state}`,
|
||||
entry: { source: "npm", version, installed, enabled, loaded },
|
||||
plugins: [details({
|
||||
name: "plugin", version, loaded, enabled, npmPackage: "pkg",
|
||||
installedVersion: installed, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
yield {
|
||||
label: `npm ${version} removed`,
|
||||
entry: { source: "npm", version, removed: true },
|
||||
plugins: [details({
|
||||
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
|
||||
removed: true, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
label: `npm ${version} no entrypoint`,
|
||||
entry: { source: "npm", version, installed: version, noEntrypoint: true },
|
||||
plugins: [details({
|
||||
name: "plugin", version, loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: version, dependencyName: "pkg", noEntrypoint: true,
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
label: `npm ${version} pending`,
|
||||
entry: { source: "npm", version, installed: version, pending: true },
|
||||
plugins: [details({
|
||||
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: version, pendingInstall: true, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
label: `alias ${version} pending`,
|
||||
entry: { source: "alias", version, installed: version, pending: true },
|
||||
plugins: [details({
|
||||
name: "pkg", version, loaded: false, enabled: false,
|
||||
installedVersion: version, pendingInstall: true, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
label: `npm ${version} removed and reinstalled at ${target}`,
|
||||
entry: { source: "npm", version, installed: target, replaced: true },
|
||||
plugins: [
|
||||
details({
|
||||
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
|
||||
removed: true, dependencyName: "pkg",
|
||||
}),
|
||||
details({
|
||||
name: "pkg", version: target, loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: target, pendingInstall: true, dependencyName: "pkg",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
yield {
|
||||
label: "alias loaded as pkg",
|
||||
taken: "dependency",
|
||||
plugins: [details({
|
||||
name: "real_plugin", version: "1.0.0", loaded: true, enabled: true,
|
||||
installedVersion: "1.0.0", dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
for (const version of versions) {
|
||||
yield {
|
||||
label: `npm ${version} skipped as its name is taken`,
|
||||
taken: "name",
|
||||
plugins: [
|
||||
details({ name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0" }),
|
||||
details({
|
||||
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: version, dependencyName: "pkg", nameTaken: true,
|
||||
}),
|
||||
],
|
||||
entry: { source: "npm", version, installed: version },
|
||||
};
|
||||
}
|
||||
yield {
|
||||
label: "local plugin with the same name",
|
||||
taken: "name",
|
||||
plugins: [details({
|
||||
name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0",
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
label: "local plugin with the same name and pkg pending",
|
||||
taken: "name",
|
||||
plugins: [
|
||||
details({ name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0" }),
|
||||
details({
|
||||
name: "pkg", version: "1.0.0", loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: "1.0.0", pendingInstall: true, dependencyName: "pkg",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function expectedOperation(state) {
|
||||
const { entry, taken } = state;
|
||||
const present = entry && !entry.removed;
|
||||
if (present && semverGte(entry.installed, target)) {
|
||||
return "none";
|
||||
}
|
||||
if (taken || present && entry.source !== "npm") {
|
||||
// Startup would skip it, or npm install pkg would replace the package at that dependency name
|
||||
return "skip";
|
||||
}
|
||||
return !present || entry.pending || entry.replaced ? "install" : "update";
|
||||
}
|
||||
|
||||
function expected(state) {
|
||||
const { entry, taken } = state;
|
||||
const op = expectedOperation(state);
|
||||
if (taken === "name") {
|
||||
return { running: undefined, afterRestart: undefined, op };
|
||||
}
|
||||
let running;
|
||||
let afterRestart;
|
||||
if (entry?.loaded || entry?.removed || entry?.replaced) {
|
||||
running = entry.version;
|
||||
}
|
||||
if (entry && !entry.removed && !entry.noEntrypoint && (entry.pending || entry.replaced || entry.enabled)) {
|
||||
afterRestart = entry.installed;
|
||||
}
|
||||
return { running, afterRestart, op };
|
||||
}
|
||||
|
||||
function semverGte(a, b) {
|
||||
return versions.indexOf(a) >= versions.indexOf(b);
|
||||
}
|
||||
|
||||
const reference = { name: "ref", isController: true, address: "controller", plugins: [details({
|
||||
name: "plugin", version: "1.0.0", loaded: true, enabled: true, npmPackage: "pkg",
|
||||
installedVersion: "1.0.0", dependencyName: "pkg",
|
||||
})] };
|
||||
|
||||
function actual(machines, name) {
|
||||
const status = computePluginStatuses(machines).get("pkg");
|
||||
const entry = status.machines.find(machine => machine.name === name);
|
||||
const plan = planUpdate(
|
||||
status, target, { canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true },
|
||||
);
|
||||
let op = "none";
|
||||
const operation = plan.operations.find(candidate => candidate.target.name === name);
|
||||
if (operation) {
|
||||
op = operation.kind;
|
||||
} else if (plan.skipped.some(skip => skip.target.name === name)) {
|
||||
op = "skip";
|
||||
}
|
||||
return { running: entry.running, afterRestart: entry.afterRestart, op };
|
||||
}
|
||||
|
||||
describe("web_ui/util/plugin_status matrix", function() {
|
||||
const all = [...states()];
|
||||
for (const state of all) {
|
||||
it(`should predict ${state.label}`, function() {
|
||||
const machine = { name: "m", isController: false, address: { hostId: 1 }, plugins: state.plugins };
|
||||
const result = actual([reference, machine], "m");
|
||||
assert.deepEqual(result, expected(state));
|
||||
// Independent of what other machines report
|
||||
for (const other of all) {
|
||||
const otherMachine = { name: "o", isController: false, address: { hostId: 2 }, plugins: other.plugins };
|
||||
assert.deepEqual(actual([reference, otherMachine, machine], "m"), result, other.label);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import { poll } from "../../packages/web_ui/src/util/poll.ts";
|
||||
|
||||
describe("web_ui/util/poll", function() {
|
||||
describe("poll()", function() {
|
||||
it("should keep every result of a task slower than the interval", async function() {
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
const applied = [];
|
||||
let count = 0;
|
||||
const stop = poll(async () => {
|
||||
count += 1;
|
||||
const run = count;
|
||||
running += 1;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
await sleep(20);
|
||||
running -= 1;
|
||||
applied.push(run);
|
||||
}, 1);
|
||||
await sleep(150);
|
||||
stop();
|
||||
await sleep(40);
|
||||
assert.equal(maxRunning, 1);
|
||||
assert.ok(applied.length >= 3, `only ${applied.length} runs finished`);
|
||||
assert.deepEqual(applied, applied.slice().sort((a, b) => a - b));
|
||||
assert.equal(applied.length, count);
|
||||
});
|
||||
|
||||
it("should not run again once stopped", async function() {
|
||||
let count = 0;
|
||||
const stop = poll(async () => { count += 1; }, 1);
|
||||
stop();
|
||||
await sleep(20);
|
||||
assert.equal(count, 1);
|
||||
});
|
||||
|
||||
it("should not run again when stopped while a run is in progress", async function() {
|
||||
let count = 0;
|
||||
const stop = poll(async () => { count += 1; await sleep(10); }, 1);
|
||||
await sleep(2);
|
||||
stop();
|
||||
await sleep(30);
|
||||
assert.equal(count, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user