From 394a4d986d707bb4cc273ecd6fa547acb3bd70f5 Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Mon, 29 Sep 2025 11:23:32 +0800 Subject: [PATCH] Implement some UI --- build.gradle | 4 + src/main/java/io/ib67/sfcraft/Backup.java | 7 + .../java/io/ib67/sfcraft/BackupCommands.java | 246 +++++++++++------- .../java/io/ib67/sfcraft/JustBackupMod.java | 21 +- .../ib67/sfcraft/config/JustBackupConfig.java | 4 + .../sfcraft/strategy/S3BackupStrategy.java | 112 ++++++++ 6 files changed, 298 insertions(+), 96 deletions(-) create mode 100644 src/main/java/io/ib67/sfcraft/strategy/S3BackupStrategy.java diff --git a/build.gradle b/build.gradle index 097e1be..8b3145b 100644 --- a/build.gradle +++ b/build.gradle @@ -65,12 +65,16 @@ dependencies { modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" // https://mvnrepository.com/artifact/software.amazon.awssdk/s3 shadow project(":bundler") + shadow 'software.amazon.awssdk:s3:2.34.0' } configurations.implementation.extendsFrom(configurations.shadow) shadowJar { configurations = [project.configurations.shadow] exclude("META-INF") + dependencies { + exclude(dependency("software.amazon.awssdk:aws-xml-protocol:.*")) + } minimize() //relocate 'com.google', 'sfcraft.relocation.google' } diff --git a/src/main/java/io/ib67/sfcraft/Backup.java b/src/main/java/io/ib67/sfcraft/Backup.java index ce4704a..306f484 100644 --- a/src/main/java/io/ib67/sfcraft/Backup.java +++ b/src/main/java/io/ib67/sfcraft/Backup.java @@ -1,5 +1,7 @@ package io.ib67.sfcraft; +import net.minecraft.text.Text; + public record Backup( String name, String backupKey, @@ -8,4 +10,9 @@ public record Backup( boolean incremental, long sizeTotal ) { + public Text toText(){ + return Text.of("sizeTotal: "+((sizeTotal / 1024.0) / 1024.0) + "GiB, "+ + (incremental ? "incremental" :"") + ", type: "+type+"." + ); + } } diff --git a/src/main/java/io/ib67/sfcraft/BackupCommands.java b/src/main/java/io/ib67/sfcraft/BackupCommands.java index 9a2a39c..f5b9b51 100644 --- a/src/main/java/io/ib67/sfcraft/BackupCommands.java +++ b/src/main/java/io/ib67/sfcraft/BackupCommands.java @@ -2,10 +2,12 @@ package io.ib67.sfcraft; import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.BoolArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import lombok.extern.log4j.Log4j2; import net.minecraft.command.CommandRegistryAccess; import net.minecraft.server.command.CommandManager; import net.minecraft.server.command.ServerCommandSource; @@ -13,107 +15,43 @@ import net.minecraft.text.ClickEvent; import net.minecraft.text.Text; import java.awt.*; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import static net.minecraft.server.command.CommandManager.argument; import static net.minecraft.server.command.CommandManager.literal; +@Log4j2 public class BackupCommands { protected final JustBackupMod mod; protected Runnable restoreIssued; + protected final List helpMessage = List.of( + "__ Help messages of JustBackup __", + " /backup help ", + " -- Show this message", + " /backup list ", + " -- List backups", + " /backup restore ", + " -- Restore the backup on next server restart.", + " Can be cancelled by /backup cancelrestore", + " /backup delete ", + " -- Delete a backup. This does not ask for a confirmation.", + " /backup create ", + " -- Create a backup.", + " Options:", + " incremental: Only bundle files detected changes", + " subject: specify a subject to backup. Or use \"@all\" to backup all subjects.", + " /backup suspend", + " -- Temporarily stops the auto-backup worker, until next restart or ", + " typing this command again." + ); public BackupCommands(JustBackupMod mod) { this.mod = mod; } - public int cmdList(CommandContext ctx) { - var s = ctx.getSource(); - s.sendFeedback(() -> Text.of("Tracked backups:"), false); - for (Backup value : mod.tracker.getTrackedBackups().values()) { - var restoreClick = Text.literal("[RESTORE]").withColor(Color.GREEN.getRGB()) - .styled(it -> it.withClickEvent(new ClickEvent.SuggestCommand("/backup restore " + value.backupKey()))); - var deleteClick = Text.literal("[DELETE]").withColor(Color.RED.getRGB()) - .styled(it -> it.withClickEvent(new ClickEvent.SuggestCommand("/backup delete " + value.backupKey()))); - var message = Text.literal(" - " + value.name() + " (" + value.backupKey() + ") " + - (value.incremental() ? "incremental" : "full") + " ").append(restoreClick).append(" ").append(deleteClick); - s.sendMessage(message); - s.sendMessage(Text.of(" Size: " + Math.floorDiv(value.sizeTotal(), 1024 * 1024) + "MiB")); - } - return Command.SINGLE_SUCCESS; - } - - public int cmdDelete(CommandContext ctx) { - var _backup = ctx.getArgument("backupName", String.class); - var backup = mod.tracker.getTrackedBackups().get(_backup); - mod.tracker.deleteBackup(backup); - ctx.getSource().sendMessage(Text.of("Backup " + _backup + " is deleted successfully.")); - return Command.SINGLE_SUCCESS; - } - - public int cmdSuspend(CommandContext ctx) { - mod.suspend = !mod.suspend; - ctx.getSource().sendMessage(Text.of("Backup disabled: " + mod.suspend)); - return Command.SINGLE_SUCCESS; - } - - public int cmdRestore(CommandContext ctx) { - var s = ctx.getSource(); - var pm = s.getServer().getPlayerManager(); - var _backup = ctx.getArgument("backupName", String.class); - var backup = mod.tracker.getTrackedBackups().get(_backup); - if (backup == null) { - s.sendMessage(Text.of("Invalid backup " + _backup)); - return 0; - } - if (restoreIssued != null) { - s.sendMessage(Text.of("Another restoration is in progress. try /backup cancel to cancel it")); - return 0; - } - s.sendMessage(Text.literal("Please restart your server to take changes")); - restoreIssued = () -> { - System.out.println("Recovering backup... "); - try { - var toOverride = Path.of(backup.from()); - var old = Path.of(backup.from() + "_old"); - if (Files.exists(old)) { - try (var f = Files.walk(old)) { - f.sorted(Comparator.reverseOrder()) - .forEach(it -> { - try { - Files.deleteIfExists(it); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - } - } - Files.deleteIfExists(old); - Files.move(toOverride, old); - System.out.println("Moved your old save to " + old); - mod.tracker.recoverBackup(backup, toOverride); - System.out.println("Recover successfully!"); - } catch (Exception e) { - throw new RuntimeException(e); - } - }; - return Command.SINGLE_SUCCESS; - } - - public int cmdHelp(CommandContext serverCommandSourceCommandContext) { - // todo i18n - return 0; - } - - public int cmdCancelRestore(CommandContext serverCommandSourceCommandContext) { - restoreIssued = null; - serverCommandSourceCommandContext.getSource().sendMessage(Text.of("Operation cancelled.")); - return 0; - } - public void registerCommand( CommandDispatcher dispatcher, CommandRegistryAccess registry, CommandManager.RegistrationEnvironment env) { @@ -130,9 +68,135 @@ public class BackupCommands { .then(argument("backupName", StringArgumentType.greedyString()) .suggests(this::suggestBackups) .executes(this::cmdRestore))) -// .then(literal("create").executes(this::cmdIssueBackupFull)) todo - .then(literal("suspend").executes(this::cmdSuspend)) - ); + .then(literal("create") + .then(argument("incremental", BoolArgumentType.bool()) + .then(argument("subject", StringArgumentType.greedyString()) + .executes(this::createBackupBySubject)) + .then(literal("@all").executes(this::fullBackup))) + .then(literal("suspend").executes(this::cmdSuspend)) + )); + } + + private int createBackupBySubject(CommandContext context) { + var incremental = BoolArgumentType.getBool(context, "incremental"); + var subject = StringArgumentType.getString(context, "subject"); + var s = context.getSource(); + s.sendMessage(Text.literal("The issued backup has been scheduled.").withColor(Color.CYAN.getRGB())); + mod.issueBackup(subject, incremental); + return Command.SINGLE_SUCCESS; + } + + private int fullBackup(CommandContext context) { + var incremental = BoolArgumentType.getBool(context, "incremental"); + var s = context.getSource(); + s.sendMessage(Text.literal("The issued backup has been scheduled.").withColor(Color.CYAN.getRGB())); + var weakRef = new WeakReference<>(s); + mod.backupAll(incremental).thenAccept(result -> { + var ref = weakRef.get(); + if (ref == null) { + ref = mod.server.getCommandSource(); + } + ref.sendMessage(Text.literal("-- BACKUP RESULT SUMMARY --")); + for (var entry : result.entrySet()) { + var k = entry.getKey(); + var v = entry.getValue(); + ref.sendMessage(Text.literal(" - [" + k + "]: ").append(v.toText())); + ref.sendMessage(Text.literal("BACKUP KEY: " + v.backupKey()).withColor(Color.GRAY.getRGB())); + ref.sendMessage(Text.literal("GENERATED FROM: " + v.from()).withColor(Color.GRAY.getRGB())); + } + }); + return Command.SINGLE_SUCCESS; + } + + private int cmdSuspend(CommandContext context) { + var s = context.getSource(); + // this is safe as we are the only writer here. + var suspend = mod.suspend; + mod.suspend = !suspend; + if (suspend) { + s.sendMessage(Text.of("The automatic backup system is now re-enabled.")); + } else { + s.sendMessage(Text.of("The automatic backup system is now disabled.")); + } + return Command.SINGLE_SUCCESS; + } + + private int cmdRestore(CommandContext context) { + var s = context.getSource(); + if (restoreIssued != null) { + s.sendMessage(Text.literal("Another restore request is already present.").withColor(Color.RED.getRGB())); + s.sendMessage(Text.of("TIP: Use `/backup cancelrestore` to cancel that request.")); + return 0; + } + var backupName = StringArgumentType.getString(context, "backupName"); + restoreIssued = () -> { + //todo implementation + }; + return Command.SINGLE_SUCCESS; + } + + private int cmdDelete(CommandContext context) { + var backupName = StringArgumentType.getString(context, "backupName"); + var backup = mod.tracker.getTrackedBackups().get(backupName); + var source = context.getSource(); + if (backup == null) { + source.sendMessage(Text.of("Invalid backup. No backups are named '" + backupName + "'")); + return 0; + } + try { + mod.tracker.deleteBackup(backup); + source.sendMessage(Text.of("Backup has been deleted.")); + } catch (Exception e) { + source.sendMessage(Text.of("Backup has not been deleted successfully. ERROR: " + e)); + log.error(e); + } + return Command.SINGLE_SUCCESS; + } + + private int cmdList(CommandContext context) { + var source = context.getSource(); + var trackedBackups = mod.tracker.getTrackedBackups(); + if (trackedBackups.isEmpty()) { + source.sendMessage(Text.of("No backups are neither created nor tracked yet.")); + return Command.SINGLE_SUCCESS; + } + source.sendMessage(Text.of("ALL TRACKED BACKUPS:")); + for (var entry : trackedBackups.entrySet()) { + var backupKey = entry.getKey(); + var backup = entry.getValue(); + source.sendMessage(Text.literal(" - [" + backup.name() + "] ").append(backup.toText())); + source.sendMessage(Text.literal(" ").append( + Text.literal("[DELETE]").withColor(Color.RED.getRGB()).styled(it -> it.withClickEvent( + new ClickEvent.SuggestCommand("/backup delete " + backupKey) + ))).append(Text.literal(" ")).append( + Text.literal("[RESTORE]").withColor(Color.CYAN.getRGB()).styled(it -> it.withClickEvent( + new ClickEvent.SuggestCommand("/backup restore " + backupKey) + )) + ) + ); + } + return 0; + } + + private int cmdCancelRestore(CommandContext context) { + var source = context.getSource(); + if (restoreIssued != null) { + restoreIssued = null; + source.sendMessage(Text.of("The backup restoration request has been cancelled.")); + return 0; + } + source.sendMessage(Text.of("You have no backup restoration request in flight.")); + return 0; + } + + private int cmdHelp(CommandContext ctx) { + var source = ctx.getSource(); + for (String s : helpMessage) { + if (s.startsWith(" /")) { + source.sendMessage(Text.literal(s).withColor(Color.CYAN.getRGB())); + } + } + return Command.SINGLE_SUCCESS; } private CompletableFuture suggestBackups(CommandContext serverCommandSourceCommandContext, SuggestionsBuilder suggestionsBuilder) { diff --git a/src/main/java/io/ib67/sfcraft/JustBackupMod.java b/src/main/java/io/ib67/sfcraft/JustBackupMod.java index 293fb72..48bb07b 100644 --- a/src/main/java/io/ib67/sfcraft/JustBackupMod.java +++ b/src/main/java/io/ib67/sfcraft/JustBackupMod.java @@ -7,6 +7,7 @@ import io.ib67.sfcraft.config.StorageOption; import io.ib67.sfcraft.strategy.BackupStrategy; import io.ib67.sfcraft.strategy.BackupTracker; import io.ib67.sfcraft.strategy.LocalBackupStrategy; +import io.ib67.sfcraft.strategy.S3BackupStrategy; import io.netty.buffer.ByteBufAllocator; import lombok.SneakyThrows; import net.fabricmc.api.ModInitializer; @@ -19,6 +20,7 @@ import org.jetbrains.annotations.UnknownNullability; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.awt.*; import java.io.IOException; import java.nio.file.*; import java.util.ArrayList; @@ -28,6 +30,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; public class JustBackupMod implements ModInitializer { @@ -104,7 +107,12 @@ public class JustBackupMod implements ModInitializer { if (config.bundleFullAtStartup()) { backupAll(false); } - scheduledBackupExecutor.scheduleAtFixedRate(() -> backupAll(config.incremental()), + scheduledBackupExecutor.scheduleAtFixedRate(() -> { + if (!suspend) backupAll(config.incremental()).thenAccept(it -> { + LOGGER.info("Backup success!"); + it.forEach((k, v) -> LOGGER.info(k + ": " + v)); + }); + }, config.backupIntervalMinutes(), config.backupIntervalMinutes(), TimeUnit.MINUTES); }); ServerLifecycleEvents.SERVER_STOPPED.register(server -> { @@ -127,6 +135,11 @@ public class JustBackupMod implements ModInitializer { public CompletableFuture> backupAll(boolean incremental) { var map = new HashMap>(); + server.getPlayerManager().broadcast( + Text.literal(" BACKUP >> ").withColor(Color.RED.getRGB()).styled(it -> it.withBold(true)) + .append(Text.of("The server is performing a backup, you may experience some lag.")), + false + ); for (var entry : backupSubjects.entrySet()) { map.put(entry.getKey(), issueBackup(entry.getKey(), incremental)); } @@ -139,9 +152,6 @@ public class JustBackupMod implements ModInitializer { } public CompletableFuture issueBackup(String subjectName, boolean incremental) { - if (suspend) { - return CompletableFuture.failedFuture(new IllegalStateException("Backup is temporarily disabled")); - } var subject = backupSubjects.get(subjectName); if (subject == null) return CompletableFuture.failedFuture(new IllegalArgumentException("Unknown backup subject: " + subjectName)); @@ -179,7 +189,7 @@ public class JustBackupMod implements ModInitializer { private BackupStrategy createStrategy() { return switch (config.option()) { case StorageOption.Local local -> new LocalBackupStrategy(local); - case StorageOption.S3 s3 -> throw new IllegalStateException(); + case StorageOption.S3 s3 -> new S3BackupStrategy(s3, Path.of(config.temporaryBackupDir())); }; } @@ -187,6 +197,7 @@ public class JustBackupMod implements ModInitializer { private void saveConfig(Path configPath) { LOGGER.info("Saving default configuration"); var cfg = new JustBackupConfig( + true, true, true, 60, diff --git a/src/main/java/io/ib67/sfcraft/config/JustBackupConfig.java b/src/main/java/io/ib67/sfcraft/config/JustBackupConfig.java index 76e8701..ea29c54 100644 --- a/src/main/java/io/ib67/sfcraft/config/JustBackupConfig.java +++ b/src/main/java/io/ib67/sfcraft/config/JustBackupConfig.java @@ -14,6 +14,7 @@ import java.util.Map; public record JustBackupConfig( boolean bundleFullAtStartup, boolean incremental, + boolean broadcast, long backupIntervalMinutes, int keepBackups, int compressionLevel, @@ -35,5 +36,8 @@ public record JustBackupConfig( log.warn("Backup subjects are not configured. The mod will not work!"); } Validate.notNull(option, "option cannot be null"); + if(backupSubjects.containsKey("@all")){ + throw new IllegalArgumentException("A ambiguous name \"@all\" is not allowed"); + } } } diff --git a/src/main/java/io/ib67/sfcraft/strategy/S3BackupStrategy.java b/src/main/java/io/ib67/sfcraft/strategy/S3BackupStrategy.java new file mode 100644 index 0000000..d84ddae --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/strategy/S3BackupStrategy.java @@ -0,0 +1,112 @@ +package io.ib67.sfcraft.strategy; + +import io.ib67.sfcraft.Backup; +import io.ib67.sfcraft.bundler.BundleReader; +import io.ib67.sfcraft.config.StorageOption; +import lombok.SneakyThrows; +import lombok.extern.log4j.Log4j2; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +@Log4j2 +public class S3BackupStrategy implements BackupStrategy { + protected final AtomicBoolean uploading = new AtomicBoolean(false); + protected final StorageOption.S3 option; + protected final S3Client s3; + protected final Path temporaryDownloadPath; + + @SneakyThrows + public S3BackupStrategy(StorageOption.S3 option, Path tempDownloadPath) { + this.option = option; + var credential = AwsBasicCredentials.create(option.accessKey(), option.secretKey()); + this.s3 = S3Client.builder() + .region(Region.of(option.region())) + .forcePathStyle(option.enforcePathStyle()) + .endpointOverride(URI.create(option.endpoint())) + .credentialsProvider(StaticCredentialsProvider.create(credential)) + .build(); + log.info("Testing S3 credentials"); + // throws exception if the bucket neither inaccessible nor not exist + s3.headBucket(b -> b.bucket(option.bucket())); + this.temporaryDownloadPath = Objects.requireNonNull(tempDownloadPath); + if (Files.notExists(temporaryDownloadPath)) + Files.createDirectories(temporaryDownloadPath); + } + + @Override + @SneakyThrows + public Backup createBackup(String subject, Path from, Path pathToBundle) { + if (!uploading.compareAndSet(false, true)) { + throw new IllegalStateException("Last upload is not done yet! keep waiting..."); + } + int i = 0; + do { + try { + var object = option.prefix() + "/" + pathToBundle.getFileName().toString(); + s3.putObject(o -> o.bucket(option.bucket()) + .contentType("application/octet-stream") + .ifNoneMatch("*") // to avoid content overriding + .key(object).build(), pathToBundle); + return new Backup(pathToBundle.getFileName().toString(), + object, "s3", + from.toString(), + false, + Files.size(pathToBundle)); + } catch (IOException e) { + if (i >= option.retryAmount()) { + throw new IllegalStateException("Cannot upload " + pathToBundle + " to S3"); + } + var nextTry = Math.pow(2, i++); + log.error("Error uploading S3. trying in {}s later. ({}/{})", nextTry, i, option.retryAmount(), e); + Thread.sleep((long) (nextTry * 1000)); + } catch (Exception e) { + throw new IllegalStateException("Unrecoverable exception occurred when uploading " + pathToBundle, e); + } finally { + uploading.set(false); + } + } while (true); + } + + @Override + @SneakyThrows + public void recoverBackup(Backup backup, Path restorePath) { + var resp = s3.getObject(b -> b.bucket(option.bucket()).key(backup.backupKey())); + var target = temporaryDownloadPath.resolve("s3_" + System.currentTimeMillis()); + try (var fs = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + resp.transferTo(fs); + BundleReader.builder().build().extract(target, restorePath); + } finally { + Files.deleteIfExists(target); + } + } + + @Override + @SneakyThrows + public void deleteBackup(Backup backup) { + s3.deleteObject(b -> b.bucket(option.bucket()).key(backup.backupKey()).build()); + } + + @Override + public boolean isAvailable() { + if (uploading.get()) { + log.warn("Last S3 Backup is still uploading!"); + return false; + } + return true; + } + + @Override + public void close() { + s3.close(); + } +}