diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 476d1314..aa3e8587 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -247,6 +247,13 @@ This command lists all plugins currently known to the remote. Newly installed pl If remote plugin updates are enabled on the remote, this will execute `npm update --save `. 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 + + ctl> controller update --all [--restart] + ctl> host update --all [--restart] + +Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote. + ### Install ctl> controller plugin install [--restart] diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index cf110b7a..4ef0047b 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -128,6 +128,7 @@ 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.UpdateAllRequest, this.handleUpdateAllRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); } @@ -1307,6 +1308,16 @@ export default class ControlConnection extends BaseConnection { return await lib.handlePluginInstall(request.pluginPackage); } + async handleUpdateAllRequest(request: lib.UpdateAllRequest) { + if (!this._controller.config.get("controller.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + if (!this._controller.config.get("controller.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos); + } + async handlePluginListRequest(request: lib.PluginListRequest) { return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, diff --git a/packages/ctl/src/commands_controller.ts b/packages/ctl/src/commands_controller.ts index 4bfbd41e..a6684abd 100644 --- a/packages/ctl/src/commands_controller.ts +++ b/packages/ctl/src/commands_controller.ts @@ -28,9 +28,14 @@ controllerCommands.add(new lib.Command({ controllerCommands.add(new lib.Command({ definition: ["update", "Update the controller", (yargs) => { yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" }); }], - handler: async function(args: { restart: boolean }, control: Control) { - await control.send(new lib.ControllerUpdateRequest()); + handler: async function(args: { restart: boolean, all: boolean }, control: Control) { + if (args.all) { + await control.send(new lib.UpdateAllRequest()); + } else { + await control.send(new lib.ControllerUpdateRequest()); + } if (args.restart) { await control.send(new lib.ControllerRestartRequest()); } else { diff --git a/packages/ctl/src/commands_host.ts b/packages/ctl/src/commands_host.ts index e2ad8283..fe408679 100644 --- a/packages/ctl/src/commands_host.ts +++ b/packages/ctl/src/commands_host.ts @@ -101,10 +101,15 @@ hostCommands.add(new lib.Command({ definition: ["update ", "Update the host", (yargs) => { yargs.positional("host", { describe: "Host to update", type: "string" }); yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" }); }], - handler: async function(args: { host: string, restart: boolean }, control: Control) { + handler: async function(args: { host: string, restart: boolean, all: boolean }, control: Control) { let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.HostUpdateRequest()); + if (args.all) { + await control.sendTo({ hostId }, new lib.UpdateAllRequest()); + } else { + await control.sendTo({ hostId }, new lib.HostUpdateRequest()); + } if (args.restart) { await control.sendTo({ hostId }, new lib.HostRestartRequest()); } else { diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 1af79a92..318a4027 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -404,6 +404,7 @@ export default class Host extends lib.Link { 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.UpdateAllRequest, this.handleUpdateAllRequest.bind(this)); this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this)); @@ -1151,6 +1152,16 @@ export default class Host extends lib.Link { return await lib.handlePluginInstall(request.pluginPackage); } + async handleUpdateAllRequest(request: lib.UpdateAllRequest) { + if (!this.config.get("host.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + if (!this.config.get("host.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos); + } + async handlePluginListRequest(request: lib.PluginListRequest) { return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, diff --git a/packages/lib/src/data/messages_plugin.ts b/packages/lib/src/data/messages_plugin.ts index 2c2a114c..9afc701a 100644 --- a/packages/lib/src/data/messages_plugin.ts +++ b/packages/lib/src/data/messages_plugin.ts @@ -1,6 +1,8 @@ import { Type, Static } from "@sinclair/typebox"; import { jsonArray } from "./composites.js"; import { PluginNodeEnvInfo } from "../plugin.js"; +import { Address, MessageRequest } from "./messages_core.js"; +import type { IUser } from "./UserDetails.js"; /* This is similar to other plugin definitions but with lots removed */ export class PluginDetails { @@ -91,3 +93,14 @@ export class PluginInstallRequest { return this.pluginPackage; } } + +export class UpdateAllRequest { + declare ["constructor"]: typeof UpdateAllRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission(user: IUser, message: MessageRequest) { + user.checkPermission(message.dst.type === Address.host ? "core.host.update" : "core.controller.update"); + user.checkPermission("core.plugin.update"); + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index dbdac526..b3c856d5 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -140,4 +140,5 @@ export const dataClasses: (RequestClass | EventClass) plugin.PluginListRequest, plugin.PluginUpdateRequest, plugin.PluginInstallRequest, + plugin.UpdateAllRequest, ]; diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts index 92f32adc..241b2b01 100644 --- a/packages/lib/src/rce_ops.ts +++ b/packages/lib/src/rce_ops.ts @@ -19,8 +19,8 @@ async function logExec(cmd: string) { } } -export async function updatePackage(name: string) { - return logExec(`npm update --save ${name}`); +export async function updatePackage(...names: string[]) { + return logExec(`npm update --save ${names.join(" ")}`); } export async function installPackage(name: string) { @@ -35,6 +35,13 @@ export async function handlePluginUpdate(pluginName: string, pluginInfos: Plugin return await updatePackage(pluginName); } +export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNodeEnvInfo[]) { + const pluginPackages = pluginInfos + .map(plugin => plugin.npmPackage) + .filter((name): name is string => name !== undefined); + 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 diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index 1a5a83e2..e543cb43 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -27,6 +27,17 @@ function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) { ; } +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([]); @@ -128,6 +139,8 @@ export function NpmButton(props: { } }, [hostId]); + const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update` as const; + function onValuesChange({ action } : { action?: string }) { if (action) { setFormAction(action); @@ -175,10 +188,17 @@ export function NpmButton(props: {
- {account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.update`) + {account.hasPermission(coreUpdatePermission) ? Update Clusterio : undefined} + {account.hasPermission(coreUpdatePermission) && account.hasPermission("core.plugin.update") + ? + Update All + : undefined} {account.hasPermission("core.plugin.update") ? Update Plugin @@ -198,6 +218,8 @@ export function NpmButton(props: { } {formAction === "remote_update" ? : undefined} + {formAction === "update_all" + ? : undefined} {formAction === "plugin_update" ? : undefined} {formAction === "plugin_install" diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 7ef99463..b2388b5c 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -521,6 +521,9 @@ describe("Integration of Clusterio", function() { it("runs", async function() { await execCtl("controller update"); }); + it("accepts --all", async function() { + await execCtl("controller update --all"); + }); it("accepts --restart", async function() { // We cannot restart the controller, so we check for controller error instead await assert.rejects( @@ -603,6 +606,9 @@ describe("Integration of Clusterio", function() { it("runs", async function() { await execCtl("host update 4"); }); + it("accepts --all", async function() { + await execCtl("host update 4 --all"); + }); it("accepts --restart", async function() { // We cannot restart the host, so we check for host error instead await assert.rejects( diff --git a/test/lib/rce_ops.js b/test/lib/rce_ops.js index dc3c56d3..8ba32661 100644 --- a/test/lib/rce_ops.js +++ b/test/lib/rce_ops.js @@ -28,6 +28,20 @@ describe("rce_ops", function() { }); }); + describe("handleUpdateAll", function() { + it("updates the core package and all npm plugins in one command", async function() { + const _audit = lib.logger.audit; + const messages = []; + lib.logger.audit = (msg) => { messages.push(msg); }; + try { + await lib.handleUpdateAll("core", [{ npmPackage: "foo" }, { name: "local" }, { npmPackage: "bar" }]); + } finally { + lib.logger.audit = _audit; + } + assert.deepEqual(messages, ["RCE | npm update --save core foo bar"]); + }); + }); + describe("handlePluginInstall", function() { const _fetch = global.fetch; before(function() { diff --git a/test/messages/plugin.test.js b/test/messages/plugin.test.js index 7935cd3c..26d365fe 100644 --- a/test/messages/plugin.test.js +++ b/test/messages/plugin.test.js @@ -133,6 +133,61 @@ describe("messages/plugin", function() { }); }); + describe("UpdateAllRequest", function() { + function checkedPermissions(dst) { + const permissions = []; + const user = { checkPermission(permission) { permissions.push(permission); } }; + lib.UpdateAllRequest.permission(user, { dst: lib.Address.fromShorthand(dst) }); + return permissions; + } + it("requires controller and plugin update permissions on the controller", function() { + assert.deepEqual(checkedPermissions("controller"), ["core.controller.update", "core.plugin.update"]); + }); + it("requires host and plugin update permissions on a host", function() { + assert.deepEqual(checkedPermissions({ hostId: 1 }), ["core.host.update", "core.plugin.update"]); + }); + it("runs on the controller", async function() { + controller.pluginInfos = [{ npmPackage: "foo" }]; + controller.config.set("controller.allow_remote_updates", true); + controller.config.set("controller.allow_plugin_updates", true); + await controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()); + }); + it("rejects if remote updates are disabled on the controller", async function() { + controller.config.set("controller.allow_remote_updates", false); + await assert.rejects( + controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Remote updates are disabled on this machine/ + ); + }); + it("rejects if plugin updates are disabled on the controller", async function() { + controller.config.set("controller.allow_plugin_updates", false); + await assert.rejects( + controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Plugin updates are disabled on this machine/ + ); + }); + it("runs on a host", async function() { + host.pluginInfos = [{ npmPackage: "foo" }]; + host.config.set("host.allow_remote_updates", true); + host.config.set("host.allow_plugin_updates", true); + await host.handleUpdateAllRequest(new lib.UpdateAllRequest()); + }); + it("rejects if remote updates are disabled on the host", async function() { + host.config.set("host.allow_remote_updates", false); + await assert.rejects( + host.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Remote updates are disabled on this machine/ + ); + }); + it("rejects if plugin updates are disabled on the host", async function() { + host.config.set("host.allow_plugin_updates", false); + await assert.rejects( + host.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Plugin updates are disabled on this machine/ + ); + }); + }); + describe("PluginInstallRequest", function() { const _fetch = global.fetch; before(function() {