Merge pull request #998 from bbassie/feature/update-all

Add update all for the controller, hosts and plugins
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-09-12 02:19:04 +01:00
commit 9f67d7ab76
12 files changed
+164 -7

No files matched your search

+7
View File
@@ -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 <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.
### Update All
ctl> controller update --all [--restart]
ctl> host update <host> --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 <name> [--restart]
@@ -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,
+7 -2
View File
@@ -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 {
+7 -2
View File
@@ -101,10 +101,15 @@ hostCommands.add(new lib.Command({
definition: ["update <host>", "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 {
+11
View File
@@ -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,
+13
View File
@@ -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");
}
}
+1
View File
@@ -140,4 +140,5 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
plugin.PluginListRequest,
plugin.PluginUpdateRequest,
plugin.PluginInstallRequest,
plugin.UpdateAllRequest,
];
+9 -2
View File
@@ -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
+23 -1
View File
@@ -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<lib.PluginDetails[]>([]);
@@ -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: {
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
<Form.Item label="Action" name="action">
<Radio.Group value={formAction}>
{account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.update`)
{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
@@ -198,6 +218,8 @@ export function NpmButton(props: {
}
{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"
+6
View File
@@ -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(
+14
View File
@@ -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() {
+55
View File
@@ -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() {