diff --git a/common/src/main/java/com/discordsrv/common/command/game/abstraction/GameCommandArguments.java b/common/src/main/java/com/discordsrv/common/command/game/abstraction/GameCommandArguments.java index 7b74316c..aa3cfb1c 100644 --- a/common/src/main/java/com/discordsrv/common/command/game/abstraction/GameCommandArguments.java +++ b/common/src/main/java/com/discordsrv/common/command/game/abstraction/GameCommandArguments.java @@ -23,6 +23,10 @@ public interface GameCommandArguments { T get(String label, Class type); + default String getString(String label) { + return get(label, String.class); + } + default Integer getInt(String label) { return get(label, Integer.class); } diff --git a/common/src/main/java/com/discordsrv/common/command/game/command/DiscordSRVCommand.java b/common/src/main/java/com/discordsrv/common/command/game/command/DiscordSRVCommand.java index 2e562e7b..857b5f89 100644 --- a/common/src/main/java/com/discordsrv/common/command/game/command/DiscordSRVCommand.java +++ b/common/src/main/java/com/discordsrv/common/command/game/command/DiscordSRVCommand.java @@ -23,6 +23,7 @@ import com.discordsrv.common.DiscordSRV; import com.discordsrv.common.command.game.abstraction.GameCommand; import com.discordsrv.common.command.game.abstraction.GameCommandArguments; import com.discordsrv.common.command.game.abstraction.GameCommandExecutor; +import com.discordsrv.common.command.game.command.subcommand.DebugCommand; import com.discordsrv.common.command.game.command.subcommand.LinkCommand; import com.discordsrv.common.command.game.command.subcommand.ReloadCommand; import com.discordsrv.common.command.game.command.subcommand.VersionCommand; @@ -38,6 +39,7 @@ public class DiscordSRVCommand implements GameCommandExecutor { INSTANCE = GameCommand.literal("discordsrv") .requiredPermission("discordsrv.player.command") .executor(new DiscordSRVCommand(discordSRV)) + .then(DebugCommand.get(discordSRV)) .then(LinkCommand.get(discordSRV)) .then(ReloadCommand.get(discordSRV)) .then(VersionCommand.get(discordSRV)); diff --git a/common/src/main/java/com/discordsrv/common/command/game/command/subcommand/DebugCommand.java b/common/src/main/java/com/discordsrv/common/command/game/command/subcommand/DebugCommand.java new file mode 100644 index 00000000..03f4d5d9 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/command/game/command/subcommand/DebugCommand.java @@ -0,0 +1,136 @@ +/* + * This file is part of DiscordSRV, licensed under the GPLv3 License + * Copyright (c) 2016-2022 Austin "Scarsz" Shapiro, Henri "Vankka" Schubin and DiscordSRV contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.discordsrv.common.command.game.command.subcommand; + +import com.discordsrv.common.DiscordSRV; +import com.discordsrv.common.command.game.abstraction.GameCommand; +import com.discordsrv.common.command.game.abstraction.GameCommandArguments; +import com.discordsrv.common.command.game.abstraction.GameCommandExecutor; +import com.discordsrv.common.command.game.sender.ICommandSender; +import com.discordsrv.common.debug.DebugReport; +import com.discordsrv.common.paste.Paste; +import com.discordsrv.common.paste.PasteService; +import com.discordsrv.common.paste.service.AESEncryptedPasteService; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Collections; +import java.util.Locale; + +public class DebugCommand implements GameCommandExecutor { + + private static GameCommand INSTANCE; + + public static GameCommand get(DiscordSRV discordSRV) { + if (INSTANCE == null) { + DebugCommand debugCommand = new DebugCommand(discordSRV); + INSTANCE = GameCommand.literal("debug") + .requiredPermission("discordsrv.admin.debug") + .executor(debugCommand) + .then( + GameCommand.stringWord("zip") + .suggester((sender, previousArguments, currentInput) -> + "zip".startsWith(currentInput.toLowerCase(Locale.ROOT)) + ? Collections.singletonList("zip") : Collections.emptyList()) + .executor(debugCommand) + ); + } + + return INSTANCE; + } + + private static final String URL_FORMAT = "https://discordsrv.vankka.dev/debug/%s#%s"; + private static final Base64.Encoder KEY_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private final DiscordSRV discordSRV; + private final PasteService pasteService; + + public DebugCommand(DiscordSRV discordSRV) { + this.discordSRV = discordSRV; + this.pasteService = new AESEncryptedPasteService(null /* TODO: tbd */, 128); + } + + @Override + public void execute(ICommandSender sender, GameCommandArguments arguments) { + boolean usePaste = !"zip".equals(arguments.getString("zip")); + + discordSRV.scheduler().run(() -> { + DebugReport report = new DebugReport(discordSRV); + report.generate(); + + Throwable pasteError = usePaste ? paste(sender, report) : null; + if (usePaste && pasteError == null) { + // Success + return; + } + + Throwable zipError = zip(sender, report); + if (zipError == null) { + // Success + if (usePaste) { + discordSRV.logger().warning("Failed to upload debug, zip generation succeeded", pasteError); + } + return; + } + + if (pasteError != null) { + zipError.addSuppressed(pasteError); + } + discordSRV.logger().error(usePaste ? "Failed to upload & zip debug" : "Failed to zip debug", zipError); + sender.sendMessage(Component.text( + usePaste + ? "Failed to upload debug report to paste & failed to generate zip" + : "Failed to create debug zip", + NamedTextColor.DARK_RED + )); + }); + } + + private Throwable paste(ICommandSender sender, DebugReport report) { + try { + Paste paste = report.upload(pasteService); + String key = new String(KEY_ENCODER.encode(paste.decryptionKey()), StandardCharsets.UTF_8); + String url = String.format(URL_FORMAT, paste.id(), key); + + sender.sendMessage(Component.text(url).clickEvent(ClickEvent.openUrl(url))); + return null; + } catch (Throwable e) { + return e; + } + } + + private Throwable zip(ICommandSender sender, DebugReport report) { + try { + Path zip = report.zip(); + Path relative = discordSRV.dataDirectory().resolve("../..").relativize(zip); + sender.sendMessage( + Component.text("Debug generated to zip: ", NamedTextColor.GRAY) + .append(Component.text(relative.toString(), NamedTextColor.GREEN)) + ); + return null; + } catch (Throwable e) { + return e; + } + } + +} diff --git a/common/src/main/java/com/discordsrv/common/debug/DebugReport.java b/common/src/main/java/com/discordsrv/common/debug/DebugReport.java index fa32b983..bcffaf2c 100644 --- a/common/src/main/java/com/discordsrv/common/debug/DebugReport.java +++ b/common/src/main/java/com/discordsrv/common/debug/DebugReport.java @@ -22,20 +22,30 @@ import com.discordsrv.common.DiscordSRV; import com.discordsrv.common.debug.file.DebugFile; import com.discordsrv.common.debug.file.KeyValueDebugFile; import com.discordsrv.common.debug.file.TextDebugFile; +import com.discordsrv.common.paste.Paste; +import com.discordsrv.common.paste.PasteService; import com.discordsrv.common.plugin.Plugin; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; +import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.FileStore; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; public class DebugReport { + private static final int BIG_FILE_SPLIT_SIZE = 20000; + private final List files = new ArrayList<>(); private final DiscordSRV discordSRV; @@ -53,18 +63,65 @@ public class DebugReport { } } + public Paste upload(PasteService service) throws Throwable { + files.sort(Comparator.comparing(DebugFile::order).reversed()); + + ArrayNode files = discordSRV.json().createArrayNode(); + for (DebugFile file : this.files) { + int length = file.content().length(); + if (length >= BIG_FILE_SPLIT_SIZE) { + ObjectNode node = discordSRV.json().createObjectNode(); + node.put("name", file.name()); + try { + Paste paste = service.uploadFile(convertToJson(file).toString().getBytes(StandardCharsets.UTF_8)); + node.put("url", paste.url()); + node.put("decryption_key", new String(Base64.getUrlEncoder().encode(paste.decryptionKey()), StandardCharsets.UTF_8)); + node.put("length", length); + } catch (Throwable e) { + node.put("content", "Failed to upload file\n\n" + ExceptionUtils.getStackTrace(e)); + } + files.add(node); + continue; + } + + files.add(convertToJson(file)); + } + + return service.uploadFile(files.toString().getBytes(StandardCharsets.UTF_8)); + } + + public Path zip() throws Throwable { + Path zipPath = discordSRV.dataDirectory().resolve("debug-" + System.currentTimeMillis() + ".zip"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(new FileOutputStream(zipPath.toFile()))) { + for (DebugFile file : files) { + zipOutputStream.putNextEntry(new ZipEntry(file.name())); + + byte[] data = file.content().getBytes(StandardCharsets.UTF_8); + zipOutputStream.write(data, 0, data.length); + zipOutputStream.closeEntry(); + } + } + + return zipPath; + } + + private ObjectNode convertToJson(DebugFile file) { + ObjectNode node = discordSRV.json().createObjectNode(); + node.put("name", file.name()); + node.put("content", file.content()); + return node; + } + public void addFile(DebugFile file) { files.add(file); } - public List getFiles() { - return files; - } - private DebugFile environment() { Map values = new LinkedHashMap<>(); - values.put("discordSRV", discordSRV.getClass().getSimpleName()); + values.put("discordSRV", discordSRV.getClass().getName()); values.put("version", discordSRV.version()); + values.put("gitRevision", discordSRV.gitRevision()); + values.put("gitBranch", discordSRV.gitBranch()); values.put("status", discordSRV.status().name()); values.put("jdaStatus", discordSRV.jda().map(jda -> jda.getStatus().name()).orElse("JDA null")); values.put("platformLogger", discordSRV.platformLogger().getClass().getName()); @@ -78,17 +135,50 @@ public class DebugReport { + " " + System.getProperty("os.version") + " (" + System.getProperty("os.arch") + ")"); + Runtime runtime = Runtime.getRuntime(); + values.put("cores", runtime.availableProcessors()); + values.put("freeMemory", runtime.freeMemory()); + values.put("totalMemory", runtime.totalMemory()); + long maxMemory = runtime.maxMemory(); + values.put("maxMemory", maxMemory == Long.MAX_VALUE ? -1 : maxMemory); + + try { + FileStore store = Files.getFileStore(discordSRV.dataDirectory()); + values.put("usableSpace", store.getUsableSpace()); + values.put("totalSpace", store.getTotalSpace()); + } catch (IOException ignored) {} + + boolean docker = false; + try { + docker = Files.readAllLines(Paths.get("/proc/1/cgroup")) + .stream().anyMatch(str -> str.contains("/docker/")); + } catch (IOException ignored) {} + values.put("docker", docker); + return new KeyValueDebugFile(10, "environment.json", values); } private DebugFile plugins() { - List plugins = discordSRV.pluginManager().getPlugins(); + List plugins = discordSRV.pluginManager().getPlugins() + .stream() + .sorted(Comparator.comparing(plugin -> plugin.name().toLowerCase(Locale.ROOT))) + .collect(Collectors.toList()); + + int longestName = 0; + int longestVersion = 0; + for (Plugin plugin : plugins) { + longestName = Math.max(longestName, plugin.name().length()); + longestVersion = Math.max(longestVersion, plugin.version().length()); + } + + longestName++; + longestVersion++; StringBuilder builder = new StringBuilder("Plugins (" + plugins.size() + "):\n"); for (Plugin plugin : plugins) { builder.append('\n') - .append(plugin.name()) - .append(" v").append(plugin.version()) + .append(StringUtils.rightPad(plugin.name(), longestName)) + .append(" v").append(StringUtils.rightPad(plugin.version(), longestVersion)) .append(" ").append(plugin.authors()); } return new TextDebugFile(5, "plugins.txt", builder.toString());