From 4efd09de31a47a74ae22e00dde4ec03a22b78590 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 1 Mar 2021 14:12:55 +0100 Subject: [PATCH 1/7] Expose computed tls_ca config Provide the loaded file of the tls_ca config available to slave, instance and ctl code. This ensures they can easily use it when connecting to the master server. --- packages/ctl/ctl.js | 9 +++++++-- packages/slave/slave.js | 12 +++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 7ca51d56..449ac26f 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -698,10 +698,15 @@ class ControlConnector extends libLink.WebSocketClientConnector { */ class Control extends libLink.Link { - constructor(connector, controlPlugins) { + constructor(connector, tlsCa, controlPlugins) { super("control", "master", connector); libLink.attachAllMessages(this); + /** + * Certificate authority used to validate TLS connections to the master. + * @type {?string} + */ + this.tlsCa = tlsCa; /** * Mapping of plugin names to their instance for loaded plugins. * @type {Map} @@ -917,7 +922,7 @@ async function startControl() { tlsCa, controlConfig.get("control.master_token") ); - let control = new Control(controlConnector, controlPlugins); + let control = new Control(controlConnector, tlsCa, controlPlugins); try { await controlConnector.connect(); } catch (err) { diff --git a/packages/slave/slave.js b/packages/slave/slave.js index 5461a2c3..5076d5db 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -736,7 +736,7 @@ class Instance extends libLink.Link { let response = await phin({ url, method: "PUT", data: content, - core: { rejectUnauthorized: false }, + core: { ca: this._slave.tlsCa }, headers: { "Content-Type": "application/zip", "x-access-token": this._slave.config.get("slave.master_token"), @@ -976,7 +976,7 @@ function checkFilename(name) { class Slave extends libLink.Link { // I don't like God classes, but the alternative of putting all this state // into global variables is not much better. - constructor(connector, slaveConfig, pluginInfos) { + constructor(connector, slaveConfig, tlsCa, pluginInfos) { super("slave", "master", connector); libLink.attachAllMessages(this); @@ -987,6 +987,12 @@ class Slave extends libLink.Link { this.config = slaveConfig; + /** + * Certificate authority used to validate TLS connections to the master. + * @type {?string} + */ + this.tlsCa = tlsCa; + this.instanceConnections = new Map(); this.discoveredInstanceInfos = new Map(); this.instanceInfos = new Map(); @@ -1618,7 +1624,7 @@ async function startSlave() { } let slaveConnector = new SlaveConnector(slaveConfig, tlsCa, pluginInfos); - let slave = new Slave(slaveConnector, slaveConfig, pluginInfos); + let slave = new Slave(slaveConnector, slaveConfig, tlsCa, pluginInfos); // Handle interrupts let secondSigint = false; From a3729504e5bfd61dd80a8db8b25db5a6cb47d5d9 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 1 Mar 2021 14:16:30 +0100 Subject: [PATCH 2/7] Log error stack on bootstrap failure Log the stack of the error caught during failure to load the web interface. This ensure the error is printed to the console instead of "[fatal] undefined". --- packages/web_ui/src/bootstrap.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx index f9fafb14..611e931f 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -72,4 +72,4 @@ async function load() { ReactDOM.render(, document.getElementById("root")); } -load().catch((err) => logger.fatal(err)); +load().catch((err) => logger.fatal(err.stack)); From d0befe59442945b97d74649cdeec9404232727c5 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 1 Mar 2021 14:18:04 +0100 Subject: [PATCH 3/7] Add error boundnary for sidebar paths too b538960 added an error boundnary for pages not directly linked on the sidebar, but forgot to add it for pages on the sidebar. This add the missing error boundnary for those pages. --- packages/web_ui/src/components/SiteLayout.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index f2ef9358..dc09a0a7 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -75,7 +75,9 @@ export default function SiteLayout(props) { {sidebar.map(({path, content}) => - {content} + + {content} + )} {pages.map(page => From 3a3a7597bfc0652c52230cf0806e25bc1d4a5115 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 1 Mar 2021 14:22:21 +0100 Subject: [PATCH 4/7] Add PluginsContext to web_ui Add a React context to access the plugins loaded in the web interface. The info, /api/plugins metadata and webpack container is provided for each plugin. --- packages/web_ui/src/bootstrap.jsx | 32 +++++++++++-------- packages/web_ui/src/components/App.jsx | 9 ++++-- .../web_ui/src/components/PluginsContext.jsx | 3 ++ 3 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 packages/web_ui/src/components/PluginsContext.jsx diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx index 611e931f..4b245cd7 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -25,7 +25,7 @@ async function loadScript(url) { return result; } -async function loadPluginInfos() { +async function loadPlugins() { let response = await fetch(`${webRoot}api/plugins`); let pluginList; if (response.ok) { @@ -36,22 +36,28 @@ async function loadPluginInfos() { pluginList = []; } - let pluginInfos = []; + let plugins = []; await __webpack_init_sharing__("default"); - for (let plugin of pluginList) { + for (let meta of pluginList) { try { - await loadScript(`${webRoot}plugin/${plugin.name}/remoteEntry.js`); - let container = window[`plugin_${plugin.name}`]; + await loadScript(`${webRoot}plugin/${meta.name}/remoteEntry.js`); + let container = window[`plugin_${meta.name}`]; await container.init(__webpack_share_scopes__.default); - let info = (await container.get("./info"))(); - info.container = container; - pluginInfos.push(info); + let pluginInfo = (await container.get("./info"))(); + plugins.push({ + meta, + info: pluginInfo, + container, + }); } catch (err) { - logger.error(`Failed to load plugin info for ${plugin.name}`); + logger.error(`Failed to load plugin info for ${meta.name}`); + plugins.push({ + meta, + }); } } - return pluginInfos; + return plugins; } async function load() { @@ -59,8 +65,8 @@ async function load() { level: "verbose", format: new WebConsoleFormat(), })); - let pluginInfos = await loadPluginInfos(); - libConfig.registerPluginConfigGroups(pluginInfos); + let plugins = await loadPlugins(); + libConfig.registerPluginConfigGroups(plugins.filter(p => p.info).map(p => p.info)); libConfig.finalizeConfigs(); let wsUrl = new URL(window.webRoot, document.location); @@ -69,7 +75,7 @@ async function load() { let controlConnector = new ControlConnector(wsUrl, 10); let control = new Control(controlConnector, []); - ReactDOM.render(, document.getElementById("root")); + ReactDOM.render(, document.getElementById("root")); } load().catch((err) => logger.fatal(err.stack)); diff --git a/packages/web_ui/src/components/App.jsx b/packages/web_ui/src/components/App.jsx index 54dc63f0..2c90130d 100644 --- a/packages/web_ui/src/components/App.jsx +++ b/packages/web_ui/src/components/App.jsx @@ -7,6 +7,7 @@ import { logger } from "@clusterio/lib/logging"; import basename from "../basename"; import SiteLayout from "./SiteLayout"; import ControlContext from "./ControlContext"; +import PluginsContext from "./PluginsContext"; import LoginForm from "./LoginForm"; import { Card, Spin } from "antd"; @@ -77,9 +78,11 @@ export default function App(props) { return ( - - {page} - + + + {page} + + ); } diff --git a/packages/web_ui/src/components/PluginsContext.jsx b/packages/web_ui/src/components/PluginsContext.jsx new file mode 100644 index 00000000..979faf93 --- /dev/null +++ b/packages/web_ui/src/components/PluginsContext.jsx @@ -0,0 +1,3 @@ +import React from "react"; + +export default React.createContext(); From 6c9d3d8893571c66360f8b78661e79dc3fcb31f4 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 1 Mar 2021 14:25:57 +0100 Subject: [PATCH 5/7] Add Plugins page to web interface Add page listing the plugins installed on the master along with a detailed page for each plugin with fields from their package.json. Failure to load the module and version missmatch is also reported. This adds ./packages.json as required part of the webpack module. --- CHANGELOG.md | 1 + packages/master/master.js | 2 +- packages/master/src/routes.js | 3 +- packages/web_ui/src/bootstrap.jsx | 2 + .../web_ui/src/components/PluginViewPage.jsx | 84 +++++++++++++++++++ .../web_ui/src/components/PluginsPage.jsx | 52 ++++++++++++ packages/web_ui/src/components/SiteLayout.jsx | 4 + plugins/global_chat/webpack.config.js | 1 + plugins/research_sync/webpack.config.js | 1 + plugins/statistics_exporter/webpack.config.js | 1 + plugins/subspace_storage/webpack.config.js | 1 + 11 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 packages/web_ui/src/components/PluginViewPage.jsx create mode 100644 packages/web_ui/src/components/PluginsPage.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index af6f237e..e05e4b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Version 2.0.0 them to be displayed by name in queries that join with the mapping. - Added option to configure timeout for exported Prometheus metrics. - Added option to set address to bind HTTP(S) port to. +- Added an overview of the installed plugins to the web interface. ### Changes diff --git a/packages/master/master.js b/packages/master/master.js index 2474c0d6..bb5a7da9 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -279,7 +279,7 @@ app.get("/api/plugins", (req, res) => { for (let pluginInfo of pluginInfos) { let name = pluginInfo.name; let enabled = masterPlugins.has(name) && masterConfig.group(name).get("enabled"); - plugins.push({ name, enabled }); + plugins.push({ name, version: pluginInfo.version, enabled }); } res.send(plugins); }); diff --git a/packages/master/src/routes.js b/packages/master/src/routes.js index bcfde4ee..c72ab970 100644 --- a/packages/master/src/routes.js +++ b/packages/master/src/routes.js @@ -11,5 +11,6 @@ module.exports = [ "/users/:id/view", "/roles", "/roles/:id/view", - "/settings", + "/plugins", + "/plugins/:name/view", ]; diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx index 4b245cd7..627f446b 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -44,9 +44,11 @@ async function loadPlugins() { let container = window[`plugin_${meta.name}`]; await container.init(__webpack_share_scopes__.default); let pluginInfo = (await container.get("./info"))(); + let pluginPackage = (await container.get("./package.json"))(); plugins.push({ meta, info: pluginInfo, + package: pluginPackage, container, }); diff --git a/packages/web_ui/src/components/PluginViewPage.jsx b/packages/web_ui/src/components/PluginViewPage.jsx new file mode 100644 index 00000000..1445f232 --- /dev/null +++ b/packages/web_ui/src/components/PluginViewPage.jsx @@ -0,0 +1,84 @@ +import React, { useEffect, useContext, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; +import { Alert, Descriptions } from "antd"; + +import PluginsContext from "./PluginsContext"; +import PageLayout from "./PageLayout"; +import { notifyErrorHandler } from "../util/notify"; + + +export default function PluginViewPage() { + let params = useParams(); + let plugins = useContext(PluginsContext); + let pluginName = params.name; + + let plugin = plugins.find(plugin => plugin.meta.name === pluginName); + let pluginTitle = plugin.info ? plugin.info.title : plugin.meta.name; + let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }]; + if (!plugin) { + return +

Plugin not found

+

Plugin with name {pluginName} was not found on the master server.

+
; + } + + if (!plugin.info) { + return + + {plugin.meta.version} + {plugin.meta.enabled ? "Yes" : "No"} + + + ; + } + + return + + {plugin.meta.version} + {plugin.meta.enabled ? "Yes" : "No"} + {plugin.info.description} + {plugin.package.homepage ? + {plugin.package.homepage} + : null} + {plugin.package.author ? + {plugin.package.author} + : null} + {plugin.package.license} + + {plugin.meta.version !== plugin.package.version ? : null} + ; +} diff --git a/packages/web_ui/src/components/PluginsPage.jsx b/packages/web_ui/src/components/PluginsPage.jsx new file mode 100644 index 00000000..042b53f6 --- /dev/null +++ b/packages/web_ui/src/components/PluginsPage.jsx @@ -0,0 +1,52 @@ +import React, { useContext } from "react"; +import { useHistory } from "react-router-dom"; +import { Table } from "antd"; + +import PluginsContext from "./PluginsContext"; +import PageLayout from "./PageLayout"; + + +export default function PluginsPage() { + let plugins = useContext(PluginsContext); + let history = useHistory(); + + return +

Plugins

+ plugin.info ? plugin.info.title : plugin.meta.name, + }, + { + title: "Version", + key: "version", + render: plugin => { + if (!plugin.package) { + return "Error loading module"; + } + if (plugin.package.version !== plugin.meta.version) { + return "Version missmatched"; + } + return plugin.package.version; + } + }, + { + title: "Enabled", + dataIndex: ["meta", "enabled"], + render: enabled => enabled ? "Yes" : null, + responsive: ["sm"], + }, + ]} + dataSource={plugins} + rowKey={plugin => plugin.meta.name} + pagination={false} + onRow={plugin => ({ + onClick: event => { + history.push(`/plugins/${plugin.meta.name}/view`); + } + })} + /> + ; +} diff --git a/packages/web_ui/src/components/SiteLayout.jsx b/packages/web_ui/src/components/SiteLayout.jsx index dc09a0a7..6553837b 100644 --- a/packages/web_ui/src/components/SiteLayout.jsx +++ b/packages/web_ui/src/components/SiteLayout.jsx @@ -17,11 +17,14 @@ import UsersPage from "./UsersPage"; import UserViewPage from "./UserViewPage"; import RolesPage from "./RolesPage"; import RoleViewPage from "./RoleViewPage"; +import PluginsPage from "./PluginsPage"; +import PluginViewPage from "./PluginViewPage"; const pages = [ { path: "/instances/:id/view", sidebarPath: "/instances", content: }, { path: "/users/:name/view", sidebarPath: "/users", content: }, { path: "/roles/:id/view", sidebarPath: "/roles", content: }, + { path: "/plugins/:name/view", sidebarPath: "/plugins", content: }, ]; const sidebar = [ @@ -30,6 +33,7 @@ const sidebar = [ { name: "Instances", path: "/instances", content: }, { name: "Users", path: "/users", content: }, { name: "Roles", path: "/roles", content: }, + { name: "Plugins", path: "/plugins", content: }, ]; diff --git a/plugins/global_chat/webpack.config.js b/plugins/global_chat/webpack.config.js index 60b98d7b..6f19d7f7 100644 --- a/plugins/global_chat/webpack.config.js +++ b/plugins/global_chat/webpack.config.js @@ -19,6 +19,7 @@ module.exports = (env = {}) => merge(common(env), { filename: "remoteEntry.js", exposes: { "./info": "./info.js", + "./package.json": "./package.json", }, shared: { "@clusterio/lib": { import: false }, diff --git a/plugins/research_sync/webpack.config.js b/plugins/research_sync/webpack.config.js index 484dfafe..80a7863e 100644 --- a/plugins/research_sync/webpack.config.js +++ b/plugins/research_sync/webpack.config.js @@ -19,6 +19,7 @@ module.exports = (env = {}) => merge(common(env), { filename: "remoteEntry.js", exposes: { "./info": "./info.js", + "./package.json": "./package.json", }, shared: { "@clusterio/lib": { import: false }, diff --git a/plugins/statistics_exporter/webpack.config.js b/plugins/statistics_exporter/webpack.config.js index 0da4c121..9a8259e7 100644 --- a/plugins/statistics_exporter/webpack.config.js +++ b/plugins/statistics_exporter/webpack.config.js @@ -19,6 +19,7 @@ module.exports = (env = {}) => merge(common(env), { filename: "remoteEntry.js", exposes: { "./info": "./info.js", + "./package.json": "./package.json", }, shared: { "@clusterio/lib": { import: false }, diff --git a/plugins/subspace_storage/webpack.config.js b/plugins/subspace_storage/webpack.config.js index cfd034a8..c53e2ca3 100644 --- a/plugins/subspace_storage/webpack.config.js +++ b/plugins/subspace_storage/webpack.config.js @@ -19,6 +19,7 @@ module.exports = (env = {}) => merge(common(env), { filename: "remoteEntry.js", exposes: { "./info": "./info.js", + "./package.json": "./package.json", }, shared: { "@clusterio/lib": { import: false }, From 7797ee708ac91207ae8c362f92540dd711a2682b Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 1 Mar 2021 15:02:28 +0100 Subject: [PATCH 6/7] Add ctl master plugin list command Add command to list the plugins enabled on the master server and their versions to clusterioctl. --- CHANGELOG.md | 1 + packages/ctl/ctl.js | 29 +++++++++++++++++++++++++++-- packages/ctl/package.json | 1 + test/integration/clusterio.js | 6 ++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e05e4b71..c5827ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Version 2.0.0 - Added option to configure timeout for exported Prometheus metrics. - Added option to set address to bind HTTP(S) port to. - Added an overview of the installed plugins to the web interface. +- Added clusterioctl command to list plugins on the master server. ### Changes diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 449ac26f..693d29df 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -13,6 +13,7 @@ const events = require("events"); const path = require("path"); const winston = require("winston"); const setBlocking = require("set-blocking"); +const phin = require("phin"); const libLink = require("@clusterio/lib/link"); const libErrors = require("@clusterio/lib/errors"); @@ -104,6 +105,25 @@ masterConfigCommands.add(new libCommand.Command({ masterCommands.add(masterConfigCommands); +const masterPluginCommands = new libCommand.CommandTree({ + name: "plugin", alias: ["p"], description: "master plugin inspection", +}); +masterPluginCommands.add(new libCommand.Command({ + definition: ["list", "List plugins on master"], + handler: async function(args, control) { + let url = new URL(control.config.get("control.master_url")); + url.pathname += "api/plugins"; + let response = await phin({ + url, + parse: "json", + core: { ca: control.tlsCa }, + }); + print(asTable(response.body)); + }, +})); +masterCommands.add(masterPluginCommands); + + const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" }); slaveCommands.add(new libCommand.Command({ definition: [["list", "l"], "List slaves connected to the master"], @@ -698,10 +718,15 @@ class ControlConnector extends libLink.WebSocketClientConnector { */ class Control extends libLink.Link { - constructor(connector, tlsCa, controlPlugins) { + constructor(connector, controlConfig, tlsCa, controlPlugins) { super("control", "master", connector); libLink.attachAllMessages(this); + /** + * Control config used for connecting to the master. + * @type {module:lib/config.ControlConfig} + */ + this.config = controlConfig; /** * Certificate authority used to validate TLS connections to the master. * @type {?string} @@ -922,7 +947,7 @@ async function startControl() { tlsCa, controlConfig.get("control.master_token") ); - let control = new Control(controlConnector, tlsCa, controlPlugins); + let control = new Control(controlConnector, controlConfig, tlsCa, controlPlugins); try { await controlConnector.connect(); } catch (err) { diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 71f3f455..2664c621 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -22,6 +22,7 @@ "as-table": "^1.0.31", "chalk": "^3.0.0", "fs-extra": "^8.1.0", + "phin": "^3.5.1", "winston": "^3.3.3", "yargs": "^14.2.0" }, diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 8d3be325..47050662 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -71,6 +71,12 @@ describe("Integration of Clusterio", function() { }); }); + describe("master plugin list", function() { + it("runs", async function() { + await execCtl("master plugin list"); + }); + }); + describe("slave list", function() { it("runs", async function() { await execCtl("slave list"); From bdd70e53dcd74a8ef07406d0755e50eb161dc450 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 3 Mar 2021 14:41:17 +0100 Subject: [PATCH 7/7] Fix errors detected by linter I meant to fix these changes before pushing the PR, but forgot about it. --- packages/web_ui/src/components/PluginViewPage.jsx | 2 +- packages/web_ui/src/components/PluginsPage.jsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web_ui/src/components/PluginViewPage.jsx b/packages/web_ui/src/components/PluginViewPage.jsx index 1445f232..98313720 100644 --- a/packages/web_ui/src/components/PluginViewPage.jsx +++ b/packages/web_ui/src/components/PluginViewPage.jsx @@ -12,7 +12,7 @@ export default function PluginViewPage() { let plugins = useContext(PluginsContext); let pluginName = params.name; - let plugin = plugins.find(plugin => plugin.meta.name === pluginName); + let plugin = plugins.find(p => p.meta.name === pluginName); let pluginTitle = plugin.info ? plugin.info.title : plugin.meta.name; let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }]; if (!plugin) { diff --git a/packages/web_ui/src/components/PluginsPage.jsx b/packages/web_ui/src/components/PluginsPage.jsx index 042b53f6..7b9b0065 100644 --- a/packages/web_ui/src/components/PluginsPage.jsx +++ b/packages/web_ui/src/components/PluginsPage.jsx @@ -17,7 +17,7 @@ export default function PluginsPage() { { title: "Name", key: "name", - render: plugin => plugin.info ? plugin.info.title : plugin.meta.name, + render: plugin => (plugin.info ? plugin.info.title : plugin.meta.name), }, { title: "Version", @@ -30,12 +30,12 @@ export default function PluginsPage() { return "Version missmatched"; } return plugin.package.version; - } + }, }, { title: "Enabled", dataIndex: ["meta", "enabled"], - render: enabled => enabled ? "Yes" : null, + render: enabled => (enabled ? "Yes" : null), responsive: ["sm"], }, ]} @@ -45,7 +45,7 @@ export default function PluginsPage() { onRow={plugin => ({ onClick: event => { history.push(`/plugins/${plugin.meta.name}/view`); - } + }, })} /> ;