diff --git a/CHANGELOG.md b/CHANGELOG.md index db398cca..39386312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ - Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins. - Added centralized logging for the cluster. All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried. -- Added remote save management with the ability to list, create, rename, copy, upload, download, delete and load saves for instances. +- Added remote save management with the ability to list, create, rename, copy, upload, download, transfer, delete and load saves for instances. ### Features diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 0f6dbedf..41454c3d 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -659,6 +659,34 @@ instanceSaveCommands.add(new libCommand.Command({ }, })); +instanceSaveCommands.add(new libCommand.Command({ + definition: [ + "transfer [target-save]", + "Transfer a save between instances", + (yargs) => { + yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" }); + yargs.positional("source-save", { describe: "Save to transfer.", type: "string" }); + yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" }); + yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" }); + yargs.options({ + "copy": { describe: "Copy instead of moving the save", type: "boolean", default: false }, + }); + }, + ], + handler: async function(args, control) { + let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance); + let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance); + let result = await libLink.messages.transferSave.send(control, { + instance_id: sourceInstanceId, + source_save: args.sourceSave, + target_instance_id: targetInstanceId, + target_save: args.targetSave || args.sourceSave, + copy: args.copy, + }); + print(`Transferred as ${result.save} to ${args.targetInstance}.`); + }, +})); + instanceSaveCommands.add(new libCommand.Command({ definition: ["download ", "Download a save from an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to download save from", type: "string" }); diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 13f5180a..dd187266 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -595,6 +595,22 @@ messages.downloadSave = new Request({ }, }); +messages.transferSave = new Request({ + type: "transfer_save", + links: ["control-master", "master-slave"], + permission: "core.instance.save.transfer", + requestProperties: { + "instance_id": { type: "integer" }, + "source_save": { type: "string" }, + "target_instance_id": { type: "integer" }, + "target_save": { type: "string" }, + "copy": { type: "boolean" }, + }, + responseProperties: { + "save": { type: "string" }, + }, +}); + messages.pullSave = new Request({ type: "pull_save", links: ["master-slave"], diff --git a/packages/lib/users.js b/packages/lib/users.js index a5539b7c..8432191b 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -412,6 +412,11 @@ definePermission({ title: "Upload save", description: "Upload savegames to instances.", }); +definePermission({ + name: "core.instance.save.transfer", + title: "Transfer save", + description: "Transfer savegames between instances.", +}); definePermission({ name: "core.instance.save.download", title: "Download save", diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index 5a10a780..05a31320 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -1,5 +1,6 @@ "use strict"; const jwt = require("jsonwebtoken"); +const events = require("events"); const libConfig = require("@clusterio/lib/config"); const libErrors = require("@clusterio/lib/errors"); @@ -373,6 +374,85 @@ class ControlConnection extends BaseConnection { return { stream_id: stream.id }; } + async transferSaveRequestHandler(message, request) { + let { source_save, target_save, copy, instance_id, target_instance_id } = message.data; + if (copy) { + this.user.checkPermission("core.instance.save.copy"); + } else if (source_save !== target_save) { + this.user.checkPermission("core.instance.save.rename"); + } + + if (instance_id === target_instance_id) { + throw new libErrors.RequestError("Source and target instance may not be the same"); + } + let sourceInstance = this._master.getRequestInstance(instance_id); + let targetInstance = this._master.getRequestInstance(target_instance_id); + let sourceSlaveId = sourceInstance.config.get("instance.assigned_slave"); + let targetSlaveId = targetInstance.config.get("instance.assigned_slave"); + if (sourceSlaveId === null) { + throw new libErrors.RequestError("Source instance is not assigned a slave"); + } + if (targetSlaveId === null) { + throw new libErrors.RequestError("Target instance is not assigned a slave"); + } + + // Let slave handle request if source and target is on the same slave. + if (sourceSlaveId === targetSlaveId) { + return await this.forwardRequestToInstance(message, request); + } + + // Check connectivity + let sourceSlaveConnection = this._master.wsServer.slaveConnections.get(sourceSlaveId); + if (!sourceSlaveConnection || sourceSlaveConnection.closing) { + throw new libErrors.RequestError("Source slave is not connected to the master server"); + } + + let targetSlaveConnection = this._master.wsServer.slaveConnections.get(targetSlaveId); + if (!targetSlaveConnection || targetSlaveConnection.closing) { + throw new libErrors.RequestError("Target slave is not connected to the master server"); + } + + // Create stream to proxy from target to source + let stream = await routes.createProxyStream(this._master.app); + stream.events.on("timeout", () => { + if (stream.source) { + stream.source.destroy(); + } + stream.events.emit("error", new libErrors.RequestError("Timed out establishing transfer stream")); + }); + + // Ignore errors if not listening for them to avoid crash. + stream.events.on("error", () => { /* ignore */ }); + + // Establish push from source slave to stream, this is done first to + // ensure the file size is known prior to the target slave pull. + await Promise.all([ + this._master.forwardRequestToInstance(libLink.messages.pushSave, { + instance_id, + stream_id: stream.id, + save: source_save, + }), + events.once(stream.events, "source"), + ]); + + // Establish pull from target slave to stream and wait for completion. + let result = await this._master.forwardRequestToInstance(libLink.messages.pullSave, { + instance_id: target_instance_id, + stream_id: stream.id, + filename: target_save, + }); + + // Delete source save if this is not a copy + if (!copy) { + await this._master.forwardRequestToInstance(libLink.messages.deleteSave, { + instance_id, + save: source_save, + }); + } + + return { save: result.save }; + } + async setLogSubscriptionsRequestHandler(message) { this.logSubscriptions = message.data; this.updateLogSubscriptions(); diff --git a/packages/slave/slave.js b/packages/slave/slave.js index 61e4f110..db3d0281 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -1489,6 +1489,45 @@ class Slave extends libLink.Link { await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); } + async transferSaveRequestHandler(message) { + let { source_save, target_save, copy, instance_id, target_instance_id } = message.data; + checkRequestSaveName(source_save); + checkRequestSaveName(target_save); + let sourceInstanceInfo = this.getRequestInstanceInfo(instance_id); + let targetInstanceInfo = this.getRequestInstanceInfo(target_instance_id); + + // For consistency with remote transfer initiated through pullSave the + // target is renamed if it already exists. + target_save = await libFileOps.findUnusedName( + path.join(targetInstanceInfo.path, "saves"), target_save, ".zip" + ); + + try { + if (copy) { + await fs.copy( + path.join(sourceInstanceInfo.path, "saves", source_save), + path.join(targetInstanceInfo.path, "saves", target_save), + { overwrite: true }, + ); + } else { + await fs.move( + path.join(sourceInstanceInfo.path, "saves", source_save), + path.join(targetInstanceInfo.path, "saves", target_save), + { overwrite: true }, + ); + } + } catch (err) { + if (err.code === "ENOENT") { + throw new libErrors.RequestError(`${source_save} does not exist`); + } + throw err; + } + await this.sendSaveListUpdate(instance_id, path.join(sourceInstanceInfo.path, "saves")); + await this.sendSaveListUpdate(target_instance_id, path.join(targetInstanceInfo.path, "saves")); + + return { save: target_save }; + } + async sendSaveListUpdate(instance_id, savesDir) { let instanceConnection = this.instanceConnections.get(instance_id); let saveList; diff --git a/packages/web_ui/src/components/SavesList.jsx b/packages/web_ui/src/components/SavesList.jsx index e63524fa..133cffac 100644 --- a/packages/web_ui/src/components/SavesList.jsx +++ b/packages/web_ui/src/components/SavesList.jsx @@ -1,5 +1,8 @@ import React, { useContext, useState } from "react"; -import { Button, Form, Input, List, Modal, Popconfirm, Progress, Space, Table, Tooltip, Upload } from "antd"; +import { + message, Button, Checkbox, Form, Input, List, Modal, + Popconfirm, Progress, Select, Space, Table, Tooltip, Upload, +} from "antd"; import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined"; import LeftOutlined from "@ant-design/icons/LeftOutlined"; @@ -9,6 +12,7 @@ import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; import CreateSaveModal from "./CreateSaveModal"; import SectionHeader from "./SectionHeader"; +import { useInstanceList } from "../model/instance"; import { useSaves } from "../model/saves"; import { notifyErrorHandler } from "../util/notify"; @@ -91,6 +95,90 @@ function CopyModal(props) { ; } +function TransferModal(props) { + let account = useAccount(); + let control = useContext(ControlContext); + let [visible, setVisible] = useState(false); + let [form] = Form.useForm(); + let [instanceList] = useInstanceList(); + + return <> + + form.submit()} + onCancel={() => setVisible(false)} + destroyOnClose + > +
{ + setVisible(false); + let hide = message.loading("Transferring save...", 0); + libLink.messages.transferSave.send(control, { + instance_id: props.instanceId, + source_save: props.save.name, + target_instance_id: values.targetInstanceId, + target_save: values.transferredName || props.save.name, + copy: values.copy, + }).then(() => { + message.success("Transfer complete"); + form.resetFields(); + }).catch( + notifyErrorHandler("Error transfering save") + ).finally(() => hide()); + }} + > + + + + + + + + + +
+
+ ; +} + function formatBytes(bytes) { if (bytes === 0) { return "0 Bytes"; @@ -179,6 +267,9 @@ export default function SavesList(props) { ); }} >Download} + {account.hasPermission("core.instance.save.transfer") && } {account.hasPermission("core.instance.save.delete") &&